mirror of
https://github.com/apple/container.git
synced 2026-09-14 19:55:43 +00:00
- 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
265 lines
10 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|