containerization-framework 0.2.1

Rust bindings for Apple's Containerization framework: Linux containers in lightweight VMs, in-process and without a daemon.
//===----------------------------------------------------------------------===//
// Images unpacked once, under `unpacked` in the image store.
//
// Each run needs its own writable ext4 rootfs, and unpacking rewrites gigabytes.
// So each image is unpacked once, keyed by digest, and a session gets a clone:
// free on APFS, costing only the blocks it changes. Off APFS it falls back to a
// copy, still cheaper than an unpack.
//
// `Build` evicts an entry once no image in the store has its digest.
//===----------------------------------------------------------------------===//

import Containerization
import ContainerizationOS
import Foundation

extension Containerization.Mount {
    /// An ext4 image file as the guest's root filesystem.
    static func ext4Root(_ image: URL) -> Self {
        .block(format: "ext4", source: image.absolutePath(), destination: "/", options: [])
    }
}

struct Unpacked {
    /// An unpack takes minutes at most; an older partial was abandoned.
    static let abandonedAfter: TimeInterval = 60 * 60

    /// The image store's root.
    let store: URL
    /// Ceiling for an unpack. Sparse, so a ceiling, not a cost — but nothing
    /// in the container may outgrow it. The caller's, since only it knows the
    /// workload; `ContainerManager`'s own default is 8 GiB.
    ///
    /// Not part of the entry's key: an image unpacked once is reused whatever
    /// ceiling the next container asks for, so raising it only takes effect
    /// for images not yet unpacked. `evict` is how to force a re-unpack.
    var capacityInBytes: UInt64 = 8.gib()

    private var root: URL { store.appending(path: "unpacked") }

    private func path(_ digest: String) -> URL {
        root.appending(path: "\(digest.replacing(":", with: "-")).ext4")
    }

    /// Whether the image is already unpacked. The Rust side asks before a run
    /// so it can announce an unpack.
    func holds(_ reference: String) async throws -> Bool {
        let image = try await ImageStore(path: store).get(reference: reference)

        return holds(image)
    }

    private func holds(_ image: Image) -> Bool {
        FileManager.default.fileExists(atPath: path(image.digest).path(percentEncoded: false))
    }

    /// Clones the image's unpacked rootfs to `destination`, unpacking it first
    /// on the image's first run.
    func rootfs(for image: Image, at destination: URL) async throws -> Containerization.Mount {
        let source = path(image.digest)

        if !holds(image) {
            try await unpack(image, to: source)
        }

        try Cache.clone(source, to: destination)

        return .ext4Root(destination)
    }

    /// Unpacked aside and moved into place, so an interrupted unpack is never
    /// found; under a unique name, so two sessions first booting the same image
    /// never share a file.
    private func unpack(_ image: Image, to destination: URL) async throws {
        try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)

        let partial = root.appending(path: "\(UUID().uuidString).partial")
        defer { try? FileManager.default.removeItem(at: partial) }

        let unpacker = EXT4Unpacker(capacityInBytes: capacityInBytes)
        _ = try await unpacker.unpack(image, for: .current, at: partial)

        do {
            try FileManager.default.moveItem(at: partial, to: destination)
        } catch where FileManager.default.fileExists(atPath: destination.path(percentEncoded: false)) {
            // Another session unpacked it first; its copy is equivalent.
        }
    }

    /// Removes entries no stored image references, and abandoned partials.
    /// Running sessions hold clones, never entries, so nothing in use is lost.
    func evict(keeping digests: some Sequence<String>) {
        let kept = Set(digests.map { path($0).lastPathComponent })
        let cutoff = Date.now.addingTimeInterval(-Self.abandonedAfter)
        let entries =
            (try? FileManager.default.contentsOfDirectory(
                at: root,
                includingPropertiesForKeys: [.contentModificationDateKey]
            )) ?? []

        for entry in entries {
            let stale =
                switch entry.pathExtension {
                case "ext4":
                    !kept.contains(entry.lastPathComponent)
                case "partial":
                    ((try? entry.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)
                        ?? .distantFuture) < cutoff
                default:
                    false
                }

            if stale {
                try? FileManager.default.removeItem(at: entry)
            }
        }
    }
}