diff --git a/.github/scripts/setup-demo-ssh.sh b/.github/scripts/setup-demo-ssh.sh new file mode 100644 index 000000000..e0929778b --- /dev/null +++ b/.github/scripts/setup-demo-ssh.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DEMO_SERVER_HOST:?DEMO_SERVER_HOST is required}" +: "${DEMO_SERVER_SSH_KEY:?DEMO_SERVER_SSH_KEY is required}" + +SSH_DIR="${HOME}/.ssh" +IDENTITY_FILE="${DEMO_SSH_IDENTITY_FILE:-${SSH_DIR}/id_ed25519}" +KNOWN_HOSTS_FILE="${DEMO_SSH_KNOWN_HOSTS_FILE:-${SSH_DIR}/known_hosts}" + +is_ip_literal() { + python3 - "$1" <<'PY' +import ipaddress +import sys + +try: + ipaddress.ip_address(sys.argv[1]) +except ValueError: + sys.exit(1) +sys.exit(0) +PY +} + +mkdir -p "$SSH_DIR" +chmod 700 "$SSH_DIR" +printf '%s\n' "$DEMO_SERVER_SSH_KEY" > "$IDENTITY_FILE" +chmod 600 "$IDENTITY_FILE" + +: > "$KNOWN_HOSTS_FILE" +keyscan_output="$(mktemp)" +keyscan_error="$(mktemp)" +trap 'rm -f "$keyscan_output" "$keyscan_error"' EXIT + +host_needs_dns=true +if is_ip_literal "$DEMO_SERVER_HOST"; then + host_needs_dns=false + echo "Demo SSH host is an IP literal; skipping DNS resolution wait." +fi + +MAX_SSH_SETUP_ATTEMPTS=18 +for attempt in $(seq 1 "$MAX_SSH_SETUP_ATTEMPTS"); do + if [ "$host_needs_dns" = "true" ] && ! getent hosts "$DEMO_SERVER_HOST" >/dev/null 2>&1; then + echo "Demo SSH host is not resolvable yet on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}." + elif ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST" > "$keyscan_output" 2>"$keyscan_error" && [ -s "$keyscan_output" ]; then + cat "$keyscan_output" >> "$KNOWN_HOSTS_FILE" + chmod 600 "$KNOWN_HOSTS_FILE" + echo "Demo SSH host key captured." + exit 0 + else + echo "ssh-keyscan did not return demo host keys on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}." + fi + + if [ "$attempt" -lt "$MAX_SSH_SETUP_ATTEMPTS" ]; then + sleep 10 + fi +done + +echo "::error::Timed out waiting for the demo SSH host to become reachable and return host keys after Tailscale setup." +if command -v tailscale >/dev/null 2>&1; then + tailscale status --peers=false || true +fi +if [ -s "$keyscan_error" ]; then + sed 's/^/ssh-keyscan: /' "$keyscan_error" || true +fi +exit 1 diff --git a/.github/workflows/deploy-demo-server.yml b/.github/workflows/deploy-demo-server.yml index 358c6c31b..9fef4bbed 100644 --- a/.github/workflows/deploy-demo-server.yml +++ b/.github/workflows/deploy-demo-server.yml @@ -143,41 +143,7 @@ jobs: env: DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} DEMO_SERVER_SSH_KEY: ${{ secrets.DEMO_SERVER_SSH_KEY }} - run: | - set -euo pipefail - mkdir -p ~/.ssh - chmod 700 ~/.ssh - echo "$DEMO_SERVER_SSH_KEY" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - - : > ~/.ssh/known_hosts - keyscan_output="$(mktemp)" - keyscan_error="$(mktemp)" - MAX_SSH_SETUP_ATTEMPTS=18 - for attempt in $(seq 1 "$MAX_SSH_SETUP_ATTEMPTS"); do - if getent hosts "$DEMO_SERVER_HOST" >/dev/null 2>&1; then - if ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST" > "$keyscan_output" 2>"$keyscan_error" && [ -s "$keyscan_output" ]; then - cat "$keyscan_output" >> ~/.ssh/known_hosts - chmod 600 ~/.ssh/known_hosts - echo "Demo SSH host key captured." - exit 0 - fi - echo "ssh-keyscan did not return demo host keys on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}." - else - echo "Demo SSH host is not resolvable yet on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}." - fi - - if [ "$attempt" -lt "$MAX_SSH_SETUP_ATTEMPTS" ]; then - sleep 10 - fi - done - - echo "::error::Timed out waiting for the demo SSH host to resolve and return host keys after Tailscale setup." - tailscale status --peers=false || true - if [ -s "$keyscan_error" ]; then - sed 's/^/ssh-keyscan: /' "$keyscan_error" || true - fi - exit 1 + run: bash .github/scripts/setup-demo-ssh.sh - name: Verify target host identity env: diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index 7c6cc9b79..70d4e70e3 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -250,41 +250,7 @@ jobs: env: DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} DEMO_SERVER_SSH_KEY: ${{ secrets.DEMO_SERVER_SSH_KEY }} - run: | - set -euo pipefail - mkdir -p ~/.ssh - chmod 700 ~/.ssh - echo "$DEMO_SERVER_SSH_KEY" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - - : > ~/.ssh/known_hosts - keyscan_output="$(mktemp)" - keyscan_error="$(mktemp)" - MAX_SSH_SETUP_ATTEMPTS=18 - for attempt in $(seq 1 "$MAX_SSH_SETUP_ATTEMPTS"); do - if getent hosts "$DEMO_SERVER_HOST" >/dev/null 2>&1; then - if ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST" > "$keyscan_output" 2>"$keyscan_error" && [ -s "$keyscan_output" ]; then - cat "$keyscan_output" >> ~/.ssh/known_hosts - chmod 600 ~/.ssh/known_hosts - echo "Demo SSH host key captured." - exit 0 - fi - echo "ssh-keyscan did not return demo host keys on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}." - else - echo "Demo SSH host is not resolvable yet on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}." - fi - - if [ "$attempt" -lt "$MAX_SSH_SETUP_ATTEMPTS" ]; then - sleep 10 - fi - done - - echo "::error::Timed out waiting for the demo SSH host to resolve and return host keys after Tailscale setup." - tailscale status --peers=false || true - if [ -s "$keyscan_error" ]; then - sed 's/^/ssh-keyscan: /' "$keyscan_error" || true - fi - exit 1 + run: bash .github/scripts/setup-demo-ssh.sh - name: Verify target host identity env: diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index ba34464a8..d61cbd959 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1183,10 +1183,11 @@ Those same governed demo deploy/update workflows also own the runner-to-host network path. They must establish the canonical Tailscale connectivity step before SSH setup so stable or preview targets may stay on governed private hostnames or Tailscale IPs, rather than silently depending on public SSH -reachability from GitHub-hosted runners. After Tailscale setup, SSH setup must -wait for the configured demo host to resolve and return host keys with bounded +reachability from GitHub-hosted runners. After Tailscale setup, shared SSH +setup must wait for configured demo hostnames to resolve, accept configured IP +literals without a DNS precheck, and then capture host keys with bounded retries before any installer or binary copy runs; a one-shot `ssh-keyscan` -against a private demo hostname is not sufficient release or deploy proof. +against a private demo target is not sufficient release or deploy proof. Those same workflows also own customer-visible browser truth for the public demo shell. Health checks and entry-asset parity are necessary but not sufficient; after those checks pass, the governed helpers diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index b6a8bd985..9a9bf0121 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -947,10 +947,7 @@ func TestDeployDemoWorkflowFailsClosedForStableAndVerifiesFrontendParity(t *test ` - stable`, `Capture expected frontend entry asset`, `Verify target host identity`, - `MAX_SSH_SETUP_ATTEMPTS=18`, - `getent hosts "$DEMO_SERVER_HOST"`, - `ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST"`, - `Timed out waiting for the demo SSH host to resolve and return host keys after Tailscale setup.`, + `bash .github/scripts/setup-demo-ssh.sh`, `SERVICE_NAME="pulse"`, `Unsupported demo target: ${TARGET}`, `Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.`, @@ -991,10 +988,7 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { `go run ./scripts/release_update_key.go public-key-ssh`, `sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"|" /tmp/pulse-install.sh`, `Verify target host identity`, - `MAX_SSH_SETUP_ATTEMPTS=18`, - `getent hosts "$DEMO_SERVER_HOST"`, - `ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST"`, - `Timed out waiting for the demo SSH host to resolve and return host keys after Tailscale setup.`, + `bash .github/scripts/setup-demo-ssh.sh`, `Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.`, `Prepare demo host storage`, `KEEP_BACKUPS=2`, @@ -1025,6 +1019,69 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { } } +func TestDemoSshSetupHelperHandlesIpLiteralTargets(t *testing.T) { + helperBytes, err := os.ReadFile(repoFile(".github", "scripts", "setup-demo-ssh.sh")) + if err != nil { + t.Fatalf("read demo ssh setup helper: %v", err) + } + helper := string(helperBytes) + required := []string{ + `is_ip_literal()`, + `ipaddress.ip_address(sys.argv[1])`, + `host_needs_dns=false`, + `Demo SSH host is an IP literal; skipping DNS resolution wait.`, + `[ "$host_needs_dns" = "true" ] && ! getent hosts "$DEMO_SERVER_HOST"`, + `ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST"`, + `MAX_SSH_SETUP_ATTEMPTS=18`, + `Timed out waiting for the demo SSH host to become reachable and return host keys after Tailscale setup.`, + } + for _, needle := range required { + if !strings.Contains(helper, needle) { + t.Fatalf("demo ssh setup helper missing guarded IP/hostname behavior: %s", needle) + } + } + + tmpDir := t.TempDir() + fakeBin := filepath.Join(tmpDir, "bin") + if err := os.MkdirAll(fakeBin, 0o755); err != nil { + t.Fatalf("create fake bin: %v", err) + } + getentMarker := filepath.Join(tmpDir, "getent-called") + if err := os.WriteFile(filepath.Join(fakeBin, "getent"), []byte("#!/bin/sh\n: > \"$GETENT_MARKER\"\nexit 1\n"), 0o755); err != nil { + t.Fatalf("write fake getent: %v", err) + } + if err := os.WriteFile(filepath.Join(fakeBin, "ssh-keyscan"), []byte("#!/bin/sh\nprintf '100.109.163.95 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDemo\\n'\n"), 0o755); err != nil { + t.Fatalf("write fake ssh-keyscan: %v", err) + } + + homeDir := filepath.Join(tmpDir, "home") + cmd := exec.Command("bash", repoFile(".github", "scripts", "setup-demo-ssh.sh")) + cmd.Env = append(os.Environ(), + "DEMO_SERVER_HOST=100.109.163.95", + "DEMO_SERVER_SSH_KEY=fake-private-key", + "GETENT_MARKER="+getentMarker, + "HOME="+homeDir, + "PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"), + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("demo ssh setup helper failed for IP literal: %v\n%s", err, output) + } + if _, err := os.Stat(getentMarker); !os.IsNotExist(err) { + t.Fatalf("demo ssh setup helper must not require getent hosts for IP literals; stat err=%v", err) + } + knownHosts, err := os.ReadFile(filepath.Join(homeDir, ".ssh", "known_hosts")) + if err != nil { + t.Fatalf("read generated known_hosts: %v", err) + } + if !strings.Contains(string(knownHosts), "ssh-ed25519") { + t.Fatalf("known_hosts missing captured key: %s", knownHosts) + } + if !strings.Contains(string(output), "Demo SSH host is an IP literal; skipping DNS resolution wait.") { + t.Fatalf("helper output did not report IP literal path: %s", output) + } +} + func TestDemoPublicBrowserSmokeWaitsForVisibleLoginUI(t *testing.T) { scriptBytes, err := os.ReadFile(repoFile("scripts", "demo_public_browser_smoke.cjs")) if err != nil { diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index c4bfbf3b6..8da1c710c 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -526,6 +526,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): content = read(".github/workflows/create-release.yml") update_demo_workflow = read(".github/workflows/update-demo-server.yml") deploy_demo_workflow = read(".github/workflows/deploy-demo-server.yml") + demo_ssh_helper = read(".github/scripts/setup-demo-ssh.sh") validation_workflow = read(".github/workflows/validate-release-assets.yml") helper = read("scripts/trigger-release.sh") renderer = read("scripts/release_control/render_release_body.py") @@ -612,10 +613,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("TRUSTED_SSH_PUBLIC_KEY", update_demo_workflow) self.assertIn('sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\\"${TRUSTED_SSH_PUBLIC_KEY}\\"|" /tmp/pulse-install.sh', update_demo_workflow) for demo_workflow in (update_demo_workflow, deploy_demo_workflow): - self.assertIn("MAX_SSH_SETUP_ATTEMPTS=18", demo_workflow) - self.assertIn('getent hosts "$DEMO_SERVER_HOST"', demo_workflow) - self.assertIn('ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST"', demo_workflow) - self.assertIn("Timed out waiting for the demo SSH host to resolve and return host keys after Tailscale setup", demo_workflow) + self.assertIn("bash .github/scripts/setup-demo-ssh.sh", demo_workflow) + self.assertIn("MAX_SSH_SETUP_ATTEMPTS=18", demo_ssh_helper) + self.assertIn("ipaddress.ip_address(sys.argv[1])", demo_ssh_helper) + self.assertIn("host_needs_dns=false", demo_ssh_helper) + self.assertIn('getent hosts "$DEMO_SERVER_HOST"', demo_ssh_helper) + self.assertIn('ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST"', demo_ssh_helper) + self.assertIn("Timed out waiting for the demo SSH host to become reachable and return host keys after Tailscale setup", demo_ssh_helper) self.assertIn("derive the OpenSSH installer trust key from `PULSE_UPDATE_SIGNING_PUBLIC_KEY`", normalize_ws(contract)) self.assertIn('SYFT_VERSION="1.42.4"', content) self.assertIn('SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"', content)