containerization-framework 0.2.2

Rust bindings for Apple's Containerization framework: Linux containers in lightweight VMs, in-process and without a daemon.
//===----------------------------------------------------------------------===//
// Building an image.
//
// Pull the base, unpack it to a writable ext4 block, boot it, run each step as an
// exec, export the block to a tar, and ingest that as a single-layer image. All
// in-process through Containerization, with no daemon.
//
// The image is one layer regardless of step count. The cache is block
// snapshots, not layers: a rebuild resumes from the deepest matching one. See
// `Cache.swift`.
//
// A build also removes stale builder rootfs and unreferenced blobs.
//
// The export is the fragile step; see `Ingest.swift`.
//===----------------------------------------------------------------------===//

import Containerization
import ContainerizationExtras
import ContainerizationOCI
import Foundation

enum Build {
  static let cacheDirectory = "build-cache"

  static func run(_ plan: BuildPlan) async throws {
    guard Entitlement.hasVirtualization else {
      throw BridgeError.unentitled
    }

    let root = URL(filePath: plan.storeRoot)
    // Our own because `ImageStore.contentStore` is internal and `ingest`
    // needs it. Same directory the store would use, so readers find the
    // blobs.
    let contentStore = try LocalContentStore(path: root.appending(path: "content"))
    let imageStore = try ImageStore(path: root, contentStore: contentStore)
    let platform = Platform.current

    // Pulled up front: the finished image inherits the base's config, and
    // its digest salts every cache key.
    let base = try await imageStore.get(reference: plan.base, pull: true)
    let baseConfig = try? await base.config(for: platform).config
    let environment = merge(baseConfig?.env ?? [], plan.environment)

    let cache = Cache(
      root: root.appending(path: cacheDirectory),
      keep: plan.cache.keep,
      keepFor: plan.cache.keepForSeconds
    )
    let keys = Keys(plan: plan, baseDigest: base.digest)

    // Nothing changed: re-tag, skipping the export.
    if plan.cache.restore, let descriptor = cache.image(keys.image),
      await Cache.holds(descriptor, in: contentStore)
    {
      note("\(plan.tag) is already built")
      try await retag(plan.tag, to: descriptor, in: imageStore)
      cache.evict()
      return
    }

    let (containerDirectory, rootfsPath) = container(plan.name, in: root)

    try? FileManager.default.removeItem(at: containerDirectory)
    sweepBuilders(in: root, keeping: plan.name)
    try FileManager.default.createDirectory(at: containerDirectory, withIntermediateDirectories: true)
    markBuilder(containerDirectory)

    let start = try await prepare(
      rootfs: rootfsPath,
      plan: plan,
      keys: keys,
      cache: cache,
      base: base,
      platform: platform
    )

    if start < plan.steps.endIndex {
      try await run(
        steps: start..<plan.steps.endIndex,
        of: plan,
        on: rootfsPath,
        keys: keys,
        cache: cache,
        base: base,
        imageStore: imageStore,
        environment: environment
      )
    }

    let descriptor = try await ingest(
      rootfs: rootfsPath,
      plan: plan,
      base: baseConfig,
      environment: environment,
      platform: platform,
      contentStore: contentStore
    )

    try await retag(plan.tag, to: descriptor, in: imageStore)

    cache.save(image: descriptor, as: keys.image)

    // Not `manager.delete`: a fully cached build has no manager, and its only
    // extra work is releasing a network interface, which there isn't.
    try? FileManager.default.removeItem(at: containerDirectory)

    if plan.reclaim {
      await reclaim(imageStore, root: root)
    }

    cache.evict()
  }

  /// Puts a rootfs at `rootfs` and returns the index of the first step to run:
  /// from the deepest cached step, else the cached unpacked base, else a fresh
  /// unpack.
  private static func prepare(
    rootfs: URL,
    plan: BuildPlan,
    keys: Keys,
    cache: Cache,
    base: Containerization.Image,
    platform: Platform
  ) async throws -> Int {
    if plan.cache.restore {
      for index in plan.steps.indices.reversed() where cache.holdsRootfs(keys.steps[index]) {
        note("cached through \(plan.steps[index].name)")
        try cache.restore(keys.steps[index], to: rootfs)

        return index + 1
      }

      if cache.holdsRootfs(keys.base) {
        try cache.restore(keys.base, to: rootfs)

        return 0
      }
    }

    let unpacker = EXT4Unpacker(capacityInBytes: plan.rootfsSizeInBytes)

    _ = try await unpacker.unpack(base, for: platform, at: rootfs)
    cache.save(rootfs: rootfs, as: keys.base)

    return 0
  }

  /// Runs the remaining steps, snapshotting the rootfs after each.
  ///
  /// One container per step: the block is only consistent once `stop` has
  /// unmounted it in the guest, so each snapshot costs a boot.
  private static func run(
    steps: Range<Int>,
    of plan: BuildPlan,
    on rootfs: URL,
    keys: Keys,
    cache: Cache,
    base: Containerization.Image,
    imageStore: ImageStore,
    environment: [String]
  ) async throws {
    let kernel = Kernel(path: URL(filePath: plan.kernelPath), platform: .linuxArm)
    var manager = try ContainerManager(
      kernel: kernel,
      initfs: try await Initfs.mount(plan.initfsReference, at: URL(filePath: plan.initfsPath), in: imageStore),
      imageStore: imageStore,
      network: nil
    )

    let mounts = plan.mounts
    let interface = plan.interface
    let block = Containerization.Mount.ext4Root(rootfs)

    for index in steps {
      let container = try await manager.create(
        plan.name,
        image: base,
        rootfs: block,
        networking: false,
        vm: plan.vm
      ) { config in
        config.cpus = plan.cpus
        config.memoryInBytes = plan.memoryInBytes
        // A keepalive, as in a session: steps are execs and need the
        // container to outlive them. The base's `Cmd` would exit.
        config.process.arguments = plan.keepalive
        config.process.user = .init()
        config.process.workingDirectory = "/"
        config.process.environmentVariables = environment
        config.mounts += mounts
        // Steps that install anything need network and DNS.
        config.interfaces = [interface]
        config.dns = interface.ipv4Gateway.map { DNS(nameservers: [$0.description]) }
      }

      try await container.create()
      try await container.start()

      do {
        try await step(
          plan.steps[index],
          index: index,
          in: container,
          shell: plan.shell,
          environment: environment
        )
      } catch {
        // Left for inspection, not cached; the next build sweeps it.
        try? await container.stop()
        throw error
      }

      try await container.stop()

      cache.save(rootfs: rootfs, as: keys.steps[index])
    }
  }

  /// Marks a `containers` directory as a builder's, not a session's. Holds the
  /// owning build's pid.
  private static let builderMarker = ".builder"

  private static func markBuilder(_ directory: URL) {
    try? Data("\(getpid())\n".utf8).write(to: directory.appending(path: builderMarker))
  }

  /// Removes rootfs left by failed builds and by tags since renamed.
  ///
  /// Only marked directories (sessions share `containers`), and only when the
  /// owning pid is gone (builds in other projects share this store).
  private static func sweepBuilders(in root: URL, keeping current: String) {
    let directories =
      (try? FileManager.default.contentsOfDirectory(at: containers(in: root), includingPropertiesForKeys: nil))
      ?? []

    for directory in directories where directory.lastPathComponent != current {
      let marker = directory.appending(path: builderMarker)

      guard let owner = try? String(contentsOf: marker, encoding: .utf8) else {
        continue
      }

      // Signal 0: does the process exist.
      if let pid = pid_t(owner.trimmingCharacters(in: .whitespacesAndNewlines)), kill(pid, 0) == 0 {
        continue
      }

      note("removing the rootfs left by \(directory.lastPathComponent)")
      try? FileManager.default.removeItem(at: directory)
    }
  }

  /// Points a reference at what this build produced.
  ///
  /// Delete first: `create` will not replace an existing reference, which a
  /// rebuild always has.
  private static func retag(_ reference: String, to descriptor: Descriptor, in imageStore: ImageStore) async throws {
    try? await imageStore.delete(reference: reference)
    try await imageStore.create(description: .init(reference: reference, descriptor: descriptor))
  }

  /// Deletes unreferenced blobs (chiefly the previous build's multi-gigabyte
  /// layer, orphaned by the re-tag) and unpacked rootfs of removed images.
  ///
  /// Only after `create`: until then this build's own blobs are unreferenced.
  /// Failure is logged, not thrown; the image is already usable.
  private static func reclaim(_ imageStore: ImageStore, root: URL) async {
    if let images = try? await imageStore.list() {
      Unpacked(store: root).evict(keeping: images.map(\.digest))
    }

    do {
      let (deleted, freed) = try await imageStore.cleanUpOrphanedBlobs()

      guard !deleted.isEmpty else {
        return
      }

      let size = ByteCountFormatter.string(fromByteCount: Int64(freed), countStyle: .file)

      note("reclaimed \(size) from \(deleted.count) unreferenced blob\(deleted.count == 1 ? "" : "s")")
    } catch {
      note("could not reclaim unreferenced blobs: \(error)")
    }
  }

  /// Runs one step to completion, throwing when it fails.
  ///
  /// `shell` is the caller's, with the script appended: whether a step that
  /// fails halfway through fails the build is its policy, not this one's.
  private static func step(
    _ step: BuildStep,
    index: Int,
    in container: LinuxContainer,
    shell: [String],
    environment: [String]
  ) async throws {
    let log = FileWriter(FileHandle.standardError)

    log.line("--> \(step.name)")

    let process = try await container.exec("build-\(index)") { config in
      config.arguments = shell + [step.script]
      config.environmentVariables = environment
      config.workingDirectory = "/"
      config.user = step.user.map { User(username: $0) } ?? User()
      config.stdout = log
      config.stderr = log
    }

    try await process.start()
    let status = try await process.wait()
    try? await process.delete()

    guard status.exitCode == 0 else {
      throw BridgeError.stepFailed(step.name, status.exitCode)
    }
  }

  /// `ENV` semantics: the plan's variables override the base's, and everything
  /// else the base declares is kept, in the base's order.
  private static func merge(_ base: [String], _ additions: [String]) -> [String] {
    func name(_ variable: String) -> String {
      String(variable.prefix(while: { $0 != "=" }))
    }

    let overridden = Set(additions.map(name))
    var merged = base.filter { !overridden.contains(name($0)) }

    merged.append(contentsOf: additions)

    return merged
  }
}