Sandbox containers: per-channel name suffix so installed builds coexist
Append a build-channel tag (-beta / -rc / -local; none for stable) to every container name so release + beta + local-dev builds running against the shared on-disk container store don't collide on per-container rootfs clones. Routes the NUCLEIC_CHANNEL define into NucleicCore (ContainerManager.channelSuffix) and scopes launch-time disk GC to the current channel (ownsContainer) so one build never reaps another's clones. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2f9252e430
commit
f42131a516
+9
-3
@@ -7,8 +7,9 @@ import PackageDescription
|
||||
// • the app executable's PRODUCT name — which becomes the on-disk binary and
|
||||
// therefore the OS process name (Activity Monitor / `ps`):
|
||||
// dev -> nucleic-local beta -> nucleic-beta rc -> nucleic-rc stable -> nucleic
|
||||
// • a compile-time define the app reads (`BuildChannel`) to pick its build identity
|
||||
// and the warning banner (red local / blue beta / gold release-candidate / none stable).
|
||||
// • a compile-time define (`NUCLEIC_STABLE/RC/BETA/DEV`) read by NucleicApp for its build
|
||||
// identity + warning banner (red local / blue beta / gold release-candidate / none stable),
|
||||
// and by NucleicCore's `ContainerManager` to suffix non-release container names.
|
||||
// Branch mapping (see BUILD.md): dev builds from `dev`, beta from `staging`,
|
||||
// rc from `rc`, stable from `main`.
|
||||
let nucleicChannel = (Context.environment["NUCLEIC_CHANNEL"] ?? "dev").lowercased()
|
||||
@@ -68,7 +69,12 @@ let package = Package(
|
||||
// the `User`/`Platform` types used directly by `ContainerEngine`.
|
||||
.product(name: "Containerization", package: "containerization"),
|
||||
.product(name: "ContainerizationOCI", package: "containerization"),
|
||||
]),
|
||||
],
|
||||
// The build channel reaches the container-naming code here: `ContainerManager` appends a
|
||||
// per-channel suffix (e.g. `-beta`, `-local`) to non-release container names so a beta /
|
||||
// local-dev build's containers don't collide with a release's on the shared on-disk
|
||||
// container store (ContainerEngine.defaultStorageRoot). Same define NucleicApp's banner uses.
|
||||
swiftSettings: [.define(channelDefine)]),
|
||||
.executableTarget(
|
||||
name: "NucleicApp",
|
||||
dependencies: ["NucleicCore", .product(name: "SwiftTerm", package: "SwiftTerm")],
|
||||
|
||||
@@ -119,12 +119,17 @@ extension ContainerEngine {
|
||||
/// `keepNames` (dead per-session containers, plus the shared control containers — which are
|
||||
/// always rebuilt fresh), then prune superseded image caches. There are no live VMs to reap;
|
||||
/// the registry is empty in a fresh process.
|
||||
///
|
||||
/// GC is scoped to THIS build channel: several installed builds (release + beta + local dev)
|
||||
/// share this store, each owning only its `ContainerManager.channelSuffix`-tagged clones, so a
|
||||
/// beta launch must not reap a release's per-session clones (and vice-versa).
|
||||
public func reconcileDisk(keepNames: Set<String>) async {
|
||||
if let clones = try? FileManager.default.contentsOfDirectory(
|
||||
at: instancesDir, includingPropertiesForKeys: nil)
|
||||
{
|
||||
for clone in clones where clone.pathExtension == "ext4" {
|
||||
let name = clone.deletingPathExtension().lastPathComponent
|
||||
guard ContainerManager.ownsContainer(named: name) else { continue } // not our channel
|
||||
if !keepNames.contains(name) {
|
||||
try? FileManager.default.removeItem(at: clone)
|
||||
}
|
||||
|
||||
@@ -21,11 +21,45 @@ public actor ContainerManager {
|
||||
self.engine = engine
|
||||
}
|
||||
|
||||
/// Per-channel suffix appended to every container name on non-release builds, so several
|
||||
/// installed Nucleic builds (release + beta + local dev, etc.) running against the **shared**
|
||||
/// on-disk container store (`ContainerEngine.defaultStorageRoot`) don't collide on their
|
||||
/// per-container rootfs clones. Empty for the stable release; `-beta` / `-rc` / `-local` for the
|
||||
/// non-release channels. Baked in at build time from the `NUCLEIC_CHANNEL` define (mirrors
|
||||
/// NucleicApp's `BuildChannel`); NucleicCore is compiled with that define — see Package.swift.
|
||||
public nonisolated static let channelSuffix: String = {
|
||||
#if NUCLEIC_STABLE
|
||||
return ""
|
||||
#elseif NUCLEIC_RC
|
||||
return "-rc"
|
||||
#elseif NUCLEIC_BETA
|
||||
return "-beta"
|
||||
#else
|
||||
return "-local"
|
||||
#endif
|
||||
}()
|
||||
|
||||
/// Every non-empty `channelSuffix` a build can carry. Used to tell a stable build's untagged
|
||||
/// containers apart from other channels' tagged ones during disk GC. Keep in lockstep with the
|
||||
/// `channelSuffix` cases above.
|
||||
public nonisolated static let nonReleaseSuffixes = ["-local", "-beta", "-rc"]
|
||||
|
||||
/// Whether a container/clone name belongs to THIS build channel's namespace. The on-disk
|
||||
/// container store is shared across installed builds (`ContainerEngine.defaultStorageRoot`), so
|
||||
/// the launch-time disk GC uses this to reap only its own channel's clones and never another
|
||||
/// build's. A non-release build owns names ending in its `channelSuffix`; the stable release owns
|
||||
/// names carrying none of the non-release tags.
|
||||
public nonisolated static func ownsContainer(named name: String) -> Bool {
|
||||
channelSuffix.isEmpty
|
||||
? !nonReleaseSuffixes.contains(where: name.hasSuffix)
|
||||
: name.hasSuffix(channelSuffix)
|
||||
}
|
||||
|
||||
/// Stable container name for a session. Lowercased because `UUID().uuidString` (hence
|
||||
/// `SessionID.short`) is uppercase, while container names are conventionally lowercase
|
||||
/// and our reconcile parser matches lowercase hex.
|
||||
/// and our reconcile parser matches lowercase hex. Carries the per-channel `channelSuffix`.
|
||||
public nonisolated static func containerName(for session: SessionID) -> String {
|
||||
"nucleic-\(session.short.lowercased())"
|
||||
"nucleic-\(session.short.lowercased())\(channelSuffix)"
|
||||
}
|
||||
|
||||
/// The single **primary** container shared by all Nucleic Control projects that haven't
|
||||
@@ -33,14 +67,15 @@ public actor ContainerManager {
|
||||
/// the reconcile/list parser never mistakes it for a per-session container (and so a
|
||||
/// per-session `teardown` — which computes a `nucleic-<short>` name — never removes it).
|
||||
/// Its busy/idle lifecycle is ref-counted by name across every control session that uses it.
|
||||
/// Also the home of Claude sessions when backend-splitting is on.
|
||||
public static let sharedControlContainerName = "nucleic-control"
|
||||
/// Also the home of Claude sessions when backend-splitting is on. Carries `channelSuffix`.
|
||||
public static let sharedControlContainerName = "nucleic-control" + channelSuffix
|
||||
|
||||
/// The separate shared control container for GPT/Codex sessions, used only when
|
||||
/// `ContainerServiceSettings.splitControlContainersByBackend` is on — keeping competitive
|
||||
/// Claude and GPT agents out of one sandbox where they kill each other's processes. Same
|
||||
/// non-`nucleic-<8 hex>` shape, so it's likewise invisible to the per-session list parser.
|
||||
public static let codexControlContainerName = "nucleic-control-codex"
|
||||
/// The `channelSuffix` lands at the very end (e.g. `nucleic-control-codex-beta`).
|
||||
public static let codexControlContainerName = "nucleic-control-codex" + channelSuffix
|
||||
|
||||
/// Every shared control container name that can exist. Lifecycle sweeps (reconcile, teardown,
|
||||
/// status, recreate) iterate this so they stay correct no matter how the split setting is
|
||||
|
||||
@@ -42,25 +42,26 @@ struct ContainerSandboxTests {
|
||||
|
||||
@Test func containerNameDerivesFromSessionShortID() {
|
||||
let id = SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb")
|
||||
#expect(ContainerManager.containerName(for: id) == "nucleic-abcdef12")
|
||||
#expect(ContainerManager.containerName(for: id) == "nucleic-abcdef12\(ContainerManager.channelSuffix)")
|
||||
}
|
||||
|
||||
/// `UUID().uuidString` is uppercase; the container name must be lowercased so it stays a stable,
|
||||
/// collision-free key for the engine's registry and on-disk rootfs clones.
|
||||
@Test func containerNameLowercasesUppercaseUUID() {
|
||||
let id = SessionID(rawValue: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")
|
||||
#expect(ContainerManager.containerName(for: id) == "nucleic-e621e1f8")
|
||||
#expect(ContainerManager.containerName(for: id) == "nucleic-e621e1f8\(ContainerManager.channelSuffix)")
|
||||
}
|
||||
|
||||
/// The shared control container names must be distinct from each other and from any per-session
|
||||
/// container name — otherwise a per-session `teardown` (which derives a `nucleic-<short>` name)
|
||||
/// or the disk-GC reconcile could clobber the shared container out from under live sessions.
|
||||
@Test func sharedControlContainerNamesAreDistinct() {
|
||||
#expect(ContainerManager.sharedControlContainerName == "nucleic-control")
|
||||
#expect(ContainerManager.codexControlContainerName == "nucleic-control-codex")
|
||||
let s = ContainerManager.channelSuffix
|
||||
#expect(ContainerManager.sharedControlContainerName == "nucleic-control\(s)")
|
||||
#expect(ContainerManager.codexControlContainerName == "nucleic-control-codex\(s)")
|
||||
#expect(ContainerManager.codexControlContainerName != ContainerManager.sharedControlContainerName)
|
||||
#expect(ContainerManager.allSharedControlContainerNames ==
|
||||
["nucleic-control", "nucleic-control-codex"])
|
||||
["nucleic-control\(s)", "nucleic-control-codex\(s)"])
|
||||
|
||||
// A per-session name never equals a shared name.
|
||||
let perSession = ContainerManager.containerName(
|
||||
@@ -71,14 +72,48 @@ struct ContainerSandboxTests {
|
||||
/// With splitting off, every backend shares `nucleic-control`; with it on, only the GPT/Codex
|
||||
/// family moves to `nucleic-control-codex` while Claude stays on `nucleic-control`.
|
||||
@Test func sharedContainerNameSplitsCodexFromClaude() {
|
||||
let s = ContainerManager.channelSuffix
|
||||
// Splitting off → one shared container regardless of backend.
|
||||
for backend in [BackendID.claudeCode, .codex, .codexExec] {
|
||||
#expect(ContainerManager.sharedContainerName(for: backend, split: false) == "nucleic-control")
|
||||
#expect(ContainerManager.sharedContainerName(for: backend, split: false) == "nucleic-control\(s)")
|
||||
}
|
||||
// Splitting on → Codex family diverges; Claude stays put.
|
||||
#expect(ContainerManager.sharedContainerName(for: .claudeCode, split: true) == "nucleic-control")
|
||||
#expect(ContainerManager.sharedContainerName(for: .codex, split: true) == "nucleic-control-codex")
|
||||
#expect(ContainerManager.sharedContainerName(for: .codexExec, split: true) == "nucleic-control-codex")
|
||||
#expect(ContainerManager.sharedContainerName(for: .claudeCode, split: true) == "nucleic-control\(s)")
|
||||
#expect(ContainerManager.sharedContainerName(for: .codex, split: true) == "nucleic-control-codex\(s)")
|
||||
#expect(ContainerManager.sharedContainerName(for: .codexExec, split: true) == "nucleic-control-codex\(s)")
|
||||
}
|
||||
|
||||
/// The per-channel suffix is one of the known channel tags — empty for the stable release, a
|
||||
/// `-`-prefixed tag otherwise — and it lands at the very end of every container name so several
|
||||
/// builds installed side-by-side don't collide on the shared on-disk container store. Channel is
|
||||
/// fixed at build time, so this is channel-agnostic (the default `dev` test build resolves
|
||||
/// `-local`; a `NUCLEIC_CHANNEL=stable` build resolves `""`).
|
||||
@Test func channelSuffixTagsNonReleaseNames() {
|
||||
let s = ContainerManager.channelSuffix
|
||||
#expect(["", "-beta", "-rc", "-local"].contains(s))
|
||||
// Only the stable release goes untagged; every other channel carries a tag.
|
||||
#expect(s.isEmpty || s.hasPrefix("-"))
|
||||
let perSession = ContainerManager.containerName(
|
||||
for: SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb"))
|
||||
#expect(perSession.hasSuffix(s))
|
||||
#expect(ContainerManager.sharedControlContainerName.hasSuffix(s))
|
||||
}
|
||||
|
||||
/// Launch-time disk GC must reap only this channel's clones from the shared on-disk store, so
|
||||
/// `ownsContainer` claims names carrying this build's suffix and disowns every other channel's.
|
||||
@Test func ownsOnlyThisChannelsContainers() {
|
||||
// Names this build mints are always ours.
|
||||
#expect(ContainerManager.ownsContainer(named: ContainerManager.sharedControlContainerName))
|
||||
#expect(ContainerManager.ownsContainer(named: ContainerManager.codexControlContainerName))
|
||||
#expect(ContainerManager.ownsContainer(named: ContainerManager.containerName(
|
||||
for: SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb"))))
|
||||
|
||||
// A clone tagged for any *other* channel is never ours.
|
||||
let foreign = (["", "-local", "-beta", "-rc"]).filter { $0 != ContainerManager.channelSuffix }
|
||||
for fs in foreign {
|
||||
#expect(!ContainerManager.ownsContainer(named: "nucleic-control\(fs)"))
|
||||
#expect(!ContainerManager.ownsContainer(named: "nucleic-abcdef12\(fs)"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - sandbox config persistence
|
||||
|
||||
Reference in New Issue
Block a user