Skip to content
Open
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
4 changes: 2 additions & 2 deletions Sources/ContainerResource/Container/Bundle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ extension Bundle {

public func setContainerRootFs(fs: Filesystem) throws {
let fsData = try JSONEncoder().encode(fs)
try fsData.write(to: self.containerRootfsConfig)
try fsData.write(to: self.containerRootfsConfig, options: .atomic)
}

public func cloneContainerRootFs(cloning fs: Filesystem, readonly: Bool = false) throws {
Expand All @@ -160,7 +160,7 @@ extension Bundle {

private static func write(_ path: URL, value: Encodable) throws {
let data = try JSONEncoder().encode(value)
try data.write(to: path)
try data.write(to: path, options: .atomic)
}

public func load<T>(filename: String) throws -> T where T: Decodable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,8 @@ public actor ContainersService {
)
}
} catch {
try? FileManager.default.removeItem(at: dir)
log.warning(
"failed to load container",
"failed to load container; leaving bundle on disk",
metadata: [
"path": "\(dir.path)",
"error": "\(error)",
Expand Down Expand Up @@ -429,8 +428,14 @@ public actor ContainersService {
}

do {
guard let runtimePlugin = self.runtimePlugins.first(where: { $0.name == config.runtimeHandler }) else {
throw ContainerizationError(
.internalError,
message: "failed to find runtime plugin \(config.runtimeHandler)"
)
}
try Self.registerService(
plugin: self.runtimePlugins.first { $0.name == config.runtimeHandler }!,
plugin: runtimePlugin,
loader: self.pluginLoader,
configuration: config,
path: path,
Expand Down
133 changes: 133 additions & 0 deletions Tests/ContainerAPIServiceTests/ContainerLoadAtBootTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerPersistence
import ContainerResource
import Containerization
import ContainerizationError
import Foundation
import Logging
import Testing

@testable import ContainerAPIService
@testable import ContainerPlugin

struct ContainerLoadAtBootTests {
@Test
func malformedConfigurationFilesRemainOnDisk() throws {
let fixture = try Fixture()
defer { fixture.remove() }

let bundlePath = fixture.containers.appendingPathComponent("malformed")
try FileManager.default.createDirectory(at: bundlePath, withIntermediateDirectories: true)
try Data("{".utf8).write(to: bundlePath.appendingPathComponent("config.json"))
try Data("{".utf8).write(to: bundlePath.appendingPathComponent("runtime-configuration.json"))

let states = try ContainersService.loadAtBoot(
root: fixture.containers,
loader: fixture.loader,
log: fixture.log
)

#expect(states.isEmpty)
#expect(FileManager.default.fileExists(atPath: bundlePath.path))
}

@Test
func missingRuntimeBundleRemainsOnDiskAndIsListedAsStopped() async throws {
let fixture = try Fixture()
defer { fixture.remove() }

let bundlePath = fixture.containers.appendingPathComponent("missing-runtime")
try FileManager.default.createDirectory(at: bundlePath, withIntermediateDirectories: true)
let bundle = ContainerResource.Bundle(path: bundlePath)
try bundle.set(configuration: testConfiguration(id: "missing-runtime"))

let states = try ContainersService.loadAtBoot(
root: fixture.containers,
loader: fixture.loader,
log: fixture.log
)

#expect(states.count == 1)
#expect(states["missing-runtime"]?.snapshot.id == "missing-runtime")
#expect(states["missing-runtime"]?.snapshot.status == .stopped)
#expect(FileManager.default.fileExists(atPath: bundlePath.path))
let recovered: ContainerConfiguration = try bundle.load(filename: "config.json")
#expect(recovered.id == "missing-runtime")

let service = try fixture.makeService()
let error = await #expect(throws: ContainerizationError.self) {
try await service.bootstrap(id: "missing-runtime", stdio: [], dynamicEnv: [:])
}
#expect(error?.code == .internalError)
}

private func testConfiguration(id: String) -> ContainerConfiguration {
let image = ImageDescription(
reference: "docker.io/library/alpine:latest",
descriptor: .init(
mediaType: "application/vnd.oci.image.manifest.v1+json",
digest: "sha256:" + String(repeating: "0", count: 64),
size: 0
)
)
let process = ProcessConfiguration(
executable: "/bin/sh",
arguments: [],
environment: [],
workingDirectory: "/",
terminal: false,
user: .id(uid: 0, gid: 0),
supplementalGroups: [],
rlimits: []
)
return ContainerConfiguration(id: id, image: image, process: process)
}
}

private struct Fixture {
let root: URL
let containers: URL
let loader: PluginLoader
let log = Logger(label: "ContainerLoadAtBootTests")

init() throws {
root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
containers = root.appendingPathComponent("containers")
try FileManager.default.createDirectory(at: containers, withIntermediateDirectories: true)
loader = try PluginLoader(
appRoot: root,
installRoot: root,
logRoot: nil,
pluginDirectories: [],
pluginFactories: []
)
}

func remove() {
try? FileManager.default.removeItem(at: root)
}

func makeService() throws -> ContainersService {
try ContainersService(
appRoot: root,
pluginLoader: loader,
containerSystemConfig: ContainerSystemConfig(),
log: log
)
}
}