Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions contracts/agents-api/sandbox-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 11 additions & 0 deletions docs/getting-started/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions services/agents-api/internal/store/runtime_deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion services/agents-api/internal/store/runtime_nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
8 changes: 8 additions & 0 deletions services/agents-api/internal/store/sandbox_specification.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading