deployment_workflow_test.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
)
// These are configuration drift guard tests: they assert shipped workflow text,
// not runtime behavior.
func TestDeployWorkflowOnlyPublishesBackendBinary(t *testing.T) {
// The ssh client setup moved into a script both the preflight and the
// deploy jobs call, so the markers are checked across the two files.
var content string
for _, path := range []string{".github/workflows/deploy.yml", "scripts/gha-node-ssh.sh"} {
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
content += string(data) + "\n"
}
for _, want := range []string{
`Upload binary to staging`,
`/opt/clirelay2/incoming/cli-proxy-api-new`,
`User deploy`,
`/usr/local/sbin/clirelay-gha-deploy`,
`StrictHostKeyChecking yes`,
`DEPLOY_SSH_KNOWN_HOSTS`,
} {
if !strings.Contains(content, want) {
t.Fatalf("deploy workflow missing backend binary deployment marker %q", want)
}
}
for _, forbidden := range []string{
`Upload panel assets`,
`source: "manage.html,management.html,assets"`,
`scripts/migrate-sqlite-to-postgres.sh`,
`scripts/prepare-runtime-data-stack.sh`,
`PANEL_SRC=`,
`PANEL_DIR=`,
`relay-panel`,
`/home/web/html`,
`appleboy/scp-action`,
`appleboy/ssh-action`,
`username: root`,
`StrictHostKeyChecking accept-new`,
} {
if strings.Contains(content, forbidden) {
t.Fatalf("backend deploy workflow must not publish frontend panel assets or use insecure deploy markers, found %q", forbidden)
}
}
}
func TestDeployWorkflowUsesBlueGreenDeployment(t *testing.T) {
data, err := os.ReadFile(".github/workflows/deploy.yml")
if err != nil {
t.Fatalf("read deploy workflow: %v", err)
}
content := string(data)
for _, want := range []string{
`Blue-green deploy via fixed root entrypoint`,
`SERVICE_CPU_QUOTA: ${{ vars.CLIRELAY_SERVICE_CPU_QUOTA || '170%' }}`,
`SERVICE_MEMORY_HIGH: ${{ vars.CLIRELAY_SERVICE_MEMORY_HIGH || '1400M' }}`,
`SERVICE_MEMORY_MAX: ${{ vars.CLIRELAY_SERVICE_MEMORY_MAX || '1600M' }}`,
`SERVICE_TASKS_MAX: ${{ vars.CLIRELAY_SERVICE_TASKS_MAX || '512' }}`,
`COMMIT_SHA: ${{ github.sha }}`,
`/usr/local/sbin/clirelay-gha-deploy`,
`EXPECTED_SCRIPT_VERSION`,
} {
if !strings.Contains(content, want) {
t.Fatalf("deploy workflow missing blue-green marker %q", want)
}
}
for _, forbidden := range []string{
`systemctl stop clirelay2`,
`systemctl start clirelay2`,
`Stop, swap, and restart`,
} {
if strings.Contains(content, forbidden) {
t.Fatalf("deploy workflow still has outage-prone restart marker %q", forbidden)
}
}
}
func TestBlueGreenDeployScriptSyntaxAndGuards(t *testing.T) {
for _, path := range []string{
"scripts/deploy-blue-green.sh",
"scripts/cleanup-drained-slot.sh",
"scripts/reconcile-active-slot.sh",
// The root entrypoint is installed by hand and never run in CI, so a
// syntax error would first show up as a failed production deploy.
"scripts/clirelay-gha-deploy",
"scripts/gha-node-ssh.sh",
"scripts/gha-node-verify.sh",
} {
cmd := exec.Command("bash", "-n", path)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s syntax failed: %v\n%s", path, err, out)
}
}
data, err := os.ReadFile("scripts/deploy-blue-green.sh")
if err != nil {
t.Fatalf("read deploy script: %v", err)
}
content := string(data)
for _, want := range []string{
`/readyz`,
`/healthz`,
`CLIRELAY_PORT=`,
`.active-port`,
`reconcile-active-slot.sh`,
`HEALTH_TIMEOUT_SECONDS`,
`SMOKE_TIMEOUT_SECONDS`,
`PUBLIC_BASE_URL`,
`external smoke failed`,
`rolling nginx back`,
`MIN_AVAILABLE_MB`,
`NGINX_CONTAINER`,
`EnvironmentFile=`,
`docker exec "$NGINX_CONTAINER" nginx -t`,
`nginx -t`,
`DRAIN_SECONDS`,
`systemd-run`,
`scripts/cleanup-drained-slot.sh`,
`NGINX_BACKUP_PATTERN`,
`SCRIPT_VERSION`,
`TimeoutStopSec=${SLOT_STOP_TIMEOUT_SECONDS}`,
`Environment=CLIRELAY_SHUTDOWN_GRACE=${SHUTDOWN_GRACE_SECONDS}s`,
// The cutover must prove it happened rather than infer it from an exit
// status. A missing rewrite tool once left the config untouched while
// the deploy reported success, which is what manufactured the slot
// drift behind two outages.
`rewrite_slot_port`,
`nginx_slot_port`,
`required command not found on deploy host`,
`refusing to stop`,
// One rewrite cannot cut over two vhosts that both route the domain to
// a slot, so the deploy must refuse instead of picking one.
`several nginx configs proxy`,
// Multi-node: settings come from a root-owned file, the smoke is pinned
// to this node, a split config is refused, and a new node can deploy.
`DEPLOY_ENV_FILE=/etc/clirelay2/deploy.env`,
`require_root_controlled "$DEPLOY_ENV_FILE"`,
`--resolve "${DOMAIN}:443:${smoke_addr}"`,
`routes to both slots`,
`first deploy on this node`,
`SLOT_USER in ${DEPLOY_ENV_FILE}`,
`CLIRELAY_DEPLOY_RESULT`,
} {
if !strings.Contains(content, want) {
t.Fatalf("deploy script missing guard %q", want)
}
}
for _, forbidden := range []string{
// perl is not installed on the deploy host. Using it for the config
// rewrite failed silently and the deploy still reported success.
`perl -0pi`,
// The dot-anchored backup filter only caught `.bak.<timestamp>` and let
// every `.bak-before-<tag>` file through, so the lookup could return a
// backup vhost that nginx never reads. Replaced by NGINX_BACKUP_PATTERN;
// see TestNginxBackupPatternExcludesEveryBackupShape.
`grep -v '\.bak\.'`,
// Taking the first file that names the domain handed cutover a port-80
// redirect with no slot port to rewrite, so every deploy failed at
// cutover. The container lookup cannot run in a unit test, so this is
// its only guard; see TestNginxLookupPicksTheVhostThatProxiesToASlot.
`"$NGINX_BACKUP_PATTERN" | head -n1`,
`'${NGINX_BACKUP_PATTERN}' | head -n1`,
// systemd then SIGKILLed a slot whose drain used the whole grace before
// it wrote its queued usage records; see
// TestSlotUnitStopTimeoutOutlastsTheShutdownGrace.
`TimeoutStopSec=${SHUTDOWN_GRACE_SECONDS}`,
`migrate-sqlite-to-postgres.sh`,
`Legacy SQLite`,
`stop_active_units_for_migration`,
`CLIRELAY_SQLITE_PATH`,
`usage.db`,
} {
if strings.Contains(content, forbidden) {
t.Fatalf("deploy script contains forbidden pattern %q; see the comment beside it", forbidden)
}
}
}
// A drain that uses the whole grace still leaves the process its own shutdown
// to run: queued usage records written or spooled, the Redis snapshot saved,
// the database closed. With TimeoutStopSec equal to the grace, systemd
// SIGKILLed the slot the moment its drain ran out. The unit's stop timeout is
// the grace in effect on the node plus a fixed headroom, so a node that raises
// its grace in deploy.env keeps the headroom; how large the headroom must be
// is checked against the binary's own timeouts in
// internal/cmd TestDeployStopHeadroomCoversTheShutdownAfterTheDrain.
func TestSlotUnitStopTimeoutOutlastsTheShutdownGrace(t *testing.T) {
const script = "scripts/deploy-blue-green.sh"
keys := extractShellAssignment(t, script, "DEPLOY_ENV_KEYS")
grace, headroom := slotStopTimeoutDefaults(t)
run := func(t *testing.T, content string) (string, error) {
t.Helper()
path := filepath.Join(t.TempDir(), "deploy.env")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write deploy.env: %v", err)
}
// Same order as the script: the node file first, then the derivation.
body := fmt.Sprintf(`DEPLOY_ENV_KEYS=%q
SHUTDOWN_GRACE_SECONDS=%d
SHUTDOWN_STOP_HEADROOM_SECONDS=%d
load_deploy_env %q
derive_stop_timeout
echo "grace=${SHUTDOWN_GRACE_SECONDS} stop_timeout=${SLOT_STOP_TIMEOUT_SECONDS}"
`, keys, grace, headroom, path)
return runShellFunctions(t, script, []string{"fail", "tunable_is_valid", "setting_is_valid", "load_deploy_env", "derive_stop_timeout"}, body)
}
for content, want := range map[string]string{
"": fmt.Sprintf("grace=%d stop_timeout=%d", grace, grace+headroom),
"SHUTDOWN_GRACE_SECONDS=600": fmt.Sprintf("grace=600 stop_timeout=%d", 600+headroom),
} {
out, err := run(t, content)
if err != nil || !strings.Contains(out, want+"\n") {
t.Fatalf("deploy.env %q: want %q, err=%v\n%s", content, want, err, out)
}
}
// The binary's shutdown sizes the headroom, not the node.
if out, err := run(t, "SHUTDOWN_STOP_HEADROOM_SECONDS=0\n"); err == nil || !strings.Contains(out, "unknown setting SHUTDOWN_STOP_HEADROOM_SECONDS") {
t.Fatalf("deploy.env must not be able to shrink the stop headroom, err=%v\n%s", err, out)
}
data, err := os.ReadFile(script)
if err != nil {
t.Fatalf("read deploy script: %v", err)
}
content := string(data)
// Derived after deploy.env is read, or a node's own grace would leave its
// unit with the default stop timeout.
loadIndex := strings.Index(content, "\n\tload_deploy_env \"$DEPLOY_ENV_FILE\"\n")
deriveIndex := strings.Index(content, "\nderive_stop_timeout\n")
if loadIndex < 0 || deriveIndex < loadIndex {
t.Fatalf("derive_stop_timeout must run after deploy.env is loaded (load at %d, derive at %d)", loadIndex, deriveIndex)
}
// The workflow waits for the old slot by the timeout the node reports.
if !strings.Contains(content, `stop_timeout_seconds=%s`) || !strings.Contains(content, `"$SLOT_STOP_TIMEOUT_SECONDS"`) {
t.Fatalf("the deploy result line must report the slot's stop timeout")
}
}
// slotStopTimeoutDefaults reads the shipped shutdown grace and stop headroom.
func slotStopTimeoutDefaults(t *testing.T) (grace, headroom int) {
t.Helper()
const script = "scripts/deploy-blue-green.sh"
grace, err := strconv.Atoi(extractShellAssignment(t, script, "SHUTDOWN_GRACE_SECONDS"))
if err != nil {
t.Fatalf("parse SHUTDOWN_GRACE_SECONDS: %v", err)
}
headroom, err = strconv.Atoi(extractShellAssignment(t, script, "SHUTDOWN_STOP_HEADROOM_SECONDS"))
if err != nil || headroom <= 0 {
t.Fatalf("SHUTDOWN_STOP_HEADROOM_SECONDS must be a positive number of seconds, got %d (%v)", headroom, err)
}
return grace, headroom
}
// defaultSlotStopTimeout is the TimeoutStopSec a node without deploy.env gets.
func defaultSlotStopTimeout(t *testing.T) string {
t.Helper()
grace, headroom := slotStopTimeoutDefaults(t)
return strconv.Itoa(grace + headroom)
}
func TestCleanupDrainedSlotStopsOnlyTheExpectedInactiveSlot(t *testing.T) {
tmp := t.TempDir()
binDir := tmp + "/bin"
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("create fake bin dir: %v", err)
}
logPath := tmp + "/systemctl.log"
activeUnitPath := tmp + "/active-unit"
fakeSystemctl := `#!/usr/bin/env bash
printf '%s\n' "$*" >> "$SYSTEMCTL_LOG"
if [ "$1" = "is-active" ] && [ "$2" = "--quiet" ]; then
active="$(cat "$SYSTEMCTL_ACTIVE_UNIT" 2>/dev/null || true)"
if [ "${3:-}" = "$active" ]; then
exit 0
fi
exit 3
fi
`
if err := os.WriteFile(binDir+"/systemctl", []byte(fakeSystemctl), 0o755); err != nil {
t.Fatalf("write fake systemctl: %v", err)
}
activePortFile := tmp + "/.active-port"
if err := os.WriteFile(activePortFile, []byte("8319\n"), 0o644); err != nil {
t.Fatalf("write active port: %v", err)
}
if err := os.WriteFile(activeUnitPath, []byte("clirelay2-8319\n"), 0o644); err != nil {
t.Fatalf("write active unit: %v", err)
}
runCleanup := func() string {
t.Helper()
cmd := exec.Command("bash", "scripts/cleanup-drained-slot.sh", "8318", "8319")
cmd.Env = append(os.Environ(),
"PATH="+binDir+":"+os.Getenv("PATH"),
"SYSTEMCTL_LOG="+logPath,
"SYSTEMCTL_ACTIVE_UNIT="+activeUnitPath,
"BASE_DIR="+tmp,
"ACTIVE_PORT_FILE="+activePortFile,
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cleanup drained slot: %v\n%s", err, out)
}
return string(out)
}
runCleanup()
logData, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read systemctl log: %v", err)
}
logText := string(logData)
for _, want := range []string{"disable --now clirelay2", "disable --now clirelay2-8318"} {
if !strings.Contains(logText, want) {
t.Fatalf("cleanup log missing %q: %s", want, logText)
}
}
if err := os.WriteFile(activePortFile, []byte("8318\n"), 0o644); err != nil {
t.Fatalf("move active port back: %v", err)
}
if err := os.Remove(logPath); err != nil {
t.Fatalf("clear systemctl log: %v", err)
}
if out := runCleanup(); !strings.Contains(out, "Skip draining 8318") {
t.Fatalf("expected stale cleanup to be skipped, got: %s", out)
}
if _, err := os.Stat(logPath); !os.IsNotExist(err) {
t.Fatalf("stale cleanup must not call systemctl, stat err = %v", err)
}
}
func TestReconcileActiveSlotRepairsStaleMarker(t *testing.T) {
tmp := t.TempDir()
binDir := tmp + "/bin"
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("create fake bin dir: %v", err)
}
statePath := tmp + "/active-unit"
activePortFile := tmp + "/.active-port"
fakeSystemctl := `#!/usr/bin/env bash
if [ "$1" = "is-active" ] && [ "$2" = "--quiet" ]; then
active="$(cat "$SYSTEMCTL_ACTIVE_UNIT" 2>/dev/null || true)"
if [ "${3:-}" = "$active" ]; then
exit 0
fi
exit 3
fi
exit 1
`
if err := os.WriteFile(binDir+"/systemctl", []byte(fakeSystemctl), 0o755); err != nil {
t.Fatalf("write fake systemctl: %v", err)
}
if err := os.WriteFile(activePortFile, []byte("8318\n"), 0o644); err != nil {
t.Fatalf("write active port: %v", err)
}
if err := os.WriteFile(statePath, []byte("clirelay2-8319\n"), 0o644); err != nil {
t.Fatalf("write active unit: %v", err)
}
cmd := exec.Command("bash", "scripts/reconcile-active-slot.sh")
cmd.Env = append(os.Environ(),
"PATH="+binDir+":"+os.Getenv("PATH"),
"BASE_DIR="+tmp,
"ACTIVE_PORT_FILE="+activePortFile,
"SYSTEMCTL_ACTIVE_UNIT="+statePath,
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("reconcile active slot: %v\n%s", err, out)
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
got := strings.TrimSpace(lines[len(lines)-1])
if got != "8319" {
t.Fatalf("reconciled slot = %q, want 8319", got)
}
data, err := os.ReadFile(activePortFile)
if err != nil {
t.Fatalf("read active port file: %v", err)
}
if got := strings.TrimSpace(string(data)); got != "8319" {
t.Fatalf("active port file = %q, want 8319", got)
}
}
func TestDeployCompletesBeforeDispatchingDevDockerBuild(t *testing.T) {
data, err := os.ReadFile(".github/workflows/deploy.yml")
if err != nil {
t.Fatalf("read deploy workflow: %v", err)
}
content := string(data)
deployIndex := strings.Index(content, `name: Blue-green deploy via fixed root entrypoint`)
dockerIndex := strings.Index(content, `name: Trigger dev Docker image build`)
if deployIndex < 0 || dockerIndex < 0 || dockerIndex <= deployIndex {
t.Fatalf("dev Docker build must be dispatched only after blue-green deployment")
}
if !strings.Contains(content, `if: success() && env.SHOULD_DEPLOY == 'true'`) {
t.Fatalf("docker publish must only run after a real deploy attempt")
}
for _, want := range []string{
`actions: write`,
`cancel-in-progress: false`,
`gh workflow run docker-publish.yml`,
} {
if !strings.Contains(content, want) {
t.Fatalf("deploy workflow missing deployment-priority marker %q", want)
}
}
}
func TestReleaseAndDeployWorkflowsRejectVendoredPanelAssets(t *testing.T) {
for _, path := range []string{
".github/workflows/deploy.yml",
".github/workflows/docker-publish.yml",
".github/workflows/release.yaml",
} {
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if !strings.Contains(string(data), `./scripts/ensure-no-vendored-panel-assets.sh`) {
t.Fatalf("%s must reject committed frontend panel build output", path)
}
}
data, err := os.ReadFile(".github/workflows/pr-test-build.yml")
if err != nil {
t.Fatalf("read PR workflow: %v", err)
}
if !strings.Contains(string(data), `./scripts/ci-pr.sh`) {
t.Fatalf("PR workflow must use the shared PR check script")
}
data, err = os.ReadFile("scripts/ci-pr.sh")
if err != nil {
t.Fatalf("read PR check script: %v", err)
}
if !strings.Contains(string(data), `./scripts/ensure-no-vendored-panel-assets.sh`) {
t.Fatalf("PR check script must reject committed frontend panel build output")
}
}
func TestDockerPublishWorkflowUsesGHCRForBranchesAndReleaseTags(t *testing.T) {
if _, err := os.Stat(".github/workflows/docker-image.yml"); !os.IsNotExist(err) {
t.Fatalf("legacy DockerHub workflow must be removed, stat err = %v", err)
}
data, err := os.ReadFile(".github/workflows/docker-publish.yml")
if err != nil {
t.Fatalf("read Docker publish workflow: %v", err)
}
content := string(data)
for _, want := range []string{
"tags:\n - 'v*'",
"REGISTRY: ghcr.io",
"IMAGE_NAME: kittors/clirelay",
`if [[ "${GITHUB_REF_TYPE:-branch}" == "tag" ]]; then`,
`FRONTEND_REF="main"`,
`VERSION="${REF_NAME}"`,
"type=ref,event=tag",
"github.ref_name == 'main' || github.ref_type == 'tag'",
"branches: [main]",
"group: docker-publish-${{ github.ref }}",
} {
if !strings.Contains(content, want) {
t.Fatalf("Docker publish workflow missing GHCR release marker %q", want)
}
}
for _, forbidden := range []string{
"branches: [main, dev]",
"DOCKERHUB_USERNAME",
"DOCKERHUB_TOKEN",
"eceasy/cli-proxy-api",
} {
if strings.Contains(content, forbidden) {
t.Fatalf("Docker publish workflow still contains legacy DockerHub marker %q", forbidden)
}
}
}
// extractShellAssignment pulls a single-quoted assignment out of a shell script
// so the test exercises the value the deploy host will actually use, rather
// than a copy that can drift away from it.
func extractShellAssignment(t *testing.T, path, name string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, name+"=") {
continue
}
value := strings.TrimPrefix(line, name+"=")
value = strings.TrimSuffix(strings.TrimPrefix(value, "'"), "'")
if value != "" {
return value
}
}
t.Fatalf("%s does not define %s", path, name)
return ""
}
// The deploy host keeps every historical vhost next to the live one (74 files
// at the time of writing), all containing the same server_name. Picking a
// backup means the cutover rewrites a file nginx never reads: traffic stays on
// the old slot, the deploy reports success, and the drain step then stops the
// slot that is still serving. This asserts the real grep behaviour against the
// filename shapes observed on the host.
func TestNginxBackupPatternExcludesEveryBackupShape(t *testing.T) {
pattern := extractShellAssignment(t, "scripts/deploy-blue-green.sh", "NGINX_BACKUP_PATTERN")
dir := t.TempDir()
const live = "code.07230805.xyz.conf"
backups := []string{
"code.07230805.xyz.conf.bak.1787125852",
"code.07230805.xyz.conf.bak.20260819_152834",
"code.07230805.xyz.conf.bak-before-options",
"code.07230805.xyz.conf.bak-before-restore-001802",
"code.07230805.xyz.conf.bak",
}
for _, name := range append([]string{live}, backups...) {
if err := os.WriteFile(dir+"/"+name, []byte("server_name code.07230805.xyz;\nproxy_pass http://127.0.0.1:8319;\n"), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
script := `set -o pipefail; grep -Rsl "$DOMAIN" "$DIR" 2>/dev/null | grep -Ev "$PATTERN" || true`
cmd := exec.Command("bash", "-c", script)
cmd.Env = append(os.Environ(), "DOMAIN=code.07230805.xyz", "DIR="+dir, "PATTERN="+pattern)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("run lookup: %v\n%s", err, out)
}
matches := strings.Fields(strings.TrimSpace(string(out)))
if len(matches) != 1 {
t.Fatalf("lookup matched %d files, want only the live vhost: %v", len(matches), matches)
}
if got := matches[0]; got != dir+"/"+live {
t.Fatalf("lookup selected %q, want the live vhost %q", got, dir+"/"+live)
}
}
// extractShellFunction returns a function definition from a shell script, from
// its `name() {` line through the closing brace in column one, so the test runs
// the lookup the deploy host will run rather than a copy that can drift.
func extractShellFunction(t *testing.T, path, name string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var body []string
for _, line := range strings.Split(string(data), "\n") {
if body == nil {
if strings.HasPrefix(line, name+"() {") {
body = append(body, line)
}
continue
}
body = append(body, line)
if line == "}" {
return strings.Join(body, "\n")
}
}
t.Fatalf("%s does not define function %s", path, name)
return ""
}
// The deploy host names the domain in two live files: a port-80 redirect and
// the TLS vhost that proxies to the slot. grep -R lists them in directory
// order, and taking the first handed cutover the redirect, which has no slot
// port to rewrite, so every deploy failed at cutover. The lookup must return
// exactly the files that route to a slot, whatever order they are found in.
func TestNginxLookupPicksTheVhostThatProxiesToASlot(t *testing.T) {
lookup := extractShellFunction(t, "scripts/deploy-blue-green.sh", "find_host_nginx_conf")
backupPattern := extractShellAssignment(t, "scripts/deploy-blue-green.sh", "NGINX_BACKUP_PATTERN")
// Shapes copied from the deploy host, including a second vhost in the TLS
// file that proxies elsewhere and must not matter.
const redirect = `server {
listen 80;
server_name code.07230805.xyz relay.07230805.xyz;
location / {
return 301 https://$host$request_uri;
}
}
`
const tlsVhost = `server {
listen 127.0.0.1:8444 ssl http2;
server_name code.07230805.xyz relay.07230805.xyz;
location / {
proxy_pass http://127.0.0.1:8318;
}
}
server {
listen 127.0.0.1:8444 ssl;
server_name sonar.07230805.xyz;
location / {
proxy_pass http://127.0.0.1:8787;
}
}
`
write := func(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
run := func(t *testing.T, searchDirs string) []string {
t.Helper()
cmd := exec.Command("bash", "-c", "set -euo pipefail\n"+lookup+"\nfind_host_nginx_conf")
cmd.Env = append(os.Environ(),
"DOMAIN=relay.07230805.xyz",
"PORT_A=8318",
"PORT_B=8319",
"NGINX_SEARCH_DIRS="+searchDirs,
"NGINX_BACKUP_PATTERN="+backupPattern,
"NGINX_CONF=",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("run lookup: %v\n%s", err, out)
}
return strings.Fields(strings.TrimSpace(string(out)))
}
t.Run("redirect is found first", func(t *testing.T) {
// grep -R walks its arguments in order, so the redirect is always the
// first match here: the file a first-match lookup would hand cutover.
redirectDir, vhostDir := t.TempDir(), t.TempDir()
write(t, redirectDir+"/code.07230805.xyz.conf", redirect)
write(t, vhostDir+"/00-https-local-8444.conf", tlsVhost)
// A backup that still routes to a slot stays excluded.
write(t, vhostDir+"/00-https-local-8444.conf.bak-before-migrate", strings.ReplaceAll(tlsVhost, "8318", "8319"))
got := run(t, redirectDir+" "+vhostDir)
if want := vhostDir + "/00-https-local-8444.conf"; len(got) != 1 || got[0] != want {
t.Fatalf("lookup returned %v, want only the vhost that proxies to the slot %q", got, want)
}
})
t.Run("two vhosts route to a slot", func(t *testing.T) {
// Both are reported so the deploy refuses instead of rewriting one and
// leaving the other on the old slot.
dir := t.TempDir()
write(t, dir+"/relay.conf", tlsVhost)
write(t, dir+"/code.conf", strings.ReplaceAll(tlsVhost, "8318", "8319"))
if got := run(t, dir); len(got) != 2 {
t.Fatalf("lookup returned %v, want both vhosts so the deploy can refuse", got)
}
})
t.Run("nothing routes to a slot", func(t *testing.T) {
dir := t.TempDir()
write(t, dir+"/code.07230805.xyz.conf", redirect)
if got := run(t, dir); len(got) != 0 {
t.Fatalf("lookup returned %v for a redirect-only host, want nothing", got)
}
})
}
// A cutover that failed to move nginx leaves .active-port and systemd agreeing
// with each other while nginx still points at the old slot. Draining then takes
// production down, so the drain must refuse.
func TestCleanupDrainedSlotRefusesToStopSlotNginxStillServes(t *testing.T) {
tmp := t.TempDir()
binDir := tmp + "/bin"
nginxDir := tmp + "/nginx"
for _, dir := range []string{binDir, nginxDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("create %s: %v", dir, err)
}
}
activeUnitPath := tmp + "/active-unit"
fakeSystemctl := `#!/usr/bin/env bash
printf '%s\n' "$*" >> "$SYSTEMCTL_LOG"
if [ "$1" = "is-active" ] && [ "$2" = "--quiet" ]; then
active="$(cat "$SYSTEMCTL_ACTIVE_UNIT" 2>/dev/null || true)"
if [ "${3:-}" = "$active" ]; then
exit 0
fi
exit 3
fi
`
if err := os.WriteFile(binDir+"/systemctl", []byte(fakeSystemctl), 0o755); err != nil {
t.Fatalf("write fake systemctl: %v", err)
}
activePortFile := tmp + "/.active-port"
// Both local sources say 8319 won the cutover...
if err := os.WriteFile(activePortFile, []byte("8319\n"), 0o644); err != nil {
t.Fatalf("write active port: %v", err)
}
if err := os.WriteFile(activeUnitPath, []byte("clirelay2-8319\n"), 0o644); err != nil {
t.Fatalf("write active unit: %v", err)
}
// ...but nginx never moved off 8318.
if err := os.WriteFile(nginxDir+"/live.conf", []byte("proxy_pass http://127.0.0.1:8318;\n"), 0o644); err != nil {
t.Fatalf("write live vhost: %v", err)
}
// A stale backup pointing at the new slot must not be mistaken for live config.
if err := os.WriteFile(nginxDir+"/live.conf.bak-before-cutover", []byte("proxy_pass http://127.0.0.1:8319;\n"), 0o644); err != nil {
t.Fatalf("write backup vhost: %v", err)
}
logPath := tmp + "/systemctl.log"
cmd := exec.Command("bash", "scripts/cleanup-drained-slot.sh", "8318", "8319")
cmd.Env = append(os.Environ(),
"PATH="+binDir+":"+os.Getenv("PATH"),
"SYSTEMCTL_LOG="+logPath,
"SYSTEMCTL_ACTIVE_UNIT="+activeUnitPath,
"BASE_DIR="+tmp,
"ACTIVE_PORT_FILE="+activePortFile,
"NGINX_SEARCH_DIRS="+nginxDir,
)
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("drain succeeded while nginx still served 8318; output:\n%s", out)
}
if !strings.Contains(string(out), "live nginx still proxies to it") {
t.Fatalf("drain refused for the wrong reason:\n%s", out)
}
logData, _ := os.ReadFile(logPath)
if strings.Contains(string(logData), "disable --now clirelay2-8318") {
t.Fatalf("drain stopped the slot nginx was serving:\n%s", logData)
}
}
// The gate compares the workflow's expectation against the version baked into
// the script. When they drift, every deploy fails before touching the host --
// safe, but it silently disables deployment until someone notices.
func TestDeployScriptVersionMatchesWorkflowExpectation(t *testing.T) {
raw := extractShellAssignment(t, "scripts/deploy-blue-green.sh", "SCRIPT_VERSION")
raw = strings.Trim(raw, `"`)
scriptVersion := strings.TrimSuffix(strings.TrimPrefix(raw, "${SCRIPT_VERSION:-"), "}")
if scriptVersion == "" || strings.ContainsAny(scriptVersion, "${}\"") {
t.Fatalf("could not parse SCRIPT_VERSION, got %q", raw)
}
data, err := os.ReadFile(".github/workflows/deploy.yml")
if err != nil {
t.Fatalf("read deploy workflow: %v", err)
}
want := "EXPECTED_SCRIPT_VERSION: '" + scriptVersion + "'"
if !strings.Contains(string(data), want) {
t.Fatalf("deploy workflow must expect script version %q (looked for %q)", scriptVersion, want)
}
}
// Deployment must not depend on a repository variable being present. It used to
// require CLIRELAY_DEV_AUTO_DEPLOY == 'true', so clearing or losing that
// variable turned every merge into dev into a silent build-only run while the
// workflow still reported success.
func TestDeployIsOptOutRatherThanOptIn(t *testing.T) {
data, err := os.ReadFile(".github/workflows/deploy.yml")
if err != nil {
t.Fatalf("read deploy workflow: %v", err)
}
content := string(data)
if !strings.Contains(content, `vars.CLIRELAY_DEV_AUTO_DEPLOY != 'false'`) {
t.Fatalf("merges into dev must deploy unless explicitly disabled")
}
if strings.Contains(content, `vars.CLIRELAY_DEV_AUTO_DEPLOY == 'true'`) {
t.Fatalf("deployment must not be gated behind an opt-in variable")
}
}
// Cutover smoke-tests the new slot, but the old slot is drained on a delay
// afterwards, and draining is where the previous outages actually happened. The
// workflow must keep probing across that window rather than reporting success
// at cutover.
func TestDeployVerifiesHealthAcrossTheDrainWindow(t *testing.T) {
data, err := os.ReadFile(".github/workflows/deploy.yml")
if err != nil {
t.Fatalf("read deploy workflow: %v", err)
}
content := string(data)
for _, want := range []string{
`name: Verify service stays healthy through drain`,
`CLIRELAY_PUBLIC_HEALTH_URL`,
`Post-deploy health probes failed`,
} {
if !strings.Contains(content, want) {
t.Fatalf("deploy workflow missing post-deploy verification marker %q", want)
}
}
// The probe is worthless if it runs before the deploy step.
deployIndex := strings.Index(content, `name: Blue-green deploy via fixed root entrypoint`)
probeIndex := strings.Index(content, `name: Verify service stays healthy through drain`)
if deployIndex < 0 || probeIndex < 0 || probeIndex <= deployIndex {
t.Fatalf("post-deploy health probe must run after the blue-green deploy step")
}
// It must also gate the run: a silent probe would repeat the failure mode
// this whole change exists to prevent.
if !strings.Contains(content, `exit 1`) {
t.Fatalf("post-deploy health probe must fail the workflow when probes fail")
}
}