Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 53 additions & 5 deletions .github/workflows/live-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,26 @@ jobs:
docker exec tailstick-live-e2e bash -lc 'ls -la /run || true'
exit 1

- name: Create Linux live auth key
id: linux-key
env:
TAILSTICK_API_KEY: ${{ secrets.TAILSTICK_LIVE_E2E_API_KEY }}
run: |
response="$(
curl -fsS -u "${TAILSTICK_API_KEY}:" \
-H 'Content-Type: application/json' \
-d '{"capabilities":{"devices":{"create":{"reusable":false,"ephemeral":false,"preauthorized":true}}},"expirySeconds":3600,"description":"linux live e2e"}' \
https://api.tailscale.com/api/v2/tailnet/-/keys
)"
auth_key="$(printf '%s' "$response" | jq -r '.key')"
test -n "$auth_key"
test "$auth_key" != "null"
echo "::add-mask::$auth_key"
echo "auth_key=$auth_key" >> "$GITHUB_OUTPUT"

- name: Run Linux live E2E
env:
TAILSTICK_AUTH_KEY: ${{ secrets.TAILSTICK_LIVE_E2E_AUTH_KEY }}
TAILSTICK_AUTH_KEY: ${{ steps.linux-key.outputs.auth_key }}
TAILSTICK_API_KEY: ${{ secrets.TAILSTICK_LIVE_E2E_API_KEY }}
TAILSTICK_OPERATOR_PASSWORD: ${{ secrets.TAILSTICK_LIVE_E2E_OPERATOR_PASSWORD }}
TAILSTICK_BIN: /src/dist/tailstick-linux-cli
Expand All @@ -82,9 +99,9 @@ jobs:
if: failure()
run: |
docker exec tailstick-live-e2e bash -lc 'journalctl --no-pager || true'
docker exec tailstick-live-e2e bash -lc 'cat /tmp/tailstick-live-e2e/tailscaled.log || true'
docker exec tailstick-live-e2e bash -lc 'cat /tmp/tailstick-live-e2e/tailstick.log || true'
docker exec tailstick-live-e2e bash -lc 'cat /tmp/tailstick-live-e2e/state.json || true'
docker exec tailstick-live-e2e bash -lc 'cat /var/tmp/tailstick-live-e2e/tailscaled.log || true'
docker exec tailstick-live-e2e bash -lc 'cat /var/tmp/tailstick-live-e2e/tailstick.log || true'
docker exec tailstick-live-e2e bash -lc 'cat /var/tmp/tailstick-live-e2e/state.json || true'

- name: Stop isolated Linux container
if: always()
Expand Down Expand Up @@ -118,10 +135,41 @@ jobs:
}
Add-Content -Path $env:GITHUB_PATH -Value $tailscalePath

- name: Create Windows live auth key
id: windows-key
shell: pwsh
env:
TAILSTICK_API_KEY: ${{ secrets.TAILSTICK_LIVE_E2E_API_KEY }}
run: |
$tokenBytes = [System.Text.Encoding]::ASCII.GetBytes("${env:TAILSTICK_API_KEY}:")
$headers = @{
Authorization = "Basic $([Convert]::ToBase64String($tokenBytes))"
"Content-Type" = "application/json"
}
$body = @{
capabilities = @{
devices = @{
create = @{
reusable = $false
ephemeral = $true
preauthorized = $true
}
}
}
expirySeconds = 3600
description = "windows live e2e"
} | ConvertTo-Json -Depth 5 -Compress
$response = Invoke-RestMethod -Method Post -Uri "https://api.tailscale.com/api/v2/tailnet/-/keys" -Headers $headers -Body $body
if ([string]::IsNullOrWhiteSpace($response.key)) {
throw "failed to create ephemeral auth key for windows live E2E"
}
Write-Host "::add-mask::$($response.key)"
"ephemeral_auth_key=$($response.key)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8

- name: Run Windows live E2E
shell: pwsh
env:
TAILSTICK_EPHEMERAL_AUTH_KEY: ${{ secrets.TAILSTICK_LIVE_E2E_EPHEMERAL_AUTH_KEY }}
TAILSTICK_EPHEMERAL_AUTH_KEY: ${{ steps.windows-key.outputs.ephemeral_auth_key }}
TAILSTICK_API_KEY: ${{ secrets.TAILSTICK_LIVE_E2E_API_KEY }}
TAILSTICK_OPERATOR_PASSWORD: ${{ secrets.TAILSTICK_LIVE_E2E_OPERATOR_PASSWORD }}
run: ./tests/live/windows-live-e2e.ps1
Expand Down
4 changes: 2 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ File: `.github/workflows/live-e2e.yml`

This workflow is manual by design (`workflow_dispatch`) and uses real Tailscale credentials stored as GitHub Actions secrets:

- `TAILSTICK_LIVE_E2E_AUTH_KEY`
- `TAILSTICK_LIVE_E2E_EPHEMERAL_AUTH_KEY`
- `TAILSTICK_LIVE_E2E_API_KEY`
- `TAILSTICK_LIVE_E2E_OPERATOR_PASSWORD`

It is intentionally separate from the default CI matrix because it creates real tailnet devices and performs real cleanup/delete operations.

Each live lane mints a short-lived auth key from the Tailscale API at runtime so the workflow does not depend on long-lived or previously-consumed auth keys.

### Linux Live E2E

- Runs inside a privileged Ubuntu 24.04 systemd container built from `tests/live/linux-live-e2e.dockerfile`
Expand Down
119 changes: 89 additions & 30 deletions internal/app/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,14 @@ func (m *Manager) AgentRun(ctx context.Context, interval time.Duration) error {
if err := m.AgentOnce(ctx); err != nil {
m.Logger.Error("agent iteration failed: %v", err)
}
st, err := state.Load(m.Runtime.StatePath)
if err != nil {
return err
}
if !hasActiveManagedLeases(st) {
m.Logger.Info("tailstick agent stopping: no active managed leases remain")
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
Expand Down Expand Up @@ -396,18 +404,7 @@ func (m *Manager) installLinuxAgent(ctx context.Context, agentPath string) error
servicePath := "/etc/systemd/system/tailstick-agent.service"
timerPath := "/etc/systemd/system/tailstick-agent.timer"

service := fmt.Sprintf(`[Unit]
Description=TailStick lease agent
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=%q agent run --once --config %q --state %q --audit %q --log %q

[Install]
WantedBy=multi-user.target
`, agentPath, m.Runtime.ConfigPath, m.Runtime.StatePath, m.Runtime.AuditPath, m.Runtime.LogPath)
service := linuxAgentServiceContent(agentPath, m.Runtime)
timer := `[Unit]
Description=TailStick lease agent timer

Expand All @@ -430,11 +427,7 @@ WantedBy=timers.target
if err := os.WriteFile(timerPath, []byte(timer), 0o644); err != nil {
return fmt.Errorf("write systemd timer: %w", err)
}
for _, cmd := range [][]string{
{"systemctl", "daemon-reload"},
{"systemctl", "enable", "--now", "tailstick-agent.timer"},
{"systemctl", "start", "tailstick-agent.service"},
} {
for _, cmd := range linuxAgentInstallCommands() {
if _, err := m.Runner.Run(ctx, cmd); err != nil {
return err
}
Expand All @@ -443,7 +436,23 @@ WantedBy=timers.target
}

func (m *Manager) installWindowsAgent(ctx context.Context, agentPath string) error {
taskCmd := fmt.Sprintf(`"%s" agent run --config "%s" --state "%s" --audit "%s" --log "%s"`, agentPath, m.Runtime.ConfigPath, m.Runtime.StatePath, m.Runtime.AuditPath, m.Runtime.LogPath)
launcherPath := windowsAgentLauncherPath(agentPath)
launcherBody := windowsAgentLauncherContent(agentPath, m.Runtime)
if m.Runtime.DryRun {
m.Logger.Info("[dry-run] would install windows agent launcher at %s", launcherPath)
} else {
if err := platform.EnsureParent(launcherPath); err != nil {
return err
}
if err := os.WriteFile(launcherPath, []byte(launcherBody), 0o644); err != nil {
return fmt.Errorf("write windows agent launcher: %w", err)
}
}

taskCmd := windowsScheduledTaskCommand(launcherPath)
if len(taskCmd) > 261 {
return fmt.Errorf("windows scheduled task target exceeds schtasks /TR limit: %d", len(taskCmd))
}
commands := [][]string{
{"schtasks", "/Create", "/TN", "TailStickAgent-Startup", "/SC", "ONSTART", "/TR", taskCmd, "/RL", "HIGHEST", "/F"},
{"schtasks", "/Create", "/TN", "TailStickAgent-Periodic", "/SC", "MINUTE", "/MO", "1", "/TR", taskCmd, "/RL", "HIGHEST", "/F"},
Expand All @@ -464,7 +473,7 @@ func (m *Manager) uninstallAgent(ctx context.Context) error {
} else {
err = m.uninstallLinuxAgent(ctx)
}
removeErr := m.removeLocalAgentBinary(ctx)
removeErr := m.removeLocalAgentArtifacts(ctx)
if err != nil {
return err
}
Expand Down Expand Up @@ -543,28 +552,78 @@ func (m *Manager) ensureLocalAgentBinary() (string, error) {
return target, nil
}

func (m *Manager) removeLocalAgentBinary(ctx context.Context) error {
target := platform.AgentBinaryPath()
if m.Runtime.DryRun {
m.Logger.Info("[dry-run] would remove local agent binary %s", target)
return nil
func (m *Manager) removeLocalAgentArtifacts(ctx context.Context) error {
targets := []string{platform.AgentBinaryPath()}
if runtime.GOOS == "windows" {
targets = append(targets, windowsAgentLauncherPath(platform.AgentBinaryPath()))
}
err := os.Remove(target)
if err == nil || os.IsNotExist(err) {
if m.Runtime.DryRun {
m.Logger.Info("[dry-run] would remove local agent artifacts %s", strings.Join(targets, ", "))
return nil
}
if runtime.GOOS != "windows" {
err := os.Remove(targets[0])
if err == nil || os.IsNotExist(err) {
return nil
}
return fmt.Errorf("remove local agent binary: %w", err)
}

escaped := strings.ReplaceAll(target, `"`, `\"`)
delCmd := fmt.Sprintf(`start "" /B cmd /C "ping 127.0.0.1 -n 3 >NUL & del /f /q \"%s\""`, escaped)
if _, delayedErr := m.Runner.Run(ctx, []string{"cmd", "/C", delCmd}); delayedErr != nil {
return fmt.Errorf("schedule delayed local agent binary delete: %w", delayedErr)
if _, delayedErr := m.Runner.Run(ctx, windowsDelayedDeleteCommand(targets)); delayedErr != nil {
return fmt.Errorf("schedule delayed local agent artifact delete: %w", delayedErr)
}
return nil
}

func windowsAgentLauncherPath(agentPath string) string {
return filepath.Join(filepath.Dir(agentPath), "agent.cmd")
}

func windowsScheduledTaskCommand(launcherPath string) string {
return fmt.Sprintf(`"%s"`, launcherPath)
}

func windowsAgentLauncherContent(agentPath string, rt Runtime) string {
return strings.Join([]string{
"@echo off",
fmt.Sprintf(`"%s" agent --once --config "%s" --state "%s" --audit "%s" --log "%s"`, agentPath, rt.ConfigPath, rt.StatePath, rt.AuditPath, rt.LogPath),
"",
}, "\r\n")
}

func linuxAgentServiceContent(agentPath string, rt Runtime) string {
return fmt.Sprintf(`[Unit]
Description=TailStick lease agent
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=%q agent --once --config %q --state %q --audit %q --log %q

[Install]
WantedBy=multi-user.target
`, agentPath, rt.ConfigPath, rt.StatePath, rt.AuditPath, rt.LogPath)
}

func linuxAgentInstallCommands() [][]string {
return [][]string{
{"systemctl", "daemon-reload"},
{"systemctl", "enable", "tailstick-agent.timer"},
{"systemctl", "start", "tailstick-agent.timer"},
}
}

func windowsDelayedDeleteCommand(targets []string) []string {
quotedTargets := make([]string, 0, len(targets))
for _, target := range targets {
quotedTargets = append(quotedTargets, fmt.Sprintf(`"%s"`, strings.ReplaceAll(target, `"`, `""`)))
}
cmdLine := "/c ping 127.0.0.1 -n 3 >NUL & del /f /q " + strings.Join(quotedTargets, " ")
ps := fmt.Sprintf(`Start-Process -FilePath cmd.exe -ArgumentList '%s' -WindowStyle Hidden`, strings.ReplaceAll(cmdLine, `'`, `''`))
return []string{"powershell", "-NoProfile", "-Command", ps}
}

func validateExitNode(preset model.Preset, value string) error {
if strings.TrimSpace(value) == "" {
return nil
Expand Down
Loading
Loading