containerization-framework 0.2.2

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)
      }
    }
  }
}