mirror of
https://github.com/apple/container.git
synced 2026-07-15 05:57:01 +00:00
d242864e9f
- 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.
244 lines
9.9 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|