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
6 changes: 6 additions & 0 deletions services/cloud-agent-next/docs/control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,12 @@ Sandbox DO ↔ wrapper (WebSocket frames): `hello`, `welcome`, `shutdown`, `hear
worktree-deletion requests (`worktree.prepareDeletion`, `worktree.delete`; both answer with
`worktree.result`). Terminal bytes use the existing wrapper-to-Session-DO terminal socket.

### Startup mode rollout

- An image change requires capture to key a snapshot with the image that the allocation started
with. Capture uses that recorded image, including after a Worker deploy. A legacy running
allocation with no recorded image skips capture until the next allocation.

### Preview

Preview reaches a dev server in a live interactive (`workspace_*`) sandbox through a dedicated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ type ContainersRecord = {
lastSnapshot: { id: string; sourceAllocation: string } | null;
/** How this allocation's container was started; absent on a record that predates it. */
startSource?: ContainersStartSource;
/** Image selected for this allocation; absent on records written before image pinning. */
startImage?: string;
instance?: ContainerInstanceSize;
billingConfigured?: true;
wrapperAttempt?: WrapperAttempt;
Expand All @@ -118,6 +120,7 @@ type ContainersRecord = {
};

type ResolvedStart = {
image: string;
source: ContainersStartSource;
options: ContainerStartupOptions;
snapshotId: string | null;
Expand Down Expand Up @@ -394,6 +397,7 @@ export class SandboxContainers extends DurableObject<Env> {
stopOpId: null,
wrapperAttempt: 'exec_pending',
startSource: start.source,
startImage: start.image,
};
if (input.egressAllowlist === undefined) {
delete launching.egressPolicy;
Expand Down Expand Up @@ -428,10 +432,13 @@ export class SandboxContainers extends DurableObject<Env> {
input: ContainersLaunchInput,
allowRepository: boolean
): Promise<ResolvedStart> {
const image = this.containerImage();
const imageStart: ResolvedStart = {
image,
source: 'image',
options: this.startOptions(
input.instance,
image,
this.nativeLaunchEnv(
input.env,
input.containment === true || input.egressAllowlist !== undefined
Expand All @@ -449,7 +456,7 @@ export class SandboxContainers extends DurableObject<Env> {
this.logSnapshotLookup('image', 'no_repo_key');
return imageStart;
}
const indexKey = await repoSnapshotIndexKey(input.repoKey, this.containerImage());
const indexKey = await repoSnapshotIndexKey(input.repoKey, image);
if (input.skipRepository === true) {
this.logSnapshotLookup('image', 'skipped');
return imageStart;
Expand Down Expand Up @@ -477,9 +484,11 @@ export class SandboxContainers extends DurableObject<Env> {
size: entry.size,
});
return {
image,
source: 'repository',
options: this.startOptions(
input.instance,
image,
this.nativeLaunchEnv(
input.env,
input.containment === true || input.egressAllowlist !== undefined
Expand Down Expand Up @@ -1066,16 +1075,15 @@ export class SandboxContainers extends DurableObject<Env> {

private startOptions(
instance: ContainerInstanceSize,
image: string,
env: Record<string, string>,
egressAllowlist?: string[],
snapshotId?: string
): ContainerStartupOptions {
// Native issuance: the main process is the supervisor, not the image default
// `sleep infinity`. The supervisor script is already in the image.
return {
...(snapshotId === undefined
? { image: this.containerImage() }
: { containerSnapshot: { id: snapshotId } }),
...(snapshotId === undefined ? { image } : { containerSnapshot: { id: snapshotId } }),
instance,
enableInternet: egressAllowlist === undefined,
entrypoint: ['/bin/sh', CONTROL_SUPERVISOR_PATH],
Expand Down Expand Up @@ -1249,7 +1257,7 @@ export class SandboxContainers extends DurableObject<Env> {

/**
* Snapshot the running container's root filesystem as the repository snapshot
* for `repoKey` and this image. It does not take the operation queue: a capture
* for `repoKey` and its recorded start image. It does not take the operation queue: a capture
* of unknown duration must not delay a stop or a launch. The snapshot is
* published only while `allocationRef` is still the running allocation, and
* any failure is a `false` the caller may ignore; a capture never fails a start.
Expand All @@ -1262,7 +1270,13 @@ export class SandboxContainers extends DurableObject<Env> {
const index = this.repoSnapshotIndex();
const container = this.ctx.container;
if (index === null || !container || container.running !== true) return false;
if (!(await this.isRunningAllocation(allocationRef))) return false;
const record = await this.readRecord();
if (
record.state !== 'running' ||
record.allocationRef !== allocationRef ||
record.startImage === undefined
)
return false;
const startedAt = Date.now();
try {
const snapshot = await withTimeout(
Expand All @@ -1276,7 +1290,7 @@ export class SandboxContainers extends DurableObject<Env> {
logRepositoryCapture('abandoned', snapshotMs, { size });
return false;
}
const key = await repoSnapshotIndexKey(repoKey, this.containerImage());
const key = await repoSnapshotIndexKey(repoKey, record.startImage);
// The capture start bounds the contents' age from above, so an age logged on
// use never understates how stale the repository is.
const stored = await index.store(key, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type StoredRecord = {
stopOpId?: string | null;
lastSnapshot?: { id: string; sourceAllocation: string } | null;
startSource?: 'image' | 'repository';
startImage?: string;
instance?: ContainerInstanceSize;
billingConfigured?: true;
wrapperAttempt?: string;
Expand Down Expand Up @@ -2390,6 +2391,29 @@ describe('SandboxContainers repository snapshots', () => {
});
});

it('captures under the start image after the configured image changes and the DO reconstructs', async () => {
const { kv, data } = fakeKv();
const initial = setup({ env: { REPO_SNAPSHOTS: kv } });
await launchFromRepo(initial.instance, REF_A);
const restored = setup({
record: initial.readRecord(),
running: true,
env: { REPO_SNAPSHOTS: kv },
});
const newImage = 'registry.example/kilo/app:next';
restored.container.images.app = newImage;

await expect(restored.instance.captureRepository(REF_A, REPO_KEY, 'abc123')).resolves.toBe(
true
);

expect(data.has(await indexKeyFor(REPO_KEY, newImage))).toBe(false);
expect(JSON.parse(data.get(await indexKeyFor()) ?? 'null')).toMatchObject({
snapshotId: 'snap-1',
commit: 'abc123',
});
});

it('starts from the image when the index has no entry, and a different image has none', async () => {
const other = await indexKeyFor(REPO_KEY, 'registry.example/kilo/app:previous');
const { kv } = fakeKv({ [other]: { snapshotId: 'snap-old-image' } });
Expand Down Expand Up @@ -2938,6 +2962,18 @@ describe('SandboxContainers repository capture', () => {
expect(container.snapshotCalls).toBe(0);
});

it('skips capture for a legacy running allocation whose start image is unknown', async () => {
const store = fakeKv();
const { instance, container } = setup({
running: true,
env: { REPO_SNAPSHOTS: store.kv },
record: { ...idleRecord, state: 'running', allocationRef: REF_A },
});
await expect(instance.captureRepository(REF_A, REPO_KEY)).resolves.toBe(false);
expect(container.snapshotCalls).toBe(0);
expect(store.data.size).toBe(0);
});

it('reports a failed snapshot or index write as false and publishes nothing', async () => {
const failedSnapshot = await runningWithIndex();
failedSnapshot.container.snapshotBehavior = { kind: 'reject' };
Expand Down
Loading