Files
container/Tests/ContainerizationTests/ContainerManagerTests.swift
T
Anthony b6b95ac897 Add per-container networking flag to ContainerManager.create() (#549)
This PR adds a `networking: Bool = true` parameter to
`ContainerManager.create()` so callers can opt out of network interface
creation on a per-container basis.

## Motivation

Currently, `ContainerManager.create()` unconditionally allocates a vmnet
network interface for every container (when the manager has a network
configured). Some use cases don't need network access and benefit from
having it disabled to reduce attack surface.

There's no way to achieve this today without either:
- Initializing the `ContainerManager` without a network (which disables
networking for *all* containers)
- Clearing `config.interfaces` in the configuration closure (which
wastes an IP allocation from the vmnet pool since `network.create(id)`
has already been called)

## Changes

- Add `networking: Bool = true` to all three `create()` overloads on
`ContainerManager`
- When `false`, `self.network?.create(id)` is skipped entirely. No
interface is allocated, and no DNS is configured.
- `releaseNetwork`/`delete` remain safe to call regardless, since
`Allocator.release` silently ignores unknown IDs.
- Add unit test `testNetworkingFalseSkipsInterfaceCreation` using the
existing `NilGatewayNetwork` fixture
- Add integration tests `testNetworkingDisabled` and
`testNetworkingEnabled` that create containers through a network-enabled
`ContainerManager` and verify the presence/absence of `eth0` via
`/sys/class/net/`
2026-02-22 17:08:49 -08:00

154 lines
6.2 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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 Containerization
import ContainerizationArchive
import ContainerizationError
import ContainerizationExtras
import Foundation
import Testing
@testable import Containerization
private struct NilGatewayInterface: Interface {
let ipv4Address: CIDRv4
let ipv4Gateway: IPv4Address? = nil
let macAddress: MACAddress? = nil
init() {
self.ipv4Address = try! CIDRv4("192.168.64.2/24")
}
}
private struct NilGatewayNetwork: ContainerManager.Network {
mutating func create(_ id: String) throws -> Interface? {
NilGatewayInterface()
}
mutating func release(_ id: String) throws {}
}
@Suite
struct ContainerManagerTests {
@Test func testCreateThrowsWhenGatewayMissing() async throws {
let fm = FileManager.default
let root = fm.uniqueTemporaryDirectory(create: true)
defer { try? fm.removeItem(at: root) }
let kernelPath = root.appendingPathComponent("vmlinux")
fm.createFile(atPath: kernelPath.path, contents: Data(), attributes: nil)
let initfsPath = root.appendingPathComponent("initfs.ext4")
fm.createFile(atPath: initfsPath.path, contents: Data(), attributes: nil)
let kernel = Kernel(path: kernelPath, platform: .linuxArm)
let initfs = Mount.block(format: "ext4", source: initfsPath.path, destination: "/")
var manager = try ContainerManager(
kernel: kernel,
initfs: initfs,
root: root,
network: NilGatewayNetwork()
)
let tempDir = fm.uniqueTemporaryDirectory()
defer { try? fm.removeItem(at: tempDir) }
let tarPath = Foundation.Bundle.module.url(forResource: "scratch", withExtension: "tar")!
let reader = try ArchiveReader(format: .pax, filter: .none, file: tarPath)
let rejectedPaths = try reader.extractContents(to: tempDir)
#expect(rejectedPaths.isEmpty)
let images = try await manager.imageStore.load(from: tempDir)
let image = images.first!
let rootfsPath = root.appendingPathComponent("rootfs.ext4")
fm.createFile(atPath: rootfsPath.path, contents: Data(), attributes: nil)
let rootfs = Mount.block(format: "ext4", source: rootfsPath.path, destination: "/")
do {
_ = try await manager.create("test-nil-gateway", image: image, rootfs: rootfs) { _ in }
#expect(Bool(false), "expected invalidState error for missing ipv4 gateway")
} catch let error as ContainerizationError {
#expect(error.code == .invalidState)
#expect(error.message.contains("missing ipv4 gateway"))
} catch {
#expect(Bool(false), "unexpected error: \(error)")
}
}
@Test func testNetworkingFalseSkipsInterfaceCreation() async throws {
let fm = FileManager.default
let root = fm.uniqueTemporaryDirectory(create: true)
defer { try? fm.removeItem(at: root) }
let kernelPath = root.appendingPathComponent("vmlinux")
fm.createFile(atPath: kernelPath.path, contents: Data(), attributes: nil)
let initfsPath = root.appendingPathComponent("initfs.ext4")
fm.createFile(atPath: initfsPath.path, contents: Data(), attributes: nil)
let kernel = Kernel(path: kernelPath, platform: .linuxArm)
let initfs = Mount.block(format: "ext4", source: initfsPath.path, destination: "/")
// Use NilGatewayNetwork — with networking: true this would throw invalidState,
// but with networking: false the network's create() is never called.
var manager = try ContainerManager(
kernel: kernel,
initfs: initfs,
root: root,
network: NilGatewayNetwork()
)
let tempDir = fm.uniqueTemporaryDirectory()
defer { try? fm.removeItem(at: tempDir) }
let tarPath = Foundation.Bundle.module.url(forResource: "scratch", withExtension: "tar")!
let reader = try ArchiveReader(format: .pax, filter: .none, file: tarPath)
let rejectedPaths = try reader.extractContents(to: tempDir)
#expect(rejectedPaths.isEmpty)
let images = try await manager.imageStore.load(from: tempDir)
let image = images.first!
let rootfsPath = root.appendingPathComponent("rootfs.ext4")
fm.createFile(atPath: rootfsPath.path, contents: Data(), attributes: nil)
let rootfs = Mount.block(format: "ext4", source: rootfsPath.path, destination: "/")
// With networking: false, NilGatewayNetwork.create() is never called,
// so we should not get the "missing ipv4 gateway" error.
// The container creation will fail for other reasons (dummy VMM), but the
// configuration closure should see empty interfaces.
var closureWasCalled = false
do {
_ = try await manager.create(
"test-no-networking",
image: image,
rootfs: rootfs,
networking: false
) { config in
closureWasCalled = true
#expect(config.interfaces.isEmpty)
#expect(config.dns == nil)
}
} catch {
// Container creation may fail due to dummy kernel/VMM — that's expected.
// The key assertion is in the configuration closure above.
let description = String(describing: error)
#expect(!description.contains("missing ipv4 gateway"))
}
#expect(closureWasCalled, "configuration closure must be invoked to validate interfaces")
}
}