Skip to content
Draft
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
13 changes: 13 additions & 0 deletions Sources/Containerization/LinuxContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,19 @@ extension LinuxContainer {
}
}

// Perform filesystem operations in the container.
public func filesystemOperation(operation: FilesystemOperation, path: String) async throws {
try await self.state.withLock {
let state = try $0.startedState("filesystemOperation")
try await state.vm.withAgent { agent in
guard let vminitd = agent as? Vminitd else {
throw ContainerizationError(.unsupported, message: "filesystemOperation requires Vminitd agent")
}
try await vminitd.filesystemOperation(operation: operation, path: path)
}
}
}

private func relayUnixSocket(
socket: UnixSocketConfiguration,
relayManager: UnixSocketRelayManager,
Expand Down
222 changes: 222 additions & 0 deletions Sources/Containerization/SandboxContext/SandboxContext.grpc.swift

Large diffs are not rendered by default.

361 changes: 361 additions & 0 deletions Sources/Containerization/SandboxContext/SandboxContext.pb.swift

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions Sources/Containerization/SandboxContext/SandboxContext.proto
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ service SandboxContext {
rpc Copy(CopyRequest) returns (stream CopyResponse);
// Stat a path in the guest filesystem.
rpc Stat(StatRequest) returns (StatResponse);
// Perform a filesystem operation on a mounted filesystem.
rpc FilesystemOperation(FilesystemOperationRequest) returns (FilesystemOperationResponse);

// Create a new process inside the container.
rpc CreateProcess(CreateProcessRequest) returns (CreateProcessResponse);
Expand Down Expand Up @@ -292,6 +294,33 @@ message StatResponse {
string error = 2; // Non-empty if stat failed.
}

message FiTrimParams {
uint64 start = 1;
uint64 len = 2;
uint64 minimum_len = 3;
}
message FiFreezeParams {}
message FiThawParams {}

message FiTrimResult {
uint64 trimmed_bytes = 1;
}

message FilesystemOperationRequest {
string path = 1;
oneof operation {
FiTrimParams trim = 2;
FiFreezeParams freeze = 3;
FiThawParams thaw = 4;
}
}

message FilesystemOperationResponse {
oneof result {
FiTrimResult trim = 1;
}
}

message IpLinkSetRequest {
string interface = 1;
bool up = 2;
Expand Down
8 changes: 8 additions & 0 deletions Sources/Containerization/VirtualMachineAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ public struct WriteFileFlags {
public var create = false
}

public enum FilesystemOperation: Sendable {
case freeze
case thaw
case trim
}

/// A protocol for the agent running inside a virtual machine. If an operation isn't
/// supported the implementation MUST return a ContainerizationError with a code of
/// `.unsupported`.
Expand All @@ -34,6 +40,8 @@ public protocol VirtualMachineAgent: Sendable {
func standardSetup() async throws
/// Close any resources held by the agent.
func close() async throws
// Perform a filesystem operation on the given path.
func filesystemOperation(operation: FilesystemOperation, path: String) async throws

// POSIX-y
func getenv(key: String) async throws -> String
Expand Down
23 changes: 23 additions & 0 deletions Sources/Containerization/Vminitd.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,15 @@ extension Vminitd: VirtualMachineAgent {
})
}

/// Perform a filesystem operation on a path inside the sandbox's environment.
public func filesystemOperation(operation: FilesystemOperation, path: String) async throws {
_ = try await client.filesystemOperation(
.with {
$0.operation = operation.toProtoOperation()
$0.path = path
})
}

public func createProcess(
id: String,
containerID: String?,
Expand Down Expand Up @@ -586,3 +595,17 @@ extension StatCategory {
return categories
}
}

extension FilesystemOperation {
/// Convert FilesystemOperation to proto oneof value.
func toProtoOperation() -> Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest.OneOf_Operation {
switch self {
case .freeze:
return .freeze(.init())
case .thaw:
return .thaw(.init())
case .trim:
return .trim(.init())
}
}
}
132 changes: 132 additions & 0 deletions Sources/Integration/ContainerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4006,6 +4006,138 @@ extension IntegrationSuite {
}
}

func testFrozenExt4Clone() async throws {
let id = "test-frozen-ext4-clone"
let bs = try await bootstrap(id)

let diskImageURL = Self.testDir.appending(component: "\(id)-data.ext4")
try? FileManager.default.removeItem(at: diskImageURL)

let filesystem = try EXT4.Formatter(FilePath(diskImageURL.absolutePath()), minDiskSize: 64.mib())
try filesystem.close()

let cloneImageURL = Self.testDir.appending(component: "\(id)-data-clone.ext4")
try? FileManager.default.removeItem(at: cloneImageURL)

let writerContainer = try LinuxContainer("\(id)-writer", rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/bin/sleep", "1000"]
config.mounts.append(
Mount.block(
format: "ext4",
source: diskImageURL.absolutePath(),
destination: "/data"
))
config.bootLog = bs.bootLog
}

do {
try await writerContainer.create()
try await writerContainer.start()

try await writerContainer.filesystemOperation(operation: .freeze, path: "/data")

let writeExec = try await writerContainer.exec("write-hello") { config in
config.arguments = ["/bin/sh", "-c", "echo hello > /data/hello.txt"]
}
try await writeExec.start()
let writeStatus = try await writeExec.wait()
try await writeExec.delete()
guard writeStatus.exitCode == 0 else {
throw IntegrationError.assert(msg: "write exec failed with status \(writeStatus)")
}

try FileManager.default.copyItem(at: diskImageURL, to: cloneImageURL)
defer {
try? FileManager.default.removeItem(at: diskImageURL)
try? FileManager.default.removeItem(at: cloneImageURL)
}

try await writerContainer.filesystemOperation(operation: .thaw, path: "/data")

try await writerContainer.kill(.kill)
_ = try await writerContainer.wait()
try await writerContainer.stop()
} catch {
try? await writerContainer.filesystemOperation(operation: .thaw, path: "/data")
try? await writerContainer.stop()
throw error
}

let verifyContainer = try LinuxContainer("\(id)-reader", rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.mounts.append(
Mount.block(
format: "ext4",
source: cloneImageURL.absolutePath(),
destination: "/data"
))
config.process.arguments = ["/bin/sleep", "1000"]
config.bootLog = bs.bootLog
}

do {
try await verifyContainer.create()
try await verifyContainer.start()

let mountBuffer = BufferWriter()
let mountExec = try await verifyContainer.exec("verify-mount") { config in
config.arguments = ["/bin/sh", "-c", "grep ' /data ' /proc/mounts"]
config.stdout = mountBuffer
}
try await mountExec.start()
var status = try await mountExec.wait()
try await mountExec.delete()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "failed to verify /data mount, status \(status)")
}

let mountOutput = String(data: mountBuffer.data, encoding: .utf8) ?? ""
guard mountOutput.contains(" /data ") && mountOutput.contains(" ext4 ") else {
throw IntegrationError.assert(msg: "expected ext4 mount at /data, got: \(mountOutput)")
}

let dmesgBuffer = BufferWriter()
let dmesgExec = try await verifyContainer.exec("verify-dmesg-clean") { config in
config.arguments = [
"/bin/sh", "-c",
"if dmesg | grep -Eiq 'fsck|recovering journal|recovery complete'; then dmesg | grep -Ei 'fsck|recovering journal|recovery complete'; exit 1; fi",
]
config.stdout = dmesgBuffer
}
try await dmesgExec.start()
status = try await dmesgExec.wait()
try await dmesgExec.delete()
guard status.exitCode == 0 else {
let dmesgOutput = String(data: dmesgBuffer.data, encoding: .utf8) ?? ""
throw IntegrationError.assert(msg: "dmesg indicates filesystem recovery on cloned image: \(dmesgOutput)")
}

let lsBuffer = BufferWriter()
let lsExec = try await verifyContainer.exec("verify-no-hello") { config in
config.arguments = ["ls", "-1", "/data"]
config.stdout = lsBuffer
}
try await lsExec.start()
status = try await lsExec.wait()
try await lsExec.delete()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "ls /data failed with status \(status)")
}

let lsOutput = String(data: lsBuffer.data, encoding: .utf8) ?? ""
let listedFiles = Set(lsOutput.split(whereSeparator: \.isNewline).map(String.init))
guard !listedFiles.contains("hello.txt") else {
throw IntegrationError.assert(msg: "expected cloned /data to not contain hello.txt, got: \(lsOutput)")
}

try await verifyContainer.kill(.kill)
_ = try await verifyContainer.wait()
try await verifyContainer.stop()
} catch {
try? await verifyContainer.stop()
throw error
}
}

func testUseInitBasic() async throws {
let id = "test-use-init-basic"

Expand Down
1 change: 1 addition & 0 deletions Sources/Integration/Suite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ struct IntegrationSuite: AsyncParsableCommand {
Test("container writable layer with ro lower", testWritableLayerWithReadOnlyLower),
Test("container writable layer size", testWritableLayerSize),
Test("container writable layer DNS and hosts", testWritableLayerWithDNSAndHosts),
Test("container frozen ext4 clone", testFrozenExt4Clone),
Test("large stdin input", testLargeStdinInput),
Test("exec large stdin input", testExecLargeStdinInput),
Test("exec custom path resolution", testExecCustomPathResolution),
Expand Down
90 changes: 90 additions & 0 deletions vminitd/Sources/VminitdCore/Server+GRPC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,96 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ
}
}

public func filesystemOperation(request: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest, context: GRPCCore.ServerContext)
async throws -> Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse
{
log.debug(
"filesystemOperation",
metadata: [
"operation": "\(String(describing: request.operation))",
"path": "\(request.path)",
])

if !request.path.hasPrefix("/") {
throw RPCError(code: .invalidArgument, message: "path must be absolute")
}

var finfo = sys_stat.stat()
let rc = lstat(request.path, &finfo)
if rc != 0 {
let error = swiftErrno("lstat")
throw RPCError(code: .notFound, message: "failed to stat path", cause: error)
}

if (finfo.st_mode & S_IFMT) == S_IFLNK {
throw RPCError(code: .internalError, message: "path cannot be a symlink")
}

let fd = open(request.path, O_RDONLY)
if fd < 0 {
let error = swiftErrno("open")
throw RPCError(code: .internalError, message: "failed to open path", cause: error)
}

defer { close(fd) }

do {
switch request.operation {
case .freeze:
try freezeFilesystem(fd: fd)
case .thaw:
try thawFilesystem(fd: fd)
case .trim(let params):
try trimFilesystem(fd: fd, params: params)
case .none:
throw RPCError(code: .invalidArgument, message: "invalid operation")
}
} catch {
log.error(
"filesystemOperation",
metadata: [
"error": "\(error)"
])
throw RPCError(code: .internalError, message: "filesystemOperation", cause: error)
}

return .init()
}

private func freezeFilesystem(fd: Int32) throws {
let FIFREEZE: UInt = 0xC004_5877
let rc: CInt = ioctl(fd, FIFREEZE, 0)
if rc != 0 {
let error = swiftErrno("ioctl(FIFREEZE)")
throw RPCError(code: .internalError, message: "freeze failed", cause: error)
}
}

private func thawFilesystem(fd: Int32) throws {
let FITHAW: UInt = 0xC004_5878
let rc: CInt = ioctl(fd, FITHAW, 0)
if rc != 0 {
let error = swiftErrno("ioctl(FITHAW)")
throw RPCError(code: .internalError, message: "thaw failed", cause: error)
}
}

private struct fitrim_range {
var start: UInt64
var len: UInt64
var minimumLen: UInt64
}

private func trimFilesystem(fd: Int32, params: Com_Apple_Containerization_Sandbox_V3_FiTrimParams) throws {
let FITRIM: UInt = 0xC004_5879
var trange = fitrim_range(start: UInt64(params.start), len: UInt64(params.len), minimumLen: UInt64(params.minimumLen))
let rc: CInt = ioctl(fd, FITRIM, &trange)
if rc != 0 {
let error = swiftErrno("ioctl(FITRIM)")
throw RPCError(code: .internalError, message: "trim failed", cause: error)
}
}

public func umount(request: Com_Apple_Containerization_Sandbox_V3_UmountRequest, context: GRPCCore.ServerContext)
async throws -> Com_Apple_Containerization_Sandbox_V3_UmountResponse
{
Expand Down