diff --git a/Package.swift b/Package.swift index f6317c43c..e80b66d69 100644 --- a/Package.swift +++ b/Package.swift @@ -637,5 +637,14 @@ let package = Package( path: "Sources/Plugins/MachineAPIServer", exclude: ["config.toml", "Resources"] ), + .testTarget( + name: "MachineAPIServiceTests", + dependencies: [ + .product(name: "ContainerizationEXT4", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), + .product(name: "SystemPackage", package: "swift-system"), + "MachineAPIService", + ] + ), ] ) diff --git a/Sources/Services/MachineAPIService/Server/MachinesService.swift b/Sources/Services/MachineAPIService/Server/MachinesService.swift index f641d3521..63b183902 100644 --- a/Sources/Services/MachineAPIService/Server/MachinesService.swift +++ b/Sources/Services/MachineAPIService/Server/MachinesService.swift @@ -175,6 +175,25 @@ public actor MachinesService { return snapshots } + /// Ensure that a cloned machine root filesystem can boot as a container machine. + /// + /// The machine boot process hands off to the image's init system via + /// `exec /sbin/init`, so an image without one (e.g. a standard application + /// image such as `docker.io/library/ubuntu`) produces a machine that can + /// never boot. Validating here surfaces an actionable error at create time. + public static func validateMachineRootfs(blockDevice: FilePath, image: String) throws { + let reader = try EXT4.EXT4Reader(blockDevice: blockDevice) + guard reader.exists(FilePath("/sbin/init")) else { + throw ContainerizationError( + .invalidArgument, + message: + "image \(image) cannot be used as a container machine: it does not contain /sbin/init. " + + "Container machine images must include an init system such as systemd. " + + "See \"Bring your own container machine image\" in the container machine guide for a working Dockerfile." + ) + } + } + public func create(configuration: MachineConfiguration, resources: MachineResources?, bootConfig: MachineConfig) async throws { self.log.debug("\(#function)") @@ -199,6 +218,10 @@ public actor MachinesService { let machineImage = ClientImage(description: configuration.image) let imageFs = try await machineImage.getCreateSnapshot(platform: configuration.platform) try bundle.setMachineRootFs(cloning: imageFs) + try Self.validateMachineRootfs( + blockDevice: FilePath(bundle.machineRootfs.source), + image: configuration.image.reference + ) let state = MachineState( snapshot: .init( diff --git a/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift b/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift index 201532634..7d8461b43 100644 --- a/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift +++ b/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift @@ -33,6 +33,20 @@ struct TestCLIMachineCommand { } } + @Test func testCreateRejectsImageWithoutInit() async throws { + try await ContainerFixture.with { f in + // hello-world contains a single binary and no /sbin/init, so it can + // never boot as a container machine. + let name = "\(f.testID)-noinit" + f.addCleanup { f.cleanupMachine(name) } + let result = try f.runMachine(["create", "--name", name, "docker.io/library/hello-world:latest"]) + #expect(result.status != 0, "create should reject an image without /sbin/init") + #expect(result.error.contains("/sbin/init"), "error should name the missing init, got: \(result.error)") + let list = try f.runMachine(["list"]) + #expect(!list.output.contains(name), "failed create should not leave a machine behind") + } + } + @Test func testCreateRejectsDots() async throws { try await ContainerFixture.with { f in let result = try f.runMachine(["create", "--name", "my.bad.name", machineImage]) diff --git a/Tests/MachineAPIServiceTests/MachineRootfsValidationTests.swift b/Tests/MachineAPIServiceTests/MachineRootfsValidationTests.swift new file mode 100644 index 000000000..4f887c158 --- /dev/null +++ b/Tests/MachineAPIServiceTests/MachineRootfsValidationTests.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationEXT4 +import ContainerizationError +import Foundation +import MachineAPIService +import SystemPackage +import Testing + +@Suite +struct MachineRootfsValidationTests { + private static let testImage = "docker.io/library/ubuntu:latest" + + /// Create an ext4 block file in a temporary directory, populated by `populate`. + private func makeRootfs(populate: (EXT4.Formatter) throws -> Void) throws -> FilePath { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("MachineRootfsValidationTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let blockDevice = FilePath(dir.appendingPathComponent("rootfs.ext4").path) + let formatter = try EXT4.Formatter(blockDevice, minDiskSize: 2.mib()) + try populate(formatter) + try formatter.close() + return blockDevice + } + + @Test func passesWhenInitIsRegularFile() throws { + let blockDevice = try makeRootfs { formatter in + try formatter.create(path: FilePath("/sbin"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)) + try formatter.create(path: FilePath("/sbin/init"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755)) + } + try MachinesService.validateMachineRootfs(blockDevice: blockDevice, image: Self.testImage) + } + + @Test func passesWhenInitIsSymlinkToExistingFile() throws { + // Mirrors a systemd image: /sbin/init -> /lib/systemd/systemd + let blockDevice = try makeRootfs { formatter in + try formatter.create(path: FilePath("/sbin"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)) + try formatter.create(path: FilePath("/lib"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)) + try formatter.create(path: FilePath("/lib/systemd"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)) + try formatter.create(path: FilePath("/lib/systemd/systemd"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755)) + try formatter.create(path: FilePath("/sbin/init"), link: FilePath("/lib/systemd/systemd"), mode: EXT4.Inode.Mode(.S_IFLNK, 0o777)) + } + try MachinesService.validateMachineRootfs(blockDevice: blockDevice, image: Self.testImage) + } + + @Test func throwsWhenInitIsMissing() throws { + // Mirrors a standard application image such as ubuntu:latest or alpine. + let blockDevice = try makeRootfs { formatter in + try formatter.create(path: FilePath("/bin"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)) + try formatter.create(path: FilePath("/bin/sh"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755)) + } + let error = #expect(throws: ContainerizationError.self) { + try MachinesService.validateMachineRootfs(blockDevice: blockDevice, image: Self.testImage) + } + let message = try #require(error).message + #expect(message.contains("/sbin/init")) + #expect(message.contains(Self.testImage)) + } + + @Test func throwsWhenInitIsDanglingSymlink() throws { + let blockDevice = try makeRootfs { formatter in + try formatter.create(path: FilePath("/sbin"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)) + try formatter.create(path: FilePath("/sbin/init"), link: FilePath("/lib/systemd/systemd"), mode: EXT4.Inode.Mode(.S_IFLNK, 0o777)) + } + #expect(throws: ContainerizationError.self) { + try MachinesService.validateMachineRootfs(blockDevice: blockDevice, image: Self.testImage) + } + } +}