diff --git a/cmd/spinloop/main.go b/cmd/spinloop/main.go index b5fdecf6..bc63657e 100644 --- a/cmd/spinloop/main.go +++ b/cmd/spinloop/main.go @@ -77,6 +77,7 @@ func main() { os.Args = append(os.Args, "") } } + remote.SetCLIVersion(version) if err := run(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) diff --git a/cmd/spinloop/remote_bootstrap.go b/cmd/spinloop/remote_bootstrap.go index 93d271c7..4e236f13 100644 --- a/cmd/spinloop/remote_bootstrap.go +++ b/cmd/spinloop/remote_bootstrap.go @@ -37,6 +37,10 @@ var ( preflightFn = checkNodeAndPackageManager ) +// controlPlaneVersionEnv is read by the CDK app (remote/lib/config.ts) to stamp +// the control plane with the version of the CLI that deploys it. +const controlPlaneVersionEnv = "SPINLOOP_CONTROL_PLANE_VERSION" + // packageManagerEnv pins the Node package manager bootstrap drives the CDK // project with, when the --package-manager flag is not given. const packageManagerEnv = "SPINLOOP_REMOTE_PACKAGE_MANAGER" @@ -278,6 +282,9 @@ func execStep(ctx context.Context, name string, argv []string, workDir string) e fmt.Fprintf(os.Stderr, "\n$ %s\n", strings.Join(argv, " ")) cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) cmd.Dir = workDir + // The control plane reports this version in every response, so a CLI at a + // different version can warn about the mismatch. + cmd.Env = append(os.Environ(), controlPlaneVersionEnv+"="+version) cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr if err := cmd.Run(); err != nil { if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) { diff --git a/cmd/spinloop/remote_bootstrap_test.go b/cmd/spinloop/remote_bootstrap_test.go index 65569350..73e2f5d3 100644 --- a/cmd/spinloop/remote_bootstrap_test.go +++ b/cmd/spinloop/remote_bootstrap_test.go @@ -381,3 +381,16 @@ func TestBootstrap_NpmOverrideDrivesNpmCommands(t *testing.T) { t.Errorf("commands = %v, want %v", got, want) } } + +// Each bootstrap step runs with the CLI's version in its environment, which +// the CDK app stamps onto the control plane. +func TestExecStep_PassesTheControlPlaneVersion(t *testing.T) { + orig := version + t.Cleanup(func() { version = orig }) + version = "1.30.0" + + check := []string{"sh", "-c", `test "$` + controlPlaneVersionEnv + `" = "1.30.0"`} + if err := execStep(context.Background(), "check-env", check, t.TempDir()); err != nil { + t.Errorf("the step did not see %s=1.30.0: %v", controlPlaneVersionEnv, err) + } +} diff --git a/docs/commands/remote.md b/docs/commands/remote.md index 41a786ae..9bcb9440 100644 --- a/docs/commands/remote.md +++ b/docs/commands/remote.md @@ -54,6 +54,13 @@ path, logging which one it picked. To pin the choice, pass `--package-manager` env var. A pinned manager that isn't installed fails the preflight rather than falling back. `spinloop remote bake` honours the same flags. +Bootstrap stamps the control plane with the version of the `spinloop` that deploys +it, and every control plane response carries that version. When a later `remote` +command sees a version different from its own, it prints one warning to stderr +naming both, and carries on; re-run `spinloop remote bootstrap` to bring the +control plane up to date. No warning appears for a control plane deployed before +this was added, or when either side is a development build. + ## Baking the AMIs Each engine runs from a baked AMI (driver + engine, no model). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c3f4a933..43734c08 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -76,6 +76,10 @@ scaled; see [`spinloop serve`](commands/serve.md#parallelism). an endpoint needs `spinloop remote bootstrap` to have run once per account; a missing control plane says so. An older control plane that lacks a feature says to re-run `bootstrap` to add it. +- **A warning says the control plane is at a different version.** The + `spinloop` you are running differs from the one that deployed the control + plane. Commands still run, but re-run `spinloop remote bootstrap` to update + it, or install the matching `spinloop`. - **The AMI is not baked.** `spinloop remote bake` once per engine, and it waits until the AMI is available. - **A cold start takes about ten minutes.** `start` prints its progress on diff --git a/internal/remote/remote.go b/internal/remote/remote.go index 3b71f2af..93dc9a49 100644 --- a/internal/remote/remote.go +++ b/internal/remote/remote.go @@ -592,6 +592,7 @@ func send( if err != nil { return 0, nil, err } + checkControlPlaneVersion(resp.Header) return resp.StatusCode, respBody, nil } @@ -838,6 +839,7 @@ func callStats(ctx context.Context, cfg Config) (*StatsResponse, error) { if err != nil { return nil, err } + checkControlPlaneVersion(resp.Header) out := &StatsResponse{StatusCode: resp.StatusCode} if err := json.Unmarshal(respBody, out); err != nil { hint := "" diff --git a/internal/remote/versioncheck.go b/internal/remote/versioncheck.go new file mode 100644 index 00000000..4636a901 --- /dev/null +++ b/internal/remote/versioncheck.go @@ -0,0 +1,58 @@ +package remote + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" +) + +// ControlPlaneVersionHeader is the response header every control plane Lambda +// sets to the spinloop version it was deployed with. +const ControlPlaneVersionHeader = "X-Spinloop-Control-Plane-Version" + +var ( + // cliVersion is the running binary's version, set once at startup by + // SetCLIVersion. While empty, no comparison is made. + cliVersion string + // versionWarnWriter receives the mismatch warning. A variable so tests can + // capture it. + versionWarnWriter io.Writer = os.Stderr + versionWarnOnce sync.Once +) + +// SetCLIVersion records the running binary's version for the comparison +// against the control plane's. +func SetCLIVersion(v string) { + cliVersion = v +} + +// versionsDiffer reports whether the CLI and control plane versions are both +// real release versions and are not the same. An empty or "dev" value on +// either side is not comparable, and a leading "v" is ignored. +func versionsDiffer(cli, controlPlane string) bool { + cli = strings.TrimPrefix(strings.TrimSpace(cli), "v") + controlPlane = strings.TrimPrefix(strings.TrimSpace(controlPlane), "v") + if cli == "" || cli == "dev" || controlPlane == "" || controlPlane == "dev" { + return false + } + return cli != controlPlane +} + +// checkControlPlaneVersion writes a warning to stderr, at most once per +// process, when the response headers carry a control plane version that +// differs from the CLI's. A response without the header (a control plane that +// predates it) produces no warning. +func checkControlPlaneVersion(h http.Header) { + got := h.Get(ControlPlaneVersionHeader) + if !versionsDiffer(cliVersion, got) { + return + } + versionWarnOnce.Do(func() { + fmt.Fprintf(versionWarnWriter, + "Warning: the control plane is at version %s but this spinloop is %s. Run `spinloop remote bootstrap` to bring it up to date.\n", + strings.TrimPrefix(got, "v"), strings.TrimPrefix(cliVersion, "v")) + }) +} diff --git a/internal/remote/versioncheck_test.go b/internal/remote/versioncheck_test.go new file mode 100644 index 00000000..9ab52693 --- /dev/null +++ b/internal/remote/versioncheck_test.go @@ -0,0 +1,152 @@ +package remote + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// captureVersionWarning points the warning at a buffer, sets the CLI version, +// and re-arms the once-per-process guard, restoring all three afterwards. +func captureVersionWarning(t *testing.T, cli string) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + origVersion, origWriter := cliVersion, versionWarnWriter + t.Cleanup(func() { + cliVersion, versionWarnWriter = origVersion, origWriter + versionWarnOnce = sync.Once{} + }) + cliVersion, versionWarnWriter = cli, &buf + versionWarnOnce = sync.Once{} + return &buf +} + +func headerWith(v string) http.Header { + h := http.Header{} + if v != "" { + h.Set(ControlPlaneVersionHeader, v) + } + return h +} + +func TestVersionsDiffer(t *testing.T) { + cases := []struct { + name, cli, controlPlane string + want bool + }{ + {"different", "1.30.0", "1.28.0", true}, + {"same", "1.30.0", "1.30.0", false}, + {"v prefix on the CLI", "v1.30.0", "1.30.0", false}, + {"v prefix on the control plane", "1.30.0", "v1.30.0", false}, + {"header absent", "1.30.0", "", false}, + {"control plane dev", "1.30.0", "dev", false}, + {"cli dev", "dev", "1.30.0", false}, + {"cli empty", "", "1.30.0", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := versionsDiffer(c.cli, c.controlPlane); got != c.want { + t.Errorf("versionsDiffer(%q, %q) = %v, want %v", c.cli, c.controlPlane, got, c.want) + } + }) + } +} + +func TestCheckControlPlaneVersion_WarnsOnceOnMismatch(t *testing.T) { + buf := captureVersionWarning(t, "1.30.0") + checkControlPlaneVersion(headerWith("1.28.0")) + checkControlPlaneVersion(headerWith("1.28.0")) + + out := buf.String() + if strings.Count(out, "Warning:") != 1 { + t.Errorf("expected exactly one warning, got %q", out) + } + for _, want := range []string{"1.28.0", "1.30.0", "spinloop remote bootstrap"} { + if !strings.Contains(out, want) { + t.Errorf("warning %q does not mention %q", out, want) + } + } +} + +func TestCheckControlPlaneVersion_SilentWhenNotComparable(t *testing.T) { + for _, h := range []string{"1.30.0", "v1.30.0", "", "dev"} { + buf := captureVersionWarning(t, "1.30.0") + checkControlPlaneVersion(headerWith(h)) + if buf.Len() != 0 { + t.Errorf("header %q: expected no warning, got %q", h, buf.String()) + } + } +} + +// Every control plane call passes through send or callStats; each reads the +// header off the response to the call itself. +func TestControlPlaneCalls_WarnFromTheirOwnResponse(t *testing.T) { + stubAWSEnv(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set(ControlPlaneVersionHeader, "1.28.0") + w.Write([]byte(`{"state":"stopped"}`)) + })) + defer server.Close() + cfg := Config{StartURL: server.URL, StatsURL: server.URL, Region: "eu-west-1"} + + t.Run("status", func(t *testing.T) { + buf := captureVersionWarning(t, "1.30.0") + if _, err := Status(context.Background(), cfg); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "1.28.0") { + t.Errorf("expected a warning naming the control plane version, got %q", buf.String()) + } + }) + + t.Run("stats", func(t *testing.T) { + buf := captureVersionWarning(t, "1.30.0") + if _, err := Stats(context.Background(), cfg); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "1.28.0") { + t.Errorf("expected a warning naming the control plane version, got %q", buf.String()) + } + }) +} + +func TestControlPlaneCalls_WarnOnErrorResponses(t *testing.T) { + stubAWSEnv(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set(ControlPlaneVersionHeader, "1.28.0") + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(`{"message":"boom"}`)) + })) + defer server.Close() + + buf := captureVersionWarning(t, "1.30.0") + if _, err := Status(context.Background(), Config{StartURL: server.URL, Region: "eu-west-1"}); err == nil { + t.Fatal("expected the 502 to be an error") + } + if !strings.Contains(buf.String(), "1.28.0") { + t.Errorf("expected a warning on an error response, got %q", buf.String()) + } +} + +func TestControlPlaneCalls_NoHeaderNoWarning(t *testing.T) { + stubAWSEnv(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"state":"stopped"}`)) + })) + defer server.Close() + + buf := captureVersionWarning(t, "1.30.0") + if _, err := Status(context.Background(), Config{StartURL: server.URL, Region: "eu-west-1"}); err != nil { + t.Fatal(err) + } + if buf.Len() != 0 { + t.Errorf("expected no warning without the header, got %q", buf.String()) + } +} diff --git a/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/.openspec.yaml b/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/.openspec.yaml new file mode 100644 index 00000000..e3966d7a --- /dev/null +++ b/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-05 diff --git a/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/design.md b/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/design.md new file mode 100644 index 00000000..a29f7c82 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/design.md @@ -0,0 +1,30 @@ +## Context + +The control plane is a set of Lambdas deployed by CDK from `remote/`, using sources version-matched to the CLI (`ResolveRef`). The CLI calls them through `call`, `send` and `callStats` in `internal/remote/remote.go`. All Lambdas build responses through `jsonResponse` in `remote/lambda/shared/http.ts`. + +## Goals / Non-Goals + +**Goals:** +- A version mismatch warning on every remote command, with no extra API call. +- One place on each side that needs to change. + +**Non-Goals:** +- Blocking commands on a mismatch. +- Comparing semantic versions (older vs newer); any difference warns. +- Changing response bodies. + +## Decisions + +**Response header, not a body field.** `jsonResponse` is used by every Lambda response, error ones included, so one edit covers all of them. Bodies differ in shape per Lambda (some are not objects), and the Go side would need a field on each reply struct. A header is read in `send`/`callStats`, which every call passes through. + +**Version injected at deploy through CDK context.** `config.ts` already reads CDK context values; add `controlPlaneVersion` (context key `controlPlaneVersion`, default `dev`) and set it as `CONTROL_PLANE_VERSION` in `commonEnv` so all Lambdas get it. Bootstrap passes `-c controlPlaneVersion=` to the `deploy` script via an environment variable read by `loadConfig` (`SPINLOOP_CONTROL_PLANE_VERSION`), which avoids changing the package scripts. `jsonResponse` reads `process.env.CONTROL_PLANE_VERSION` and falls back to `dev`. + +**Warning once, in the transport layer.** `send` and `callStats` return the header value; a small function in `internal/remote` compares it against the CLI version and calls a warning writer, guarded by `sync.Once`. The CLI version is set at startup from `main.version`. Comparison trims a leading `v`. Absent header, empty, or `dev` on either side produces no warning. + +**Stderr.** The warning is written to stderr in the CLI's chrome style per `cli-ux`, so JSON and other piped stdout stay clean. + +## Risks / Trade-offs + +- A control plane deployed from a dev build reports `dev` and never warns. Accepted; dev builds are not comparable. +- A once-per-process guard means a long-running `daemon` or `gateway` process warns once, not on every poll. Accepted. +- Existing control planes show no warning until re-bootstrapped. Accepted; there is no reliable way to tell them from old ones without a call. diff --git a/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/proposal.md b/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/proposal.md new file mode 100644 index 00000000..a2de8486 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/proposal.md @@ -0,0 +1,28 @@ +## Why + +The `spinloop remote` commands call a control plane (Lambdas in the user's AWS account) that was deployed from a particular spinloop release. When the CLI is upgraded and the control plane is not, the two can disagree about request and response shapes, and nothing tells the user. Issue 213 asks for a warning on any cloud command when the versions differ, without an extra API call. + +## What Changes + +- Every response from a control plane Lambda carries the control plane's version in a response header, `x-spinloop-control-plane-version`. +- `spinloop remote bootstrap` stamps the control plane with the version of the CLI that deploys it, through a CDK context value that becomes a Lambda environment variable. +- The CLI reads that header on every control plane call and, when it differs from the CLI's own version, prints one warning to stderr per process naming both versions and the fix (`spinloop remote bootstrap`). +- No warning when the header is absent (a control plane that predates this change) or when either side is a development build. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `remote-version-reporting`: adds the control plane version header and the CLI's mismatch warning. +- `endpoint-provisioning`: bootstrap records the deploying CLI's version in the control plane. + +## Impact + +- `remote/lib/llm-stack.ts`, `remote/lib/config.ts`, `remote/lambda/shared/http.ts` (CDK and Lambda response helper). +- `internal/remote/remote.go` (read the header in `send` and `callStats`), `cmd/spinloop/remote*.go` (print the warning, pass the version into bootstrap). +- Docs for `spinloop remote` and the `remote/` README. +- No new API calls and no change to response bodies. diff --git a/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/specs/endpoint-provisioning/spec.md b/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/specs/endpoint-provisioning/spec.md new file mode 100644 index 00000000..37685db4 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/specs/endpoint-provisioning/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Bootstrap records the deploying CLI's version + +`spinloop remote bootstrap` SHALL pass the running binary's version to the control plane deploy, so every control plane Lambda reports it in the `x-spinloop-control-plane-version` response header. A binary built without a version SHALL record `dev`. + +#### Scenario: A release build bootstraps + +- **WHEN** a CLI at version `1.30.0` runs `spinloop remote bootstrap` +- **THEN** the deployed Lambdas report `1.30.0` as the control plane version + +#### Scenario: A development build bootstraps + +- **WHEN** a CLI built without a version override runs `spinloop remote bootstrap` +- **THEN** the deployed Lambdas report `dev` diff --git a/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/specs/remote-version-reporting/spec.md b/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/specs/remote-version-reporting/spec.md new file mode 100644 index 00000000..aff3bd33 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/specs/remote-version-reporting/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Control plane responses carry the control plane version + +Every response from a control plane Lambda SHALL include the header `x-spinloop-control-plane-version`, whose value is the spinloop version the control plane was deployed with. The header SHALL be present on error responses as well as successful ones. When the control plane was deployed without a version, the value SHALL be `dev`. + +#### Scenario: A successful response carries the header + +- **WHEN** the start, stop, deploy, env, stats, seed or update Lambda returns a 200 response +- **THEN** the response includes `x-spinloop-control-plane-version` set to the deployed version + +#### Scenario: An error response carries the header + +- **WHEN** a control plane Lambda returns a 4xx or 5xx response +- **THEN** the response still includes `x-spinloop-control-plane-version` + +### Requirement: The CLI warns when the control plane version differs + +When a `spinloop remote` command receives a control plane response whose `x-spinloop-control-plane-version` differs from the CLI's own version, the CLI SHALL print a single warning to stderr naming both versions and suggesting `spinloop remote bootstrap`. The version SHALL be read from the response to the command's own request, with no extra call. The warning SHALL be printed at most once per process and SHALL NOT change the command's exit status or stdout. + +#### Scenario: Versions differ + +- **WHEN** the CLI is `1.30.0` and a control plane call returns the header `1.28.0` +- **THEN** stderr gets one warning naming `1.30.0` and `1.28.0` and suggesting `spinloop remote bootstrap` +- **AND** the command carries on and exits as it would have without the warning + +#### Scenario: Several calls in one command + +- **WHEN** one command makes several control plane calls that all return a differing version +- **THEN** the warning is printed once + +#### Scenario: Versions match + +- **WHEN** the header equals the CLI's version, ignoring a leading `v` +- **THEN** no warning is printed + +#### Scenario: The control plane predates the header + +- **WHEN** a control plane response has no `x-spinloop-control-plane-version` header +- **THEN** no warning is printed + +#### Scenario: A development build is on either side + +- **WHEN** the CLI version or the header value is `dev` or empty +- **THEN** no warning is printed + +#### Scenario: Machine-readable output is untouched + +- **WHEN** a command run with JSON output prints a version warning +- **THEN** the warning goes to stderr and stdout holds only the JSON diff --git a/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/tasks.md b/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/tasks.md new file mode 100644 index 00000000..bcc21629 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-warn-on-control-plane-version-mismatch/tasks.md @@ -0,0 +1,17 @@ +## 1. Control plane + +- [x] 1.1 Add `controlPlaneVersion` to `remote/lib/config.ts` (context key, env var `SPINLOOP_CONTROL_PLANE_VERSION`, default `dev`) +- [x] 1.2 Set `CONTROL_PLANE_VERSION` in `commonEnv` for every Lambda in `remote/lib/llm-stack.ts` +- [x] 1.3 Add `x-spinloop-control-plane-version` to every response in `jsonResponse` (`remote/lambda/shared/http.ts`) +- [x] 1.4 Add vitest cases: header on success and error responses, `dev` default, stack passes the env var + +## 2. CLI + +- [x] 2.1 Return response headers from `send` and `callStats` and compare the version header against the CLI version (trim `v`, skip when absent, empty or `dev`) +- [x] 2.2 Print the warning once per process to stderr, naming both versions and `spinloop remote bootstrap` +- [x] 2.3 Pass the CLI version to the bootstrap deploy as `SPINLOOP_CONTROL_PLANE_VERSION` +- [x] 2.4 Add Go tests: mismatch, match, `v` prefix, absent header, dev on either side, once per process, stdout untouched + +## 3. Docs + +- [x] 3.1 Document the warning in the `spinloop remote` docs and the `remote/` README diff --git a/openspec/specs/endpoint-provisioning/spec.md b/openspec/specs/endpoint-provisioning/spec.md index 67782158..06ab99ee 100644 --- a/openspec/specs/endpoint-provisioning/spec.md +++ b/openspec/specs/endpoint-provisioning/spec.md @@ -287,3 +287,17 @@ allowed ingress CIDR is also per-environment and belongs to `deploy`, not here. - **WHEN** the user runs bootstrap - **THEN** no ingress CIDR is requested or written, since it is scoped per environment at `spinloop remote deploy` + +### Requirement: Bootstrap records the deploying CLI's version + +`spinloop remote bootstrap` SHALL pass the running binary's version to the control plane deploy, so every control plane Lambda reports it in the `x-spinloop-control-plane-version` response header. A binary built without a version SHALL record `dev`. + +#### Scenario: A release build bootstraps + +- **WHEN** a CLI at version `1.30.0` runs `spinloop remote bootstrap` +- **THEN** the deployed Lambdas report `1.30.0` as the control plane version + +#### Scenario: A development build bootstraps + +- **WHEN** a CLI built without a version override runs `spinloop remote bootstrap` +- **THEN** the deployed Lambdas report `dev` diff --git a/openspec/specs/remote-version-reporting/spec.md b/openspec/specs/remote-version-reporting/spec.md index 8710bcfc..7e872297 100644 --- a/openspec/specs/remote-version-reporting/spec.md +++ b/openspec/specs/remote-version-reporting/spec.md @@ -2,7 +2,9 @@ ## Purpose Reports the spinloop version running on a remote instance or fleet node so the operator can answer "is this node on the release I expect?" without SSH access. + ## Requirements + ### Requirement: An environment's status shows version `spinloop status --env ` SHALL display the spinloop version running on the remote instance alongside its existing state, health, and base URL fields. @@ -69,3 +71,51 @@ The daemon's `GET /v1/status` response SHALL include a `version` field containin - **WHEN** nodes in the fleet run different spinloop versions - **THEN** each node's row shows its own version, making the difference visible +### Requirement: Control plane responses carry the control plane version + +Every response from a control plane Lambda SHALL include the header `x-spinloop-control-plane-version`, whose value is the spinloop version the control plane was deployed with. The header SHALL be present on error responses as well as successful ones. When the control plane was deployed without a version, the value SHALL be `dev`. + +#### Scenario: A successful response carries the header + +- **WHEN** the start, stop, deploy, env, stats, seed or update Lambda returns a 200 response +- **THEN** the response includes `x-spinloop-control-plane-version` set to the deployed version + +#### Scenario: An error response carries the header + +- **WHEN** a control plane Lambda returns a 4xx or 5xx response +- **THEN** the response still includes `x-spinloop-control-plane-version` + +### Requirement: The CLI warns when the control plane version differs + +When a `spinloop remote` command receives a control plane response whose `x-spinloop-control-plane-version` differs from the CLI's own version, the CLI SHALL print a single warning to stderr naming both versions and suggesting `spinloop remote bootstrap`. The version SHALL be read from the response to the command's own request, with no extra call. The warning SHALL be printed at most once per process and SHALL NOT change the command's exit status or stdout. + +#### Scenario: Versions differ + +- **WHEN** the CLI is `1.30.0` and a control plane call returns the header `1.28.0` +- **THEN** stderr gets one warning naming `1.30.0` and `1.28.0` and suggesting `spinloop remote bootstrap` +- **AND** the command carries on and exits as it would have without the warning + +#### Scenario: Several calls in one command + +- **WHEN** one command makes several control plane calls that all return a differing version +- **THEN** the warning is printed once + +#### Scenario: Versions match + +- **WHEN** the header equals the CLI's version, ignoring a leading `v` +- **THEN** no warning is printed + +#### Scenario: The control plane predates the header + +- **WHEN** a control plane response has no `x-spinloop-control-plane-version` header +- **THEN** no warning is printed + +#### Scenario: A development build is on either side + +- **WHEN** the CLI version or the header value is `dev` or empty +- **THEN** no warning is printed + +#### Scenario: Machine-readable output is untouched + +- **WHEN** a command run with JSON output prints a version warning +- **THEN** the warning goes to stderr and stdout holds only the JSON diff --git a/remote/README.md b/remote/README.md index ab49b758..8953ca95 100644 --- a/remote/README.md +++ b/remote/README.md @@ -210,6 +210,7 @@ layer's own settings all have defaults, overridable in `cdk.json`: | `stopRetentionMinutes` | `60` | Keep a stopped instance (re-wakeable) this long before terminating it | | `gracePeriodMinutes` | `30` | Never stop this soon after boot (covers the cold load) | | `maxRuntimeMinutes` | `240` | Hard stop this long after boot, even if busy | +| `controlPlaneVersion` | `dev` | The spinloop version reported in the `x-spinloop-control-plane-version` header of every Lambda response, so the CLI can warn on a mismatch. `spinloop remote bootstrap` sets it (through `SPINLOOP_CONTROL_PLANE_VERSION`) to its own version; a hand-run `pnpm run deploy` leaves it `dev`, which never triggers the warning | The **model, quant, context window and engine flags are not in this table** — they come from the `Spinloop` and its preset via `spinloop remote deploy`, so diff --git a/remote/lambda/shared/http.ts b/remote/lambda/shared/http.ts index b77cf37c..dae95f06 100644 --- a/remote/lambda/shared/http.ts +++ b/remote/lambda/shared/http.ts @@ -1,5 +1,10 @@ import type { LambdaFunctionURLResult } from 'aws-lambda'; +// Names the spinloop version this control plane was deployed with. The CLI +// compares it against its own version on every call, so it rides on every +// response — errors included — rather than costing a separate request. +export const CONTROL_PLANE_VERSION_HEADER = 'x-spinloop-control-plane-version'; + export function jsonResponse( statusCode: number, body: unknown, @@ -7,7 +12,11 @@ export function jsonResponse( ): LambdaFunctionURLResult { return { statusCode, - headers: { 'content-type': 'application/json', ...extraHeaders }, + headers: { + 'content-type': 'application/json', + [CONTROL_PLANE_VERSION_HEADER]: process.env.CONTROL_PLANE_VERSION || 'dev', + ...extraHeaders, + }, body: JSON.stringify(body), }; } diff --git a/remote/lib/config.ts b/remote/lib/config.ts index 199ac53c..1f4493c9 100644 --- a/remote/lib/config.ts +++ b/remote/lib/config.ts @@ -20,6 +20,12 @@ export const AMI_RUNNER_TAG_KEY = 'cloud-vm-llm:runner'; */ export interface LlmConfig { region: string; + /** + * The spinloop version this control plane was deployed with. Every Lambda + * reports it in a response header so the CLI can warn when it differs from + * its own. `spinloop remote bootstrap` supplies it; "dev" when unset. + */ + controlPlaneVersion: string; /** Optional Hugging Face token, used only for the seeding of gated repos. */ hfToken: string; /** Instance type every environment's runtime instance launches as. */ @@ -222,6 +228,11 @@ export function loadConfig( return { region: contextString(app, 'region', DEFAULTS.region), + controlPlaneVersion: contextString( + app, + 'controlPlaneVersion', + process.env.SPINLOOP_CONTROL_PLANE_VERSION || 'dev', + ), hfToken: contextString(app, 'hfToken', dotEnv.HF_TOKEN ?? DEFAULTS.hfToken), instanceType: contextString(app, 'instanceType', DEFAULTS.instanceType), vllmVersion: contextString(app, 'vllmVersion', DEFAULTS.vllmVersion), diff --git a/remote/lib/llm-stack.ts b/remote/lib/llm-stack.ts index 90c4016f..8dff4d76 100644 --- a/remote/lib/llm-stack.ts +++ b/remote/lib/llm-stack.ts @@ -335,6 +335,7 @@ export class LlmStack extends cdk.Stack { }); const commonEnv = { + CONTROL_PLANE_VERSION: cfg.controlPlaneVersion, TAG_KEY, TAG_VALUE, ENGINE_PORT: String(cfg.enginePort), @@ -499,6 +500,7 @@ export class LlmStack extends cdk.Stack { memorySize: 256, logGroup: lambdaLogGroup('DeployFnLogGroup', 'deploy'), environment: { + CONTROL_PLANE_VERSION: cfg.controlPlaneVersion, ENGINE_PORT: String(cfg.enginePort), VPC_ID: vpc.vpcId, // Seeding: the Lambda launches the disposable download instance itself @@ -569,6 +571,7 @@ export class LlmStack extends cdk.Stack { memorySize: 256, logGroup: lambdaLogGroup('SeedFnLogGroup', 'seed'), environment: { + CONTROL_PLANE_VERSION: cfg.controlPlaneVersion, TAG_KEY, ...seedEnv, MAX_CONCURRENT_SEEDS: String(cfg.maxConcurrentSeeds), @@ -627,6 +630,7 @@ export class LlmStack extends cdk.Stack { memorySize: 128, logGroup: lambdaLogGroup('EnvFnLogGroup', 'env'), environment: { + CONTROL_PLANE_VERSION: cfg.controlPlaneVersion, TAG_KEY, TAG_VALUE, ENGINE_PORT: String(cfg.enginePort), @@ -662,6 +666,7 @@ export class LlmStack extends cdk.Stack { memorySize: 128, logGroup: lambdaLogGroup('UpdateFnLogGroup', 'update'), environment: { + CONTROL_PLANE_VERSION: cfg.controlPlaneVersion, TAG_KEY, TAG_VALUE, }, diff --git a/remote/test/control-plane-version.test.ts b/remote/test/control-plane-version.test.ts new file mode 100644 index 00000000..9e2e6d03 --- /dev/null +++ b/remote/test/control-plane-version.test.ts @@ -0,0 +1,87 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as cdk from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import { afterEach, describe, expect, it } from 'vitest'; +import { loadConfig } from '../lib/config'; +import { LlmStack } from '../lib/llm-stack'; +import { CONTROL_PLANE_VERSION_HEADER, jsonResponse } from '../lambda/shared/http'; + +const NO_DOTENV = path.join(os.tmpdir(), 'cloud-vm-llm-no-such-env'); + +// jsonResponse always builds the structured form of the Lambda result. +function headersOf(res: ReturnType): Record { + return (res as { headers: Record }).headers; +} + +describe('control plane version header', () => { + const original = process.env.CONTROL_PLANE_VERSION; + afterEach(() => { + if (original === undefined) { + delete process.env.CONTROL_PLANE_VERSION; + } else { + process.env.CONTROL_PLANE_VERSION = original; + } + }); + + it('is named x-spinloop-control-plane-version', () => { + expect(CONTROL_PLANE_VERSION_HEADER).toBe('x-spinloop-control-plane-version'); + }); + + it('carries the deployed version on a success response', () => { + process.env.CONTROL_PLANE_VERSION = '1.30.0'; + const res = jsonResponse(200, { ok: true }); + expect(headersOf(res)[CONTROL_PLANE_VERSION_HEADER]).toBe('1.30.0'); + expect(headersOf(res)['content-type']).toBe('application/json'); + }); + + it('carries the deployed version on an error response', () => { + process.env.CONTROL_PLANE_VERSION = '1.30.0'; + expect(headersOf(jsonResponse(503, { message: 'x' }))[CONTROL_PLANE_VERSION_HEADER]).toBe('1.30.0'); + }); + + it('falls back to dev when no version was deployed', () => { + delete process.env.CONTROL_PLANE_VERSION; + expect(headersOf(jsonResponse(200, {}))[CONTROL_PLANE_VERSION_HEADER]).toBe('dev'); + }); +}); + +describe('control plane version config', () => { + const original = process.env.SPINLOOP_CONTROL_PLANE_VERSION; + afterEach(() => { + if (original === undefined) { + delete process.env.SPINLOOP_CONTROL_PLANE_VERSION; + } else { + process.env.SPINLOOP_CONTROL_PLANE_VERSION = original; + } + }); + + it('defaults to dev', () => { + delete process.env.SPINLOOP_CONTROL_PLANE_VERSION; + expect(loadConfig(new cdk.App(), NO_DOTENV).controlPlaneVersion).toBe('dev'); + }); + + it('reads SPINLOOP_CONTROL_PLANE_VERSION', () => { + process.env.SPINLOOP_CONTROL_PLANE_VERSION = '1.30.0'; + expect(loadConfig(new cdk.App(), NO_DOTENV).controlPlaneVersion).toBe('1.30.0'); + }); + + it('lets the controlPlaneVersion context value win', () => { + process.env.SPINLOOP_CONTROL_PLANE_VERSION = '1.30.0'; + const app = new cdk.App({ context: { controlPlaneVersion: '2.0.0' } }); + expect(loadConfig(app, NO_DOTENV).controlPlaneVersion).toBe('2.0.0'); + }); + + it('sets CONTROL_PLANE_VERSION on every Lambda', () => { + const app = new cdk.App({ context: { controlPlaneVersion: '1.30.0' } }); + const config = loadConfig(app, NO_DOTENV); + const template = Template.fromStack( + new LlmStack(app, 'test-runtime', { config, env: { region: config.region } }), + ); + const fns = Object.values(template.findResources('AWS::Lambda::Function')) as any[]; + expect(fns).toHaveLength(7); + for (const fn of fns) { + expect(fn.Properties.Environment.Variables.CONTROL_PLANE_VERSION).toBe('1.30.0'); + } + }); +});