Files
J Logan d242864e9f Relocate and rename ClientDefaults. (#474)
- Part of #384.
- Rename to reflect that these are not just client defaults.
- Relocate so callers don't need the heavyweight coupling to
ContainerClient to access the type.
2025-08-08 16:04:32 -07:00

244 lines
9.9 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ContainerClient
import ContainerPersistence
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 let defaultUnpackStrategy: UnpackStrategy = { image, platform in
guard platform.os == "linux" else {
return nil
}
var minBlockSize = 512.gib()
if image.reference == DefaultsStore.get(key: .defaultInitImage) {
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("Skipping unpack for \(image.reference) for platform \(platform.description). No unpacker configured.")
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
}
}
extension FileManager {
fileprivate func directorySize(dir: URL) throws -> UInt64 {
var size = 0
let resourceKeys: [URLResourceKey] = [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey]
let contents = try self.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: resourceKeys)
for p in contents {
let val = try p.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey])
size += val.totalFileAllocatedSize ?? val.fileAllocatedSize ?? 0
}
return UInt64(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
}
}
}