containerization-framework 0.1.0

Rust bindings for Apple's Containerization framework: Linux containers in lightweight VMs, in-process and without a daemon.
//===----------------------------------------------------------------------===//
// The build cache, under `build-cache` in the image store:
//
//   * `rootfs/<key>.ext4` — the block after some step. A build matching an
//     earlier one through step `i` resumes from it.
//   * `images/<key>.json` — the descriptor a build ingested, so an unchanged
//     build skips the export.
//
// Snapshots are APFS clones, so they cost only what they differ by. Off APFS
// they fall back to a full copy.
//
// Keys come from the Rust side's `cache` module; `Keys` salts them.
//===----------------------------------------------------------------------===//

import ContainerizationOCI
import CryptoKit
import Foundation

/// The plan's key chain, salted with what only the builder knows.
struct Keys {
    let base: String
    let steps: [String]
    /// The last rootfs plus the user and working directory, which a rootfs
    /// does not record.
    let image: String

    init(plan: BuildPlan, baseDigest: String) {
        // The base digest, so a moved base misses everything; the capacity,
        // because a snapshot keeps the size it was taken at.
        func salted(_ key: String) -> String {
            Self.digest("\(baseDigest)\n\(plan.rootfsSizeInBytes)\n\(key)")
        }

        let base = salted(plan.baseKey)
        let steps = plan.steps.map { salted($0.cacheKey) }

        self.base = base
        self.steps = steps
        self.image = Self.digest("\(steps.last ?? base)\n\(plan.user ?? "")\n\(plan.workingDirectory ?? "")")
    }

    static func digest(_ text: String) -> String {
        SHA256.hash(data: Data(text.utf8))
            .map { String(format: "%02x", $0) }
            .joined()
    }
}

struct Cache {
    let root: URL
    /// Snapshots kept, most recently used first. The caller's: how much disk a
    /// build cache is worth is not this crate's to decide.
    let keep: Int
    /// Anything unused this long goes regardless.
    let keepFor: TimeInterval

    private var rootfsDirectory: URL { root.appending(path: "rootfs") }
    private var imagesDirectory: URL { root.appending(path: "images") }

    private func rootfsPath(_ key: String) -> URL {
        rootfsDirectory.appending(path: "\(key).ext4")
    }

    private func imagePath(_ key: String) -> URL {
        imagesDirectory.appending(path: "\(key).json")
    }

    // MARK: - Rootfs snapshots

    func holdsRootfs(_ key: String) -> Bool {
        FileManager.default.fileExists(atPath: rootfsPath(key).path(percentEncoded: false))
    }

    /// Clones a snapshot to `destination`. The build writes to the clone, so a
    /// failed build leaves the cache untouched.
    func restore(_ key: String, to destination: URL) throws {
        try Self.clone(rootfsPath(key), to: destination)
        touch(rootfsPath(key))
    }

    /// Best effort: a failure only costs the next build time.
    func save(rootfs: URL, as key: String) {
        let destination = rootfsPath(key)

        guard !FileManager.default.fileExists(atPath: destination.path(percentEncoded: false)) else {
            touch(destination)
            return
        }

        do {
            try FileManager.default.createDirectory(at: rootfsDirectory, withIntermediateDirectories: true)
            // Cloned aside then moved, so an interrupted clone is never found.
            let partial = rootfsDirectory.appending(path: "\(key).partial")

            try Self.clone(rootfs, to: partial)
            try? FileManager.default.removeItem(at: destination)
            try FileManager.default.moveItem(at: partial, to: destination)
        } catch {
            note("could not cache the rootfs for \(key.prefix(12)): \(error)")
        }
    }

    // MARK: - Ingested images

    func image(_ key: String) -> Descriptor? {
        guard let data = try? Data(contentsOf: imagePath(key)) else {
            return nil
        }

        touch(imagePath(key))

        return try? JSONDecoder().decode(Descriptor.self, from: data)
    }

    func save(image descriptor: Descriptor, as key: String) {
        do {
            try FileManager.default.createDirectory(at: imagesDirectory, withIntermediateDirectories: true)
            try JSONEncoder().encode(descriptor).write(to: imagePath(key), options: .atomic)
        } catch {
            note("could not cache the image for \(key.prefix(12)): \(error)")
        }
    }

    /// Whether every blob under a descriptor is still stored; `reclaim` deletes
    /// them once the tag is gone.
    static func holds(_ descriptor: Descriptor, in contentStore: ContentStore) async -> Bool {
        guard let content = try? await contentStore.get(digest: descriptor.digest),
            let index: Index = try? content.decode()
        else {
            return false
        }

        for entry in index.manifests {
            guard let held = try? await contentStore.get(digest: entry.digest),
                let manifest: Manifest = try? held.decode()
            else {
                return false
            }

            for blob in manifest.layers + [manifest.config] {
                guard (try? await contentStore.get(digest: blob.digest)) != nil else {
                    return false
                }
            }
        }

        return true
    }

    // MARK: - Eviction

    /// By last use: restoring or re-saving touches an entry.
    func evict() {
        let cutoff = Date.now.addingTimeInterval(-keepFor)
        let snapshots = Self.entries(of: rootfsDirectory)

        for (index, entry) in snapshots.enumerated() where index >= keep || entry.used < cutoff {
            try? FileManager.default.removeItem(at: entry.path)
        }

        for entry in Self.entries(of: imagesDirectory) where entry.used < cutoff {
            try? FileManager.default.removeItem(at: entry.path)
        }
    }

    /// Most recently used first.
    private static func entries(of directory: URL) -> [(path: URL, used: Date)] {
        let listing =
            (try? FileManager.default.contentsOfDirectory(
                at: directory,
                includingPropertiesForKeys: [.contentModificationDateKey]
            )) ?? []

        return
            listing
            .map { path in
                let used =
                    (try? path.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)
                    ?? .distantPast

                return (path: path, used: used)
            }
            .sorted { $0.used > $1.used }
    }

    private func touch(_ path: URL) {
        try? FileManager.default.setAttributes(
            [.modificationDate: Date.now],
            ofItemAtPath: path.path(percentEncoded: false)
        )
    }

    // MARK: - Copying

    /// `clonefile` where supported, else a real copy.
    static func clone(_ source: URL, to destination: URL) throws {
        try? FileManager.default.removeItem(at: destination)

        if clonefile(source.path(percentEncoded: false), destination.path(percentEncoded: false), 0) == 0 {
            return
        }

        let failure = errno

        guard [ENOTSUP, EXDEV, EINVAL, EPERM].contains(failure) else {
            throw BridgeError.notCloned(source.lastPathComponent, String(cString: strerror(failure)))
        }

        try FileManager.default.copyItem(at: source, to: destination)
    }
}