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
3 changes: 3 additions & 0 deletions Changes/Current.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- Added **Free Up Space…** with compaction selected by default, explicit opt-in resource/cache deletion, and access to automatic compaction settings.
- Fixed Apple Container recreation accumulating image entrypoint arguments; pinned and verified rollback, preserved originals before update pulls without weakening child-manifest digest pins, and added visible recovery for missing containers after failed replacement/restoration.
- Protected saved recovery and local metadata during failed runtime reads, corrupt-data recovery, and orphan cleanup; kept stopped originals stopped during restoration, kept open resource details current, and reduced repeated cleanup scans and scrolling work.
17 changes: 17 additions & 0 deletions Documentation/App/System-Settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ references, and default/system networks are protected. Volume and stopped-contai
deletion are irreversible. The combined "Reclaim all" action is replaced with
individually reviewed categories so their different risks cannot be conflated.

**Free Up Space…** in Storage analysis and the low-disk-space alert opens this
review directly. Only running-container/builder compaction is preselected. Builder
cache, unreferenced image, unused volume, and unused network removal require explicit
selection; stopped containers and arbitrary guest temporary files are not included.
The same sheet exposes the existing opt-in automatic compaction policy. Enabling
automation does not enable any automatic deletion. If an earlier selected action
changes runtime inventory, remaining exact previews are rejected until refreshed.

The combined preview shares one runtime inventory and measures host allocation only
when a selected category needs it. Execution still refreshes inventory before acting.

Builder cache reset stops/deletes only the builder VM, interrupts external builds,
and discards BuildKit cache. The next build recreates it. Running-builder compaction
is also available without cache deletion. App builds/pulls disable cleanup. Activity
Expand Down Expand Up @@ -164,6 +175,8 @@ delete settings, templates, container records, or retained history.
If database reads or saves fail, Contained rolls back pending changes and pauses
new writes until explicit recovery succeeds. Waiting alone never resumes writes.
An alert links to General settings and an explicit retry.
Unreadable personalization or health-check records also pause writes instead of
crashing startup; previously loaded values remain intact when a reload fails.
Successful explicit retry reloads saved preferences, registry retry state, and cleanup/
image-update scheduling state, history counts, and loaded recent activity without
saving fallback defaults. It then reruns runtime detection using the restored CLI paths.
Expand All @@ -177,6 +190,10 @@ downgrade decision is explicitly accepted; ordinary retry does not grant accepta
Configuration export reads saved personalization and health checks directly without
enabling writes or normalization. Unreadable records abort export instead of producing
a successful-looking backup with missing data.
**Clean Up Orphans** refreshes container and image inventories first and refuses to
remove local metadata if any connected runtime cannot supply a complete inventory.
Saved recreation/migration identities and state belonging to unqueried runtimes are
protected, including personalization, health checks, and retained history.
Forced runtime detection queues behind an in-flight
startup check rather than reusing its pre-recovery configuration.
Diagnostics show only an error domain/code, not database contents or private paths.
Expand Down
38 changes: 36 additions & 2 deletions Documentation/Features/Containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,46 @@ Rebuild is always available from a container's context menu. When the app knows
that the container's immutable image identity differs from the current tag—or a
newer registry digest is available—the action is relabeled **Update Container**
and an orange update button appears persistently at the far right of the card
footer. Updating pulls first only
when needed, then recreates through the same rollback path as Edit → Save. A
footer. Updating preserves the original image and saves its recovery recipe before
pulling when needed, then recreates through the same rollback path as Edit → Save. A
failed streamed pull stops the update instead of being reported as successful.
Rebuild and Update preserve whether the container was running or stopped, while
ordinary Start, Stop, and Restart remain non-destructive lifecycle operations.

Apple Container edits preserve the inspected executable explicitly, so recreating
does not repeatedly prepend the image entrypoint's arguments. Intentional repeated
arguments are preserved, not guessed away. Before teardown, Contained must save the
original recipe; its image is pinned to the inspected digest when available. Apple
Container resolves that digest through a verified `contained-recovery-<digest>` image
tag when needed. Only an exact image descriptor is aliased; a child-manifest digest
is never replaced with its multi-platform parent index. This retains the local
original independently of a moving tag; it can be removed through unused-image
cleanup once no container references it. Rollback
is verified for image identity and startup before reporting that the original returned.

If replacement and rollback fail, **Review Recovery…** remains on the Containers
page even when the runtime container is missing. Review the original image, recovery
digest, and requested command before **Restore Original**. Recovery never overwrites or
deletes an existing container. If a partial runtime object already occupies the name,
inspect it with the normal container controls; **Keep Existing Container** explicitly
closes the saved recovery after confirmation. Inventory refreshes, another Edit/Rebuild,
and duplicate-record repair cannot replace the saved original with a partial object's
configuration. Resolve the pending recovery before recreating that container again.
If teardown cannot delete a previously running original, Contained restarts and verifies
it; a failed restart keeps the recovery open. Restoration preserves the original running
or stopped state: stopped originals use the runtime's create-only command and are never
started, including automatic rollback. Recovery closes only after image and state
verification succeed. Runtime stderr is shown
for a failed attempt but is not stored in the recovery record or Activity history.
Destructive storage cleanup is blocked for that runtime while recreation recovery
remains pending, because a missing runtime object cannot protect its saved image,
volumes, and networks from native pruning. Compaction remains available.

Open container details follow current inventory state, including changes made by
their Start/Stop controls. If a runtime cannot refresh, its last known containers and
metrics remain visible with the refresh error; an outage is not treated as confirmed
resource removal.

The app serializes refreshes around lifecycle actions so a user action and the
background polling tick do not fight over inventory and stats streams. While the
Containers screen is hidden or Contained is inactive, a five-minute, per-runtime
Expand Down
5 changes: 5 additions & 0 deletions Packages/ContainedCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ not guaranteed reclaim; results contain before/after measurements and safe parti
failure codes. Apple internals are never deleted directly. Registry credentials stay
inside the adapter; retry policy is a pure `Core.Registry.UpdateRetryPolicy` value.

Use `core.cleanupPlans([.compactRunningContainers, .unusedVolumes],
runtimeKind: .appleContainer)` for a combined manual preview. It shares one runtime
inventory and measures host allocation once only when nonempty candidates can use
that measurement. Execution still validates each plan against fresh inventory.

## Fixtures

Core fixtures are available only by depending on the separate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
public extension Core.Orchestrator {
func containerInventory(all: Bool = true) async throws -> Core.Runtime.InventoryResult<Core.Container.Snapshot> {
var snapshots: [Core.Container.Snapshot] = []
var successes = 0
var successes: Set<Core.Runtime.Kind> = []
var firstError: Swift.Error?
var failures: [Core.Runtime.InventoryFailure] = []
for kind in runtimes.keys.sorted(by: { $0.rawValue < $1.rawValue }) {
Expand All @@ -13,14 +13,14 @@ public extension Core.Orchestrator {
capability: .containers,
as: (any RuntimeContainerClient).self)
snapshots += try await runtime.listContainers(all: all).map { $0.scoped(to: kind) }
successes += 1
successes.insert(kind)
} catch {
firstError = firstError ?? error
failures.append(.init(resource: "containers", kind: kind, message: String(describing: error)))
}
}
if successes == 0, let firstError { throw firstError }
return Core.Runtime.InventoryResult(items: snapshots, failures: failures)
if successes.isEmpty, let firstError { throw firstError }
return Core.Runtime.InventoryResult(items: snapshots, failures: failures, successfulRuntimeKinds: successes)
}

func listRuntimeContainers(all: Bool = true) async throws -> [Core.Container.Snapshot] {
Expand All @@ -29,7 +29,7 @@ public extension Core.Orchestrator {

func networkInventory() async throws -> Core.Runtime.InventoryResult<Core.Network.Resource> {
var networks: [Core.Network.Resource] = []
var successes = 0
var successes: Set<Core.Runtime.Kind> = []
var firstError: Swift.Error?
var failures: [Core.Runtime.InventoryFailure] = []
for kind in runtimes.keys.sorted(by: { $0.rawValue < $1.rawValue }) {
Expand All @@ -39,14 +39,14 @@ public extension Core.Orchestrator {
capability: .networks,
as: (any RuntimeNetworkClient).self)
networks += try await runtime.networks().map { $0.scoped(to: kind) }
successes += 1
successes.insert(kind)
} catch {
firstError = firstError ?? error
failures.append(.init(resource: "networks", kind: kind, message: String(describing: error)))
}
}
if successes == 0, let firstError { throw firstError }
return Core.Runtime.InventoryResult(items: networks, failures: failures)
if successes.isEmpty, let firstError { throw firstError }
return Core.Runtime.InventoryResult(items: networks, failures: failures, successfulRuntimeKinds: successes)
}

func runtimeNetworks() async throws -> [Core.Network.Resource] {
Expand All @@ -55,7 +55,7 @@ public extension Core.Orchestrator {

func volumeInventory() async throws -> Core.Runtime.InventoryResult<Core.Volume.Resource> {
var volumes: [Core.Volume.Resource] = []
var successes = 0
var successes: Set<Core.Runtime.Kind> = []
var firstError: Swift.Error?
var failures: [Core.Runtime.InventoryFailure] = []
for kind in runtimes.keys.sorted(by: { $0.rawValue < $1.rawValue }) {
Expand All @@ -65,14 +65,14 @@ public extension Core.Orchestrator {
capability: .volumes,
as: (any RuntimeVolumeClient).self)
volumes += try await runtime.volumes().map { $0.scoped(to: kind) }
successes += 1
successes.insert(kind)
} catch {
firstError = firstError ?? error
failures.append(.init(resource: "volumes", kind: kind, message: String(describing: error)))
}
}
if successes == 0, let firstError { throw firstError }
return Core.Runtime.InventoryResult(items: volumes, failures: failures)
if successes.isEmpty, let firstError { throw firstError }
return Core.Runtime.InventoryResult(items: volumes, failures: failures, successfulRuntimeKinds: successes)
}

func runtimeVolumes() async throws -> [Core.Volume.Resource] {
Expand All @@ -81,7 +81,7 @@ public extension Core.Orchestrator {

func imageInventory() async throws -> Core.Runtime.InventoryResult<Core.Image.Resource> {
var images: [Core.Image.Resource] = []
var successes = 0
var successes: Set<Core.Runtime.Kind> = []
var firstError: Swift.Error?
var failures: [Core.Runtime.InventoryFailure] = []
for kind in runtimes.keys.sorted(by: { $0.rawValue < $1.rawValue }) {
Expand All @@ -91,14 +91,14 @@ public extension Core.Orchestrator {
capability: .images,
as: (any RuntimeImageClient).self)
images += try await runtime.images().map { $0.scoped(to: kind) }
successes += 1
successes.insert(kind)
} catch {
firstError = firstError ?? error
failures.append(.init(resource: "images", kind: kind, message: String(describing: error)))
}
}
if successes == 0, let firstError { throw firstError }
return Core.Runtime.InventoryResult(items: images, failures: failures)
if successes.isEmpty, let firstError { throw firstError }
return Core.Runtime.InventoryResult(items: images, failures: failures, successfulRuntimeKinds: successes)
}

func runtimeImages() async throws -> [Core.Image.Resource] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,10 @@ public extension Core.Orchestrator {
try await requireRuntime(plan.runtimeKind, capability: .storageManagement,
as: (any RuntimeStorageClient).self).executeCleanup(plan)
}

/// Build a manual preview from one runtime inventory and, when needed, one allocation scan.
func cleanupPlans(_ actions: [Core.System.CleanupAction], runtimeKind: Core.Runtime.Kind) async throws -> [Core.System.CleanupPlan] {
try await requireRuntime(runtimeKind, capability: .storageManagement,
as: (any RuntimeStorageClient).self).cleanupPlans(actions)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -324,22 +324,22 @@ public extension Core {
as: (any RuntimeSystemStatusClient).self).systemStatus()
}

private func previewCreateCommand(for request: Core.Container.CreateRequest) throws -> Core.Command.Preview {
private func previewCreateCommand(for request: Core.Container.CreateRequest, start: Bool) throws -> Core.Command.Preview {
try requireRuntime(request.runtimeKind,
capability: .containers,
as: (any RuntimeContainerClient).self).previewCreateCommand(for: request)
as: (any RuntimeContainerClient).self).previewCreateCommand(for: request, start: start)
}

public func previewCreateCommand(for document: Core.Schema.Document) throws -> Core.Command.Preview {
public func previewCreateCommand(for document: Core.Schema.Document, start: Bool = true) throws -> Core.Command.Preview {
let definition = schemaDefinition(for: document.operation, runtimeKind: document.runtimeKind)
let request = try document.validatedRequest(definition: definition)
return try previewCreateCommand(for: request)
return try previewCreateCommand(for: request, start: start)
}

@discardableResult private func createContainer(_ request: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult {
try await requireRuntime(request.runtimeKind,
capability: .containers,
as: (any RuntimeContainerClient).self).createContainer(request)
as: (any RuntimeContainerClient).self).createContainer(request, start: true)
}

@discardableResult public func createContainer(_ document: Core.Schema.Document) async throws -> Core.Container.CreateResult {
Expand All @@ -348,6 +348,26 @@ public extension Core {
return try await createContainer(request)
}

/// Preserve recovery resources before pulling a replacement or tearing down the original.
public func prepareContainerRecovery(_ document: Core.Schema.Document) async throws {
let definition = schemaDefinition(for: document.operation, runtimeKind: document.runtimeKind)
let request = try document.validatedRequest(definition: definition)
let runtime = try requireRuntime(request.runtimeKind, capability: .containers,
as: (any RuntimeContainerClient).self)
// Preserve any local immutable image before a replacement pull can move its tag.
_ = try await runtime.prepareCreateRequest(request)
}

/// Restore a saved recipe and its running/stopped state without replacing an existing object.
@discardableResult public func restoreContainer(_ document: Core.Schema.Document,
originalWasRunning: Bool) async throws -> Core.Container.CreateResult {
let definition = schemaDefinition(for: document.operation, runtimeKind: document.runtimeKind)
let request = try document.validatedRequest(definition: definition)
let runtime = try requireRuntime(request.runtimeKind, capability: .containers,
as: (any RuntimeContainerClient).self)
return try await runtime.restoreContainer(request, originalWasRunning: originalWasRunning)
}

@discardableResult private func recreateContainer(originalID: String,
replacement: Core.Container.CreateRequest,
rollback: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult {
Expand Down
Loading
Loading