Files
container/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift
T
Noah Thornton e3c49803a0 Move to TOML configuration for defaults (#1425)
- Discussion topic #1336.
- This change migrates away from using `UserDefaults`,
  instead providing a TOML configuration mechanism for
  user configurable settings. All existing system property
  settings keys are supported in the new configuration
  file. However, users will have to migrate any settings
  they have configured in the `UserDefaults` into TOML
  for these settings to take effect.
- Breaking changes:
  * `container system property get` is removed in favor of
    users directly utilizing `container system property list --format toml | jq<>`.
  * `container system property set` is removed since the TOML
    configuration is effectively immutable during the lifetime of the
    `container` daemon. Uses can edit the TOML they have in their home
    directory, however no changes will take effect until the daemon is
    restarted via `container system stop && container system start`
* `container system property list --format table` is removed as
    generating tabular format is non-trivial and the new TOML format is
    intended to be human readable
2026-05-04 12:04:24 -07:00

265 lines
10 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerAPIClient
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import ContainerizationOS
import Foundation
import Logging
import TerminalProgress
public actor SnapshotStore {
private static let snapshotFileName = "snapshot"
private static let snapshotInfoFileName = "snapshot-info"
private static let ingestDirName = "ingest"
/// Return the Unpacker to use for a given image.
/// If the given platform for the image cannot be unpacked return `nil`.
public typealias UnpackStrategy = @Sendable (Containerization.Image, Platform) async throws -> Unpacker?
public static func defaultUnpackStrategy(initImage: String) -> UnpackStrategy {
{ image, platform in
guard platform.os == "linux" else {
return nil
}
var minBlockSize = 512.gib()
if image.reference == initImage {
minBlockSize = 512.mib()
}
return EXT4Unpacker(blockSizeInBytes: minBlockSize)
}
}
let path: URL
let fm = FileManager.default
let ingestDir: URL
let unpackStrategy: UnpackStrategy
let log: Logger?
public init(path: URL, unpackStrategy: @escaping UnpackStrategy, log: Logger?) throws {
let root = path.appendingPathComponent("snapshots")
self.path = root
self.ingestDir = self.path.appendingPathComponent(Self.ingestDirName)
self.unpackStrategy = unpackStrategy
self.log = log
try self.fm.createDirectory(at: root, withIntermediateDirectories: true)
try self.fm.createDirectory(at: self.ingestDir, withIntermediateDirectories: true)
}
public func unpack(image: Containerization.Image, platform: Platform? = nil, progressUpdate: ProgressUpdateHandler?) async throws {
var toUnpack: [Descriptor] = []
if let platform {
let desc = try await image.descriptor(for: platform)
toUnpack = [desc]
} else {
toUnpack = try await image.unpackableDescriptors()
}
let taskManager = ProgressTaskCoordinator()
var taskUpdateProgress: ProgressUpdateHandler?
for desc in toUnpack {
try Task.checkCancellation()
let snapshotDir = self.snapshotDir(desc)
guard !self.fm.fileExists(atPath: snapshotDir.absolutePath()) else {
// We have already unpacked this image + platform. Skip
continue
}
guard let platform = desc.platform else {
throw ContainerizationError(.internalError, message: "missing platform for descriptor \(desc.digest)")
}
guard let unpacker = try await self.unpackStrategy(image, platform) else {
self.log?.warning("no unpacker configured, skipping unpack for \(image.reference) for platform \(platform.description)")
continue
}
let currentSubTask = await taskManager.startTask()
if let progressUpdate {
let _taskUpdateProgress = ProgressTaskCoordinator.handler(for: currentSubTask, from: progressUpdate)
await _taskUpdateProgress([
.setSubDescription("for platform \(platform.description)")
])
taskUpdateProgress = _taskUpdateProgress
}
let tempDir = try self.tempUnpackDir()
let tempSnapshotPath = tempDir.appendingPathComponent(Self.snapshotFileName, isDirectory: false)
let infoPath = tempDir.appendingPathComponent(Self.snapshotInfoFileName, isDirectory: false)
do {
let progress = ContainerizationProgressAdapter.handler(from: taskUpdateProgress)
let mount = try await unpacker.unpack(image, for: platform, at: tempSnapshotPath, progress: progress)
let fs = Filesystem.block(
format: mount.type,
source: self.snapshotPath(desc).absolutePath(),
destination: mount.destination,
options: mount.options
)
let snapshotInfo = try JSONEncoder().encode(fs)
self.fm.createFile(atPath: infoPath.absolutePath(), contents: snapshotInfo)
} catch {
try? self.fm.removeItem(at: tempDir)
throw error
}
do {
try fm.moveItem(at: tempDir, to: snapshotDir)
} catch let err as NSError {
guard err.code == NSFileWriteFileExistsError else {
throw err
}
try? self.fm.removeItem(at: tempDir)
}
}
await taskManager.finish()
}
public func delete(for image: Containerization.Image, platform: Platform? = nil) async throws {
var toDelete: [Descriptor] = []
if let platform {
let desc = try await image.descriptor(for: platform)
toDelete.append(desc)
} else {
toDelete = try await image.unpackableDescriptors()
}
for desc in toDelete {
let p = self.snapshotDir(desc)
guard self.fm.fileExists(atPath: p.absolutePath()) else {
continue
}
try self.fm.removeItem(at: p)
}
}
public func get(for image: Containerization.Image, platform: Platform) async throws -> Filesystem {
let desc = try await image.descriptor(for: platform)
let infoPath = snapshotInfoPath(desc)
let fsPath = snapshotPath(desc)
guard self.fm.fileExists(atPath: infoPath.absolutePath()),
self.fm.fileExists(atPath: fsPath.absolutePath())
else {
throw ContainerizationError(.notFound, message: "image snapshot for \(image.reference) with platform \(platform.description)")
}
let decoder = JSONDecoder()
let data = try Data(contentsOf: infoPath)
let fs = try decoder.decode(Filesystem.self, from: data)
return fs
}
public func clean(keepingSnapshotsFor images: [Containerization.Image] = []) async throws -> UInt64 {
var toKeep: [String] = [Self.ingestDirName]
for image in images {
for manifest in try await image.index().manifests {
guard let platform = manifest.platform else {
continue
}
let desc = try await image.descriptor(for: platform)
toKeep.append(desc.digest.trimmingDigestPrefix)
}
}
let all = try self.fm.contentsOfDirectory(at: self.path, includingPropertiesForKeys: [.totalFileAllocatedSizeKey]).map {
$0.lastPathComponent
}
let delete = Set(all).subtracting(Set(toKeep))
var deletedBytes: UInt64 = 0
for dir in delete {
let unpackedPath = self.path.appending(path: dir, directoryHint: .isDirectory)
guard self.fm.fileExists(atPath: unpackedPath.absolutePath()) else {
continue
}
deletedBytes += (try? self.fm.directorySize(dir: unpackedPath)) ?? 0
try self.fm.removeItem(at: unpackedPath)
}
return deletedBytes
}
private func snapshotDir(_ desc: Descriptor) -> URL {
let p = self.path.appendingPathComponent(desc.digest.trimmingDigestPrefix, isDirectory: true)
return p
}
private func snapshotPath(_ desc: Descriptor) -> URL {
let p = self.snapshotDir(desc)
.appendingPathComponent(Self.snapshotFileName, isDirectory: false)
return p
}
private func snapshotInfoPath(_ desc: Descriptor) -> URL {
let p = self.snapshotDir(desc)
.appendingPathComponent(Self.snapshotInfoFileName, isDirectory: false)
return p
}
private func tempUnpackDir() throws -> URL {
let uniqueDirectoryURL = ingestDir.appendingPathComponent(UUID().uuidString, isDirectory: true)
try self.fm.createDirectory(at: uniqueDirectoryURL, withIntermediateDirectories: true, attributes: nil)
return uniqueDirectoryURL
}
/// Get the disk size for a specific snapshot descriptor
public func getSnapshotSize(descriptor: Descriptor) throws -> UInt64 {
let snapshotPath = self.snapshotDir(descriptor)
guard self.fm.fileExists(atPath: snapshotPath.path) else {
return 0
}
return try self.fm.directorySize(dir: snapshotPath)
}
}
extension FileManager {
fileprivate func directorySize(dir: URL) throws -> UInt64 {
var size: UInt64 = 0
let resourceKeys: [URLResourceKey] = [.totalFileAllocatedSizeKey]
guard
let enumerator = self.enumerator(
at: dir,
includingPropertiesForKeys: resourceKeys,
options: [.skipsHiddenFiles]
)
else {
return 0
}
for case let fileURL as URL in enumerator {
if let resourceValues = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey]),
let fileSize = resourceValues.totalFileAllocatedSize
{
size += UInt64(fileSize)
}
}
return size
}
}
extension Containerization.Image {
fileprivate func unpackableDescriptors() async throws -> [Descriptor] {
let index = try await self.index()
return index.manifests.filter { desc in
guard desc.platform != nil else {
return false
}
if let referenceType = desc.annotations?["vnd.docker.reference.type"], referenceType == "attestation-manifest" {
return false
}
return true
}
}
}