containerization-framework 0.2.2

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