diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index c039345a9..062309863 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -230,6 +230,16 @@ Preserve identities, provider receipts, history and storage. This version does n automatically adopt the old database or delete its resources; removing an environment variable alone does not complete that migration. +A Web-managed Docker or microsandbox selection saved before specifications has +the empty migration default. Core loads it for draining only. Retained nodes +without a recorded digest or generation authenticate while the deployment is in +this state. GET omits `specification` and `specification_digest`. Fresh hosted +sandboxes are refused with the same error as during maintenance. Enrollment +tokens return 409 `sandbox_deployment_conflict`; node configuration reads and +enrollment are refused. +The ordinary maintenance, archive and PUT sequence records a specification and +retires those nodes. See the [operator upgrade notes](../../docs/getting-started/operations.md#data-and-upgrades). + Unit tests, database tests and provider inspection are separate from live execution acceptance. This contract does not assert that every resource profile, provider deployment or host-reboot recovery path has been qualified. diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index 90820e16e..100478438 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -156,6 +156,17 @@ Keep the original business data, credential key, Runtime history and private pro state. There is no automatic old-database conversion, force reset or resource deletion. The supported current path uses a database-managed deployment. +A Web-selected Docker or microsandbox deployment saved before deployment +specifications existed has an empty specification after migration. Current Core +loads it only to drain: nodes enrolled under the previous release reconnect with +their existing node service, and their sandboxes stay reachable for archive and +cleanup. Fresh sandbox creation, node configuration reads and enrollment are +refused. Back up as above, replace Core and Web, then use steps 1–4 with a Runtime +release from the new distribution. The replacement retires the old nodes. Stop each +retired node service, move its identity directory aside as a backup, and add the +host again with a new command from Web. Node IDs change; Session history and +persisted Files/Artifacts remain. E2B deployments from that period are not covered. + ## Exposure and network policy API and console bind to host loopback. With native Core, PostgreSQL publishes an diff --git a/services/agents-api/internal/store/runtime_deployment.go b/services/agents-api/internal/store/runtime_deployment.go index 6319181ab..a2bf62c5a 100644 --- a/services/agents-api/internal/store/runtime_deployment.go +++ b/services/agents-api/internal/store/runtime_deployment.go @@ -109,6 +109,9 @@ func checkRuntimeDeploymentAdmission(ctx context.Context, q *sqlc.Queries, insta if current.Maintenance { return fmt.Errorf("%w: sandbox creation is paused for provider maintenance", ErrEnvironmentUnavailable) } + if unspecifiedNodeDeployment(current) { + return fmt.Errorf("%w: sandbox creation requires a deployment specification", ErrEnvironmentUnavailable) + } if installation != "" { id, err := parseConnectionGeneration(installation) if err != nil || id != current.InstallationID { diff --git a/services/agents-api/internal/store/runtime_nodes.go b/services/agents-api/internal/store/runtime_nodes.go index 0a753b205..cd5050baf 100644 --- a/services/agents-api/internal/store/runtime_nodes.go +++ b/services/agents-api/internal/store/runtime_nodes.go @@ -105,7 +105,7 @@ func (s *Store) CreateRuntimeEnrollment(ctx context.Context, capacity RuntimeNod token := hex.EncodeToString(bytes[:]) var expires time.Time err := s.runtimeManagerTransaction(ctx, func(q *sqlc.Queries, d sqlc.RuntimeDeployment) error { - if d.Mode != "nodes" || d.Maintenance { + if d.Mode != "nodes" || d.Maintenance || unspecifiedNodeDeployment(d) { return ErrSandboxDeploymentConflict } if err := q.CreateRuntimeEnrollment(ctx, sqlc.CreateRuntimeEnrollmentParams{TokenSha256: runtimeTokenDigest(token), InstallationID: d.InstallationID, MaxActive: int32(capacity.MaxActive), MaxRetained: int32(capacity.MaxRetained)}); err != nil { @@ -190,6 +190,12 @@ func (s *Store) AuthenticateRuntimeNode(ctx context.Context, nodeID, credential if n.InstallationID != d.InstallationID || d.Mode != "nodes" { return RuntimeNodeIdentity{}, ErrRuntimeNodeCredential } + if unspecifiedNodeDeployment(d) { + if n.SpecificationDigest != "" || n.DeploymentGeneration != 0 { + return RuntimeNodeIdentity{}, ErrRuntimeSpecificationMismatch + } + return nodeIdentity(n, d.ProviderKind), nil + } spec, err := deploymentSpecification(d) if err != nil || n.SpecificationDigest != spec.Digest(d.ProviderKind) || n.DeploymentGeneration != d.Generation { return RuntimeNodeIdentity{}, ErrRuntimeSpecificationMismatch diff --git a/services/agents-api/internal/store/sandbox_deployment_setup.go b/services/agents-api/internal/store/sandbox_deployment_setup.go index 926fa27fe..573083bc6 100644 --- a/services/agents-api/internal/store/sandbox_deployment_setup.go +++ b/services/agents-api/internal/store/sandbox_deployment_setup.go @@ -49,7 +49,7 @@ func (s *Store) GetSandboxSetup(ctx context.Context) (SandboxSetup, error) { if err := json.Unmarshal(d.Specification, &result.Specification); err != nil { return SandboxSetup{}, err } - if d.ProviderKind != "" { + if d.ProviderKind != "" && !unspecifiedNodeDeployment(d) { if err := result.Specification.Validate(d.ProviderKind); err != nil { return SandboxSetup{}, err } diff --git a/services/agents-api/internal/store/sandbox_deployment_switch_test.go b/services/agents-api/internal/store/sandbox_deployment_switch_test.go index f0571e8a5..b02d4f670 100644 --- a/services/agents-api/internal/store/sandbox_deployment_switch_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_switch_test.go @@ -302,3 +302,65 @@ func TestSandboxSwitchPreservesReleasedAllocationAndItemHistory(t *testing.T) { } } } + +// Migration 000069 leaves a node-backed selection saved before specifications +// with an empty specification and its nodes with empty digests. The retained +// node reconnects to drain resources; fresh admission and node configuration +// stay closed until an administrator replaces the selection. +func TestUnspecifiedNodeDeploymentDrainsBeforeReplacement(t *testing.T) { + _, pool := newManagedTestStore(t) + cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{7}, 32)) + s := NewWithCredentialCipher(pool, cipher) + w := executionLease(t, s).Store() + id := uuid.NewString() + if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { + t.Fatal(err) + } + spec := SandboxDeploymentTestSpec("docker") + selection := SandboxDeploymentSetupRequest{DeploymentSpec: spec, Provider: "docker", CoreURL: "https://core.example"} + if _, err := w.InitializeSandboxDeployment(t.Context(), id, selection); err != nil { + t.Fatal(err) + } + token, _, err := s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 4}) + if err != nil { + t.Fatal(err) + } + node := RuntimeNodeEnrollment{DeploymentGeneration: 1, SpecificationDigest: spec.Digest("docker"), NodeID: uuid.NewString(), Name: "Legacy", Provider: "docker", Credential: strings.Repeat("l", 64), BackendFingerprint: strings.Repeat("b", 64)} + if _, err := s.EnrollRuntimeNode(t.Context(), token, node); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "UPDATE runtime_deployment SET specification='{}'"); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "UPDATE runtime_nodes SET specification_digest='', deployment_generation=0"); err != nil { + t.Fatal(err) + } + + if setup, err := s.GetSandboxSetup(t.Context()); err != nil || setup.Provider != "docker" { + t.Fatal("unspecified deployment could not load for draining", err) + } + identity, err := s.AuthenticateRuntimeNode(t.Context(), node.NodeID, node.Credential) + if err != nil || identity.SpecificationDigest != "" || identity.DeploymentGeneration != 0 { + t.Fatal("retained unspecified node could not reconnect", identity, err) + } + if _, err := s.RuntimeNodeConfiguration(t.Context(), node.NodeID, node.Credential); !errors.Is(err, ErrRuntimeSpecificationMismatch) { + t.Fatal("node configuration served without a specification", err) + } + if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString(), "")); !errors.Is(err, ErrEnvironmentUnavailable) { + t.Fatal("unspecified deployment admitted a fresh sandbox", err) + } + if _, _, err := s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 4}); !errors.Is(err, ErrSandboxDeploymentConflict) { + t.Fatal("unspecified deployment issued an enrollment token", err) + } + + if _, err := w.SetSandboxMaintenance(t.Context(), id, SandboxMaintenanceRequest{Maintenance: true, ExpectedGeneration: 1}); err != nil { + t.Fatal(err) + } + view, err := w.UpdateSandboxDeployment(t.Context(), id, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: selection, ExpectedGeneration: 1}) + if err != nil || view.Generation != 2 || view.Specification == nil { + t.Fatal("replacement did not record the specification", view, err) + } + if _, err := s.AuthenticateRuntimeNode(t.Context(), node.NodeID, node.Credential); !errors.Is(err, ErrRuntimeNodeCredential) { + t.Fatal("unspecified node survived replacement", err) + } +} diff --git a/services/agents-api/internal/store/sandbox_specification.go b/services/agents-api/internal/store/sandbox_specification.go index 8ed755ed3..5f5c19b72 100644 --- a/services/agents-api/internal/store/sandbox_specification.go +++ b/services/agents-api/internal/store/sandbox_specification.go @@ -41,6 +41,14 @@ func SandboxSetupForSelection(installationID string, input SandboxDeploymentSetu return result, nil } +// unspecifiedNodeDeployment identifies a node-backed selection saved before +// deployments carried a specification; migration left the empty default. Its +// retained nodes may reconnect to drain resources, but it cannot create +// sandboxes or enroll nodes until an administrator replaces the selection. +func unspecifiedNodeDeployment(d sqlc.RuntimeDeployment) bool { + return d.WebManaged && d.Mode == "nodes" && (d.ProviderKind == "docker" || d.ProviderKind == "microsandbox") && string(d.Specification) == "{}" +} + func deploymentSpecification(d sqlc.RuntimeDeployment) (sandbox.DeploymentSpec, error) { var spec sandbox.DeploymentSpec if json.Unmarshal(d.Specification, &spec) != nil || spec.Validate(d.ProviderKind) != nil {