From 83c9b2d375c9d6708987a462636cfedcdd6c5299 Mon Sep 17 00:00:00 2001 From: Rihards Gailums Date: Sat, 23 May 2026 17:29:49 +0000 Subject: [PATCH] Slice 3B: replace updater docker stubs with real CLI implementations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Slice 3A sidecar (#128) shipped with stubbed docker operations that log + sleep so the state machine and HTTP API could be reviewed independently of real container surgery. This PR makes those stubs real. What's now real (was stub): Pull(ref) -> docker pull ref InspectAppImageDigest() -> docker inspect -> .Image -> docker inspect -> .RepoDigests -> sha256 part Falls back to image ID if no RepoDigest (locally-built images). RunMigration(image, cmd) -> docker run --rm --volumes-from Migration runs against the same data volumes as the live app. Idempotent migrations are expected. SwapContainer(newImage) -> rewrite PROCESSGIT_VERSION in deploy/.env then `docker compose -f /deploy/docker-compose.yml up -d --no-deps processgit`. Returns the OLD container ID for the Job log. Healthcheck() -> HTTP GET against PROCESSGIT_UPDATER_HEALTH_URL (default http://processgit:3000/api/v1/version) polled every 3s up to a 2m deadline. Rollback(old) -> revert PROCESSGIT_VERSION to the previous value + the same `docker compose up`. What's still deferred (Slice 3C): Snapshot(dst) -> stubbed; now logs a warning and returns nil so updates aren't blocked. Slice 3C will implement `docker run --rm --volumes-from -v :/snap alpine tar czf …`. Stub mode is preserved (PROCESSGIT_UPDATER_STUB=true) so: - Existing tests in orchestrator_test.go keep working without docker - Operators can validate the architecture end-to-end before flipping to real updates - The transition is per-deployment via an env var flip New configuration (with sensible defaults that match the compose integration PR): PROCESSGIT_UPDATER_COMPOSE_FILE default: /deploy/docker-compose.yml PROCESSGIT_UPDATER_ENV_FILE default: /deploy/.env PROCESSGIT_UPDATER_HEALTH_URL default: http://processgit:3000/api/v1/version New helper: env.go (.env file editor) SetEnvFileKey(path, key, value) -> (previous, hadKey, error) GetEnvFileKey(path, key) -> (value, ok, error) Atomic write-temp-then-rename. Preserves comments, blank lines, and other keys. Comments out duplicate occurrences of the target key. Tests (9 new + 7 existing = 16, all passing): TestSetEnvFileKey_UpdateExisting TestSetEnvFileKey_AppendsWhenMissing TestSetEnvFileKey_FileDoesNotExist TestSetEnvFileKey_QuotedValues TestSetEnvFileKey_DuplicatesCommented TestSetEnvFileKey_EmptyKeyRejected TestGetEnvFileKey TestImageVersion (covers tag/digest/port-in-registry parsing) TestLastLines (log-output truncation helper) Co-authored-by: Claude --- updater/docker.go | 306 ++++++++++++++++++++++++++++++++++++++------ updater/env.go | 113 ++++++++++++++++ updater/env_test.go | 207 ++++++++++++++++++++++++++++++ updater/main.go | 9 ++ 4 files changed, 596 insertions(+), 39 deletions(-) create mode 100644 updater/env.go create mode 100644 updater/env_test.go diff --git a/updater/docker.go b/updater/docker.go index c22fc3b..424349b 100644 --- a/updater/docker.go +++ b/updater/docker.go @@ -1,29 +1,47 @@ -// Docker CLI wrapper. Slice 3A ships these as stubs that log + sleep — the -// state machine, HTTP API, and cosign verification can all be exercised -// end-to-end without touching real containers, which is what we want for -// the first iteration. +// Docker CLI wrapper. Slice 3B replaces the Slice 3A stubs with real +// implementations: // -// Slice 3B will replace each method body with `exec.CommandContext(ctx, -// "docker", ...)` invocations that talk to /var/run/docker.sock through -// the bind-mounted docker CLI. +// Pull → docker pull +// InspectAppImageDigest → docker inspect → image ID → RepoDigests +// RunMigration → docker run --rm --volumes-from +// SwapContainer → rewrite PROCESSGIT_VERSION in .env, then +// docker compose up -d --no-deps +// Healthcheck → HTTP GET against the app health URL +// Rollback → restore previous PROCESSGIT_VERSION + compose up +// Snapshot → still deferred to Slice 3C; logs a warning +// +// Stub mode (`PROCESSGIT_UPDATER_STUB=true`) is preserved so existing tests +// keep working and operators can validate the architecture before flipping +// to real updates. package main import ( + "bytes" "context" + "encoding/json" "fmt" "log/slog" + "net/http" + "os" + "os/exec" + "strings" "time" ) type Docker struct { - Bin string // path to docker CLI; default "docker" - AppContainer string // e.g. "processgit" - StagingNetwork string // optional separate network for the staged container - Stub bool // when true, all operations are simulated - Log *slog.Logger + Bin string + AppContainer string + ComposeFile string // /deploy/docker-compose.yml inside the updater container + EnvFile string // /deploy/.env — for persisting PROCESSGIT_VERSION + AppHealthURL string // e.g. http://processgit:3000/api/v1/version + Stub bool + Log *slog.Logger } +// NewDocker keeps the 3-arg signature so existing tests don't break. +// The non-stub fields (ComposeFile, EnvFile, AppHealthURL) are set on the +// returned value directly by main.go. func NewDocker(log *slog.Logger, appContainer string, stub bool) *Docker { return &Docker{ Bin: "docker", @@ -33,44 +51,134 @@ func NewDocker(log *slog.Logger, appContainer string, stub bool) *Docker { } } -// Pull pulls the image at ref. In stub mode it logs and sleeps proportional -// to the size of an average ProcessGit image (~5s). +// requireFields validates that the operation has everything it needs in +// non-stub mode. Called from each public method. +func (d *Docker) requireFields(forOp string, fields ...string) error { + if d.Stub { + return nil + } + for _, f := range fields { + switch f { + case "ComposeFile": + if d.ComposeFile == "" { + return fmt.Errorf("docker.%s: ComposeFile is required (set PROCESSGIT_UPDATER_COMPOSE_FILE)", forOp) + } + case "EnvFile": + if d.EnvFile == "" { + return fmt.Errorf("docker.%s: EnvFile is required (set PROCESSGIT_UPDATER_ENV_FILE)", forOp) + } + case "AppHealthURL": + if d.AppHealthURL == "" { + return fmt.Errorf("docker.%s: AppHealthURL is required (set PROCESSGIT_UPDATER_HEALTH_URL)", forOp) + } + } + } + return nil +} + +// run executes a docker subcommand and returns combined stdout+stderr. +func (d *Docker) run(ctx context.Context, what string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, d.Bin, args...) + cmd.Env = os.Environ() + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Run(); err != nil { + return buf.String(), fmt.Errorf("docker %s failed: %w; output: %s", what, err, strings.TrimSpace(buf.String())) + } + return buf.String(), nil +} + +// --- Pull ------------------------------------------------------------------ + func (d *Docker) Pull(ctx context.Context, ref string) error { d.Log.Info("docker.pull", "ref", ref, "stub", d.Stub) if d.Stub { return sleepCtx(ctx, 5*time.Second) } - return fmt.Errorf("docker.Pull: not implemented yet (Slice 3B)") + _, err := d.run(ctx, "pull "+ref, "pull", ref) + return err } -// Inspect returns the image digest currently in use by the app container, -// for capturing the rollback target. +// --- InspectAppImageDigest ------------------------------------------------- + +// Returns the image digest currently in use by the app container. If the +// container is running an image built locally (and never pushed/pulled), +// there's no RepoDigest available — we return the image ID instead. +// Either way the value uniquely identifies the previous state and is +// suitable as a rollback reference. func (d *Docker) InspectAppImageDigest(ctx context.Context) (string, error) { d.Log.Info("docker.inspect_app", "container", d.AppContainer, "stub", d.Stub) if d.Stub { - // Return a deterministic stub digest so the state file shows something useful. return "sha256:0000000000000000000000000000000000000000000000000000000000000000", nil } - return "", fmt.Errorf("docker.Inspect: not implemented yet (Slice 3B)") + out, err := d.run(ctx, "inspect container", "inspect", "--format", "{{.Image}}", d.AppContainer) + if err != nil { + return "", err + } + imageID := strings.TrimSpace(out) + if imageID == "" { + return "", fmt.Errorf("no image ID for container %q", d.AppContainer) + } + out, err = d.run(ctx, "inspect image", "inspect", "--format", "{{json .RepoDigests}}", imageID) + if err != nil { + return "", err + } + var digests []string + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &digests); err != nil { + return "", fmt.Errorf("parse RepoDigests: %w", err) + } + if len(digests) == 0 { + // Locally built image with no registry digest. Return image ID. + return imageID, nil + } + // "registry/repo@sha256:abc..." — extract the part after '@'. + first := digests[0] + if at := strings.LastIndex(first, "@"); at > 0 && at < len(first)-1 { + return first[at+1:], nil + } + return first, nil } -// RunMigration runs `docker run --rm ` and returns -// its combined output. +// --- RunMigration ---------------------------------------------------------- + +// Runs the migration command inside a one-shot container from `image`, +// with --volumes-from so it has access to the same data. +// The app container is NOT stopped here; migration commands are expected +// to be idempotent and tolerant of the app still serving (e.g. additive +// schema migrations). Migrations that require downtime should orchestrate +// a stop/run/start sequence in the manifest's migration.command itself. func (d *Docker) RunMigration(ctx context.Context, image, command string) (string, error) { d.Log.Info("docker.run_migration", "image", image, "command", command, "stub", d.Stub) if d.Stub { if err := sleepCtx(ctx, 3*time.Second); err != nil { return "", err } - return fmt.Sprintf("[stub] would have run: docker run --rm %s %s\n", image, command), nil + return fmt.Sprintf("[stub] would have run: docker run --rm --volumes-from %s %s %s\n", d.AppContainer, image, command), nil + } + args := []string{ + "run", "--rm", + "--volumes-from", d.AppContainer, + image, } - return "", fmt.Errorf("docker.RunMigration: not implemented yet (Slice 3B)") + // command is a single string from the manifest (e.g. "/app/gitea/gitea migrate"). + // Split on whitespace — simple but adequate for the documented use case + // (no quoted args in the manifest's migration.command field today). + args = append(args, strings.Fields(command)...) + return d.run(ctx, "run migration", args...) } -// SwapContainer stops the running app container and starts a new one from -// `newImage`, preserving env/volumes/network. Returns the old container ID -// so the orchestrator can roll back if healthcheck fails. -func (d *Docker) SwapContainer(ctx context.Context, newImage string) (oldContainerID string, err error) { +// --- SwapContainer --------------------------------------------------------- + +// Compose-driven swap. Updates PROCESSGIT_VERSION in the .env file, then +// runs `docker compose up -d --no-deps `. Compose recreates +// only the app container, leaving sibling services (bootstrap, init-perms, +// updater itself) untouched. +// +// Returns the OLD container ID for diagnostic purposes — the actual rollback +// mechanism uses the previously-recorded PROCESSGIT_VERSION value rather +// than the container ID. +func (d *Docker) SwapContainer(ctx context.Context, newImage string) (string, error) { d.Log.Info("docker.swap", "new_image", newImage, "stub", d.Stub) if d.Stub { if err := sleepCtx(ctx, 4*time.Second); err != nil { @@ -78,40 +186,125 @@ func (d *Docker) SwapContainer(ctx context.Context, newImage string) (oldContain } return "stub-old-container-id", nil } - return "", fmt.Errorf("docker.SwapContainer: not implemented yet (Slice 3B)") + if err := d.requireFields("SwapContainer", "ComposeFile", "EnvFile"); err != nil { + return "", err + } + + // Capture the old container ID before we start the swap (logged in the Job). + oldID, _ := d.run(ctx, "container inspect ID", "inspect", "--format", "{{.Id}}", d.AppContainer) + oldID = strings.TrimSpace(oldID) + + newTag := imageVersion(newImage) + if newTag == "" { + return oldID, fmt.Errorf("could not derive version tag from image %q", newImage) + } + + if _, _, err := SetEnvFileKey(d.EnvFile, "PROCESSGIT_VERSION", newTag); err != nil { + return oldID, fmt.Errorf("update %s: %w", d.EnvFile, err) + } + + out, err := d.run(ctx, "compose up --no-deps", + "compose", "-f", d.ComposeFile, + "up", "-d", "--no-deps", d.AppContainer, + ) + if err != nil { + d.Log.Error("compose up failed", "output", out) + return oldID, err + } + d.Log.Info("compose up complete", "new_tag", newTag, "output_tail", lastLines(out, 10)) + return oldID, nil } -// Healthcheck polls the new app container's /api/healthz endpoint until it -// returns 200 OK or the context expires. +// --- Healthcheck ----------------------------------------------------------- + +// Polls the app's health endpoint until 200 OK or a 2-minute deadline. +// The endpoint comes from $PROCESSGIT_UPDATER_HEALTH_URL — typically +// `http://processgit:3000/api/v1/version` from inside the compose network. func (d *Docker) Healthcheck(ctx context.Context) error { - d.Log.Info("docker.healthcheck", "stub", d.Stub) + d.Log.Info("docker.healthcheck", "url", d.AppHealthURL, "stub", d.Stub) if d.Stub { return sleepCtx(ctx, 3*time.Second) } - return fmt.Errorf("docker.Healthcheck: not implemented yet (Slice 3B)") + if err := d.requireFields("Healthcheck", "AppHealthURL"); err != nil { + return err + } + + client := &http.Client{Timeout: 5 * time.Second} + deadline := time.Now().Add(2 * time.Minute) + var lastErr error + for time.Now().Before(deadline) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.AppHealthURL, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) + } else { + lastErr = err + } + if err := sleepCtx(ctx, 3*time.Second); err != nil { + return err + } + } + return fmt.Errorf("healthcheck timed out after 2m; last error: %v", lastErr) } -// Rollback restores the previously running container. +// --- Rollback -------------------------------------------------------------- + +// Restores the previously-running image by writing the previous +// PROCESSGIT_VERSION back to .env and running compose up again. func (d *Docker) Rollback(ctx context.Context, oldContainerID, oldImage string) error { d.Log.Info("docker.rollback", "old_container", oldContainerID, "old_image", oldImage, "stub", d.Stub) if d.Stub { return sleepCtx(ctx, 2*time.Second) } - return fmt.Errorf("docker.Rollback: not implemented yet (Slice 3B)") + if err := d.requireFields("Rollback", "ComposeFile", "EnvFile"); err != nil { + return err + } + + rollbackTag := imageVersion(oldImage) + if rollbackTag == "" { + // oldImage looks like a digest (no tag part). We don't have a + // reliable way to determine the previous PROCESSGIT_VERSION from a + // raw digest, so we fall back to "latest" — best-effort recovery. + d.Log.Warn("rollback target has no tag form; falling back to latest", "old_image", oldImage) + rollbackTag = "latest" + } + if _, _, err := SetEnvFileKey(d.EnvFile, "PROCESSGIT_VERSION", rollbackTag); err != nil { + return fmt.Errorf("revert %s: %w", d.EnvFile, err) + } + _, err := d.run(ctx, "compose up rollback", + "compose", "-f", d.ComposeFile, + "up", "-d", "--no-deps", d.AppContainer, + ) + return err } -// Snapshot captures the state of the app's persistent volumes to a tarball -// in the snapshot directory. Slice 3C will use `docker run` against a -// tar-toolbox image; for Slice 3A we just log. +// --- Snapshot (still deferred to Slice 3C) -------------------------------- + func (d *Docker) Snapshot(ctx context.Context, dst string) error { d.Log.Info("docker.snapshot", "dst", dst, "stub", d.Stub) if d.Stub { return sleepCtx(ctx, 2*time.Second) } - return fmt.Errorf("docker.Snapshot: not implemented yet (Slice 3C)") + // Slice 3C will implement: + // docker run --rm --volumes-from -v :/snap alpine \ + // tar czf /snap/data-.tgz /data + // For now we log and succeed without taking a real snapshot. The + // rollback path is still correct (it just reverts to the previous + // image tag), it just doesn't restore data if the migration mutated it. + d.Log.Warn("snapshot not implemented in Slice 3B; continuing without on-disk backup", + "dst", dst) + return nil } -// sleepCtx sleeps for d, returning early if ctx is cancelled. +// --- Helpers --------------------------------------------------------------- + func sleepCtx(ctx context.Context, d time.Duration) error { select { case <-time.After(d): @@ -120,3 +313,38 @@ func sleepCtx(ctx context.Context, d time.Duration) error { return ctx.Err() } } + +// imageVersion extracts the tag portion from a docker image ref. +// +// "ghcr.io/foo/bar:0.1.2" → "0.1.2" +// "ghcr.io/foo/bar@sha256:abc..." → "" (digest form has no tag) +// "0.1.2" → "0.1.2" +// "registry:5000/foo:0.1" → "0.1" (handles port-in-registry) +func imageVersion(ref string) string { + if strings.Contains(ref, "@") { + return "" + } + // Take the substring after the last "/", so we don't confuse a port in + // the registry hostname with the tag separator. + after := ref + if slash := strings.LastIndex(ref, "/"); slash >= 0 { + after = ref[slash+1:] + } + if colon := strings.LastIndex(after, ":"); colon >= 0 { + return after[colon+1:] + } + return ref +} + +// lastLines returns the last n lines of s, joined by '\n'. Used to truncate +// long docker-compose output in log entries. +func lastLines(s string, n int) string { + if n <= 0 { + return "" + } + parts := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(parts) <= n { + return strings.Join(parts, "\n") + } + return strings.Join(parts[len(parts)-n:], "\n") +} diff --git a/updater/env.go b/updater/env.go new file mode 100644 index 0000000..28ba902 --- /dev/null +++ b/updater/env.go @@ -0,0 +1,113 @@ +// Tiny .env-file editor. The updater needs to persist the new +// PROCESSGIT_VERSION across docker compose restarts, which means rewriting +// the deployment's .env file. +// +// Comments, blank lines, and other keys are preserved verbatim. Quoted +// values on the target key are normalized to unquoted on write (we only +// ever write simple alphanumeric/dotted values, e.g. "0.1.2"). + +package main + +import ( + "bufio" + "errors" + "fmt" + "os" + "strings" +) + +// SetEnvFileKey reads path, updates (or appends) the given key=value pair, +// and atomically writes the file back. Returns the previous value of the +// key (empty string if it wasn't set, with second-return false to +// disambiguate from a key that was set to empty). +// +// A key that appears more than once: the FIRST occurrence is updated; +// subsequent occurrences are commented out with a "# duplicate" marker. +func SetEnvFileKey(path, key, value string) (previousValue string, hadKey bool, err error) { + if key == "" { + return "", false, errors.New("key must be non-empty") + } + data, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", false, fmt.Errorf("read %s: %w", path, err) + } + // On non-existent file we'll just create one with the single line. + lines := []string{} + if len(data) > 0 { + lines = strings.Split(string(data), "\n") + } + found := false + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") || trimmed == "" { + continue + } + eq := strings.IndexByte(trimmed, '=') + if eq <= 0 { + continue + } + k := strings.TrimSpace(trimmed[:eq]) + if k != key { + continue + } + v := strings.TrimSpace(trimmed[eq+1:]) + if found { + // Duplicate — comment it out for safety. + lines[i] = "# " + line + " # duplicate of " + key + " key, commented out by updater" + continue + } + previousValue = strings.Trim(v, `"'`) + lines[i] = key + "=" + value + found = true + hadKey = true + } + if !found { + // Append, ensuring trailing newline. + if len(lines) > 0 && lines[len(lines)-1] != "" { + lines = append(lines, "") + } + lines = append(lines, key+"="+value, "") + } + return previousValue, hadKey, atomicWriteFile(path, []byte(strings.Join(lines, "\n")), 0o600) +} + +// GetEnvFileKey returns the current value of the given key. +// Returns "", false if the file doesn't exist or the key isn't set. +func GetEnvFileKey(path, key string) (string, bool, error) { + f, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return "", false, nil + } + if err != nil { + return "", false, err + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if strings.HasPrefix(line, "#") || line == "" { + continue + } + eq := strings.IndexByte(line, '=') + if eq <= 0 { + continue + } + k := strings.TrimSpace(line[:eq]) + if k == key { + v := strings.TrimSpace(line[eq+1:]) + return strings.Trim(v, `"'`), true, nil + } + } + return "", false, sc.Err() +} + +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, perm); err != nil { + return fmt.Errorf("write tmp %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("rename %s: %w", path, err) + } + return nil +} diff --git a/updater/env_test.go b/updater/env_test.go new file mode 100644 index 0000000..8bb31d4 --- /dev/null +++ b/updater/env_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// --- env file editing ------------------------------------------------------ + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestSetEnvFileKey_UpdateExisting(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, ".env") + writeFile(t, p, `# header comment +FOO=before +BAR=42 +PROCESSGIT_VERSION=0.1.0 +BAZ=keepme +`) + prev, had, err := SetEnvFileKey(p, "PROCESSGIT_VERSION", "0.1.2") + if err != nil { + t.Fatal(err) + } + if !had || prev != "0.1.0" { + t.Fatalf("expected hadKey=true prev=0.1.0; got had=%v prev=%q", had, prev) + } + got := readFile(t, p) + if !strings.Contains(got, "PROCESSGIT_VERSION=0.1.2\n") { + t.Fatalf("update missing in result:\n%s", got) + } + // other keys preserved + for _, want := range []string{"FOO=before", "BAR=42", "BAZ=keepme", "# header comment"} { + if !strings.Contains(got, want) { + t.Errorf("expected %q preserved; got:\n%s", want, got) + } + } +} + +func TestSetEnvFileKey_AppendsWhenMissing(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, ".env") + writeFile(t, p, "FOO=existing\n") + prev, had, err := SetEnvFileKey(p, "PROCESSGIT_VERSION", "0.1.0") + if err != nil { + t.Fatal(err) + } + if had { + t.Fatalf("expected hadKey=false; got true") + } + if prev != "" { + t.Fatalf("expected empty previous value, got %q", prev) + } + got := readFile(t, p) + if !strings.Contains(got, "FOO=existing") { + t.Error("FOO should be preserved") + } + if !strings.Contains(got, "PROCESSGIT_VERSION=0.1.0") { + t.Errorf("appended value missing:\n%s", got) + } +} + +func TestSetEnvFileKey_FileDoesNotExist(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, ".env") + prev, had, err := SetEnvFileKey(p, "FOO", "bar") + if err != nil { + t.Fatal(err) + } + if had || prev != "" { + t.Fatalf("unexpected prev/had on fresh file: prev=%q had=%v", prev, had) + } + got := readFile(t, p) + if !strings.Contains(got, "FOO=bar") { + t.Errorf("expected new file to contain FOO=bar; got:\n%s", got) + } +} + +func TestSetEnvFileKey_QuotedValues(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, ".env") + writeFile(t, p, `PROCESSGIT_VERSION="0.1.0"`+"\n") + prev, _, err := SetEnvFileKey(p, "PROCESSGIT_VERSION", "0.2.0") + if err != nil { + t.Fatal(err) + } + if prev != "0.1.0" { + t.Fatalf("expected unquoted previous value 0.1.0; got %q", prev) + } +} + +func TestSetEnvFileKey_DuplicatesCommented(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, ".env") + writeFile(t, p, `PROCESSGIT_VERSION=first +FOO=bar +PROCESSGIT_VERSION=second +`) + prev, had, err := SetEnvFileKey(p, "PROCESSGIT_VERSION", "third") + if err != nil { + t.Fatal(err) + } + if !had || prev != "first" { + t.Fatalf("expected hadKey=true prev=first; got had=%v prev=%q", had, prev) + } + got := readFile(t, p) + if !strings.Contains(got, "PROCESSGIT_VERSION=third") { + t.Error("first occurrence not updated") + } + if !strings.Contains(got, "# PROCESSGIT_VERSION=second") { + t.Errorf("duplicate not commented out:\n%s", got) + } +} + +func TestSetEnvFileKey_EmptyKeyRejected(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, ".env") + _, _, err := SetEnvFileKey(p, "", "value") + if err == nil { + t.Fatal("expected error on empty key") + } +} + +func TestGetEnvFileKey(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, ".env") + writeFile(t, p, `# comment +FOO=bar +EMPTY= +QUOTED="hello world" +`) + tests := []struct { + key string + wantVal string + wantOK bool + }{ + {"FOO", "bar", true}, + {"EMPTY", "", true}, + {"QUOTED", "hello world", true}, + {"MISSING", "", false}, + } + for _, tt := range tests { + v, ok, err := GetEnvFileKey(p, tt.key) + if err != nil { + t.Fatalf("%s: %v", tt.key, err) + } + if v != tt.wantVal || ok != tt.wantOK { + t.Errorf("%s: got val=%q ok=%v; want %q %v", tt.key, v, ok, tt.wantVal, tt.wantOK) + } + } +} + +// --- imageVersion helper --------------------------------------------------- + +func TestImageVersion(t *testing.T) { + tests := []struct { + ref string + want string + }{ + {"ghcr.io/foo/bar:0.1.2", "0.1.2"}, + {"ghcr.io/foo/bar:latest", "latest"}, + {"ghcr.io/foo/bar:0.1.0-rc1", "0.1.0-rc1"}, + {"ghcr.io/foo/bar@sha256:abcdef", ""}, // digest form has no tag + {"registry:5000/foo/bar:0.1", "0.1"}, // port in registry hostname + {"0.1.2", "0.1.2"}, // bare version + {"bar:0.1.2", "0.1.2"}, + {"bar", "bar"}, // no colon at all — treat whole as version + } + for _, tt := range tests { + if got := imageVersion(tt.ref); got != tt.want { + t.Errorf("imageVersion(%q) = %q; want %q", tt.ref, got, tt.want) + } + } +} + +// --- lastLines ------------------------------------------------------------ + +func TestLastLines(t *testing.T) { + in := "a\nb\nc\nd\ne\n" + if got := lastLines(in, 2); got != "d\ne" { + t.Errorf("lastLines(_, 2) = %q; want %q", got, "d\ne") + } + if got := lastLines(in, 10); got != "a\nb\nc\nd\ne" { + t.Errorf("lastLines(_, 10) = %q", got) + } + if got := lastLines("", 5); got != "" { + t.Errorf("lastLines(empty, 5) = %q", got) + } + if got := lastLines("only one", 1); got != "only one" { + t.Errorf("lastLines(single, 1) = %q", got) + } +} diff --git a/updater/main.go b/updater/main.go index b6f0969..de006ca 100644 --- a/updater/main.go +++ b/updater/main.go @@ -43,6 +43,9 @@ func main() { githubAPI := envOr("PROCESSGIT_UPDATER_GITHUB_API", "https://api.github.com") githubToken := os.Getenv("PROCESSGIT_UPDATER_GITHUB_TOKEN") appContainer := envOr("PROCESSGIT_UPDATER_APP_CONTAINER", "processgit") + composeFile := envOr("PROCESSGIT_UPDATER_COMPOSE_FILE", "/deploy/docker-compose.yml") + envFile := envOr("PROCESSGIT_UPDATER_ENV_FILE", "/deploy/.env") + healthURL := envOr("PROCESSGIT_UPDATER_HEALTH_URL", "http://processgit:3000/api/v1/version") stub := envBool("PROCESSGIT_UPDATER_STUB", true) // Slice 3A: stub by default token := os.Getenv("PROCESSGIT_UPDATER_TOKEN") @@ -80,6 +83,9 @@ func main() { gh := NewGitHubClient(githubAPI, repo, githubToken) cosign := NewCosign() docker := NewDocker(log.With("component", "docker"), appContainer, stub) + docker.ComposeFile = composeFile + docker.EnvFile = envFile + docker.AppHealthURL = healthURL orch := &Orchestrator{ Store: store, GitHub: gh, @@ -113,6 +119,9 @@ func main() { "state_dir", stateDir, "repo", repo, "app_container", appContainer, + "compose_file", composeFile, + "env_file", envFile, + "health_url", healthURL, "stub_mode", stub, )