chore: import upstream snapshot with attribution
container project - merge build / Invoke build (push) Successful in 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:06:18 +08:00
commit e84fb4a79e
474 changed files with 68321 additions and 0 deletions
@@ -0,0 +1,103 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
#if os(macOS)
import Foundation
import ContainerXPC
/// Keys for XPC fields.
public enum ImagesServiceXPCKeys: String {
case fd
/// FDs pointing to container logs key.
case logs
/// Path to a file on disk key.
case filePath
/// Images
case imageReference
case imageNewReference
case imageDescription
case imageDescriptions
case filesystem
case ociPlatform
case insecureFlag
case garbageCollect
case maxConcurrentDownloads
case forceLoad
case rejectedMembers
/// ContentStore
case digest
case digests
case directory
case contentPath
case imageSize
case ingestSessionId
/// Disk Usage
case activeImageReferences
case totalCount
case activeCount
case reclaimableSize
}
extension XPCMessage {
public func set(key: ImagesServiceXPCKeys, value: String) {
self.set(key: key.rawValue, value: value)
}
public func set(key: ImagesServiceXPCKeys, value: UInt64) {
self.set(key: key.rawValue, value: value)
}
public func set(key: ImagesServiceXPCKeys, value: Data) {
self.set(key: key.rawValue, value: value)
}
public func set(key: ImagesServiceXPCKeys, value: Bool) {
self.set(key: key.rawValue, value: value)
}
public func set(key: ImagesServiceXPCKeys, value: Int64) {
self.set(key: key.rawValue, value: value)
}
public func string(key: ImagesServiceXPCKeys) -> String? {
self.string(key: key.rawValue)
}
public func data(key: ImagesServiceXPCKeys) -> Data? {
self.data(key: key.rawValue)
}
public func dataNoCopy(key: ImagesServiceXPCKeys) -> Data? {
self.dataNoCopy(key: key.rawValue)
}
public func uint64(key: ImagesServiceXPCKeys) -> UInt64 {
self.uint64(key: key.rawValue)
}
public func int64(key: ImagesServiceXPCKeys) -> Int64 {
self.int64(key: key.rawValue)
}
public func bool(key: ImagesServiceXPCKeys) -> Bool {
self.bool(key: key.rawValue)
}
}
#endif
@@ -0,0 +1,52 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
#if os(macOS)
import Foundation
import ContainerXPC
public enum ImagesServiceXPCRoute: String {
case imageList
case imagePull
case imagePush
case imageTag
case imageBuild
case imageDelete
case imageSave
case imageLoad
case imageCleanupOrphanedBlobs
case imageDiskUsage
case contentGet
case contentDelete
case contentClean
case contentSize
case contentIngestStart
case contentIngestComplete
case contentIngestCancel
case imageUnpack
case snapshotDelete
case snapshotGet
}
extension XPCMessage {
public init(route: ImagesServiceXPCRoute) {
self.init(route: route.rawValue)
}
}
#endif
@@ -0,0 +1,155 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
#if os(macOS)
import Crypto
import ContainerizationError
import Foundation
import ContainerizationOCI
import ContainerXPC
public struct RemoteContentStoreClient: ContentStore {
private static let serviceIdentifier = "com.apple.container.core.container-core-images"
private static let encoder = JSONEncoder()
private static func newClient() -> XPCClient {
XPCClient(service: serviceIdentifier)
}
public init() {}
private func _get(digest: String) async throws -> URL? {
let client = Self.newClient()
let request = XPCMessage(route: .contentGet)
request.set(key: .digest, value: digest)
do {
let response = try await client.send(request)
guard let path = response.string(key: .contentPath) else {
return nil
}
return URL(filePath: path)
} catch let error as ContainerizationError {
if error.code == .notFound {
return nil
}
throw error
}
}
public func get(digest: String) async throws -> Content? {
guard let url = try await self._get(digest: digest) else {
return nil
}
return try LocalContent(path: url)
}
public func get<T: Decodable>(digest: String) async throws -> T? {
guard let content: Content = try await self.get(digest: digest) else {
return nil
}
return try content.decode()
}
public func delete(keeping: [String]) async throws -> ([String], UInt64) {
let client = Self.newClient()
let request = XPCMessage(route: .contentClean)
let d = try Self.encoder.encode(keeping)
request.set(key: .digests, value: d)
let response = try await client.send(request)
guard let data = response.dataNoCopy(key: .digests) else {
throw ContainerizationError.init(.internalError, message: "failed to delete digests")
}
let decoder = JSONDecoder()
let deleted = try decoder.decode([String].self, from: data)
let size = response.uint64(key: .imageSize)
return (deleted, size)
}
@discardableResult
public func delete(digests: [String]) async throws -> ([String], UInt64) {
let client = Self.newClient()
let request = XPCMessage(route: .contentDelete)
let d = try Self.encoder.encode(digests)
request.set(key: .digests, value: d)
let response = try await client.send(request)
guard let data = response.dataNoCopy(key: .digests) else {
throw ContainerizationError.init(.internalError, message: "failed to delete digests")
}
let decoder = JSONDecoder()
let deleted = try decoder.decode([String].self, from: data)
let size = response.uint64(key: .imageSize)
return (deleted, size)
}
@discardableResult
public func ingest(_ body: @Sendable @escaping (URL) async throws -> Void) async throws -> [String] {
let (id, tempPath) = try await self.newIngestSession()
try await body(tempPath)
return try await self.completeIngestSession(id)
}
public func newIngestSession() async throws -> (id: String, ingestDir: URL) {
let client = Self.newClient()
let request = XPCMessage(route: .contentIngestStart)
let response = try await client.send(request)
guard let id = response.string(key: .ingestSessionId) else {
throw ContainerizationError.init(.internalError, message: "failed create new ingest session")
}
guard let dir = response.string(key: .directory) else {
throw ContainerizationError.init(.internalError, message: "failed create new ingest session")
}
return (id, URL(filePath: dir))
}
@discardableResult
public func completeIngestSession(_ id: String) async throws -> [String] {
let client = Self.newClient()
let request = XPCMessage(route: .contentIngestComplete)
request.set(key: .ingestSessionId, value: id)
let response = try await client.send(request)
guard let data = response.dataNoCopy(key: .digests) else {
throw ContainerizationError.init(.internalError, message: "failed to delete digests")
}
let decoder = JSONDecoder()
let ingested = try decoder.decode([String].self, from: data)
return ingested
}
public func cancelIngestSession(_ id: String) async throws {
let client = Self.newClient()
let request = XPCMessage(route: .contentIngestCancel)
request.set(key: .ingestSessionId, value: id)
try await client.send(request)
}
public func totalAllocatedSize() async throws -> UInt64 {
let client = Self.newClient()
let request = XPCMessage(route: .contentSize)
let response = try await client.send(request)
return response.uint64(key: .imageSize)
}
}
#endif
@@ -0,0 +1,122 @@
//===----------------------------------------------------------------------===//
// 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 ContainerImagesServiceClient
import ContainerXPC
import Containerization
import ContainerizationError
import Foundation
import Logging
public struct ContentServiceHarness: Sendable {
private let log: Logging.Logger
private let service: ContentStoreService
public init(service: ContentStoreService, log: Logging.Logger) {
self.log = log
self.service = service
}
@Sendable
public func get(_ message: XPCMessage) async throws -> XPCMessage {
let d = message.string(key: .digest)
guard let d else {
throw ContainerizationError(.invalidArgument, message: "missing digest")
}
guard let path = try await service.get(digest: d) else {
let err = ContainerizationError(.notFound, message: "digest \(d) not found")
let reply = message.reply()
reply.set(error: err)
return reply
}
let reply = message.reply()
reply.set(key: .contentPath, value: path.path(percentEncoded: false))
return reply
}
@Sendable
public func delete(_ message: XPCMessage) async throws -> XPCMessage {
let data = message.dataNoCopy(key: .digests)
guard let data else {
throw ContainerizationError(.invalidArgument, message: "missing digest")
}
let digests = try JSONDecoder().decode([String].self, from: data)
let (deleted, size) = try await self.service.delete(digests: digests)
let d = try JSONEncoder().encode(deleted)
let reply = message.reply()
reply.set(key: .digests, value: d)
reply.set(key: .imageSize, value: size)
return reply
}
@Sendable
public func clean(_ message: XPCMessage) async throws -> XPCMessage {
let data = message.dataNoCopy(key: .digests)
guard let data else {
throw ContainerizationError(.invalidArgument, message: "missing digest")
}
let digests = try JSONDecoder().decode([String].self, from: data)
let (deleted, size) = try await self.service.delete(keeping: digests)
let d = try JSONEncoder().encode(deleted)
let reply = message.reply()
reply.set(key: .digests, value: d)
reply.set(key: .imageSize, value: size)
return reply
}
@Sendable
public func newIngestSession(_ message: XPCMessage) async throws -> XPCMessage {
let session = try await self.service.newIngestSession()
let id = session.id
let dir = session.ingestDir
let reply = message.reply()
reply.set(key: .directory, value: dir.path(percentEncoded: false))
reply.set(key: .ingestSessionId, value: id)
return reply
}
@Sendable
public func cancelIngestSession(_ message: XPCMessage) async throws -> XPCMessage {
let id = message.string(key: .ingestSessionId)
guard let id else {
throw ContainerizationError(.invalidArgument, message: "missing ingest session id")
}
try await self.service.cancelIngestSession(id)
let reply = message.reply()
return reply
}
@Sendable
public func completeIngestSession(_ message: XPCMessage) async throws -> XPCMessage {
let id = message.string(key: .ingestSessionId)
guard let id else {
throw ContainerizationError(.invalidArgument, message: "missing ingest session id")
}
let ingested = try await self.service.completeIngestSession(id)
let d = try JSONEncoder().encode(ingested)
let reply = message.reply()
reply.set(key: .digests, value: d)
return reply
}
@Sendable
public func totalSize(_ message: XPCMessage) async throws -> XPCMessage {
let size = try await self.service.totalAllocatedSize()
let reply = message.reply()
reply.set(key: .imageSize, value: size)
return reply
}
}
@@ -0,0 +1,164 @@
//===----------------------------------------------------------------------===//
// 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 ContainerImagesServiceClient
import Containerization
import ContainerizationOCI
import Foundation
import Logging
public actor ContentStoreService {
private let log: Logger
private let contentStore: LocalContentStore
private let root: URL
public init(root: URL, log: Logger) throws {
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
self.root = root.appendingPathComponent("content")
self.contentStore = try LocalContentStore(path: self.root)
self.log = log
}
public func get(digest: String) async throws -> URL? {
self.log.trace(
"ContentStoreService: enter",
metadata: [
"func": "\(#function)",
"digest": "\(digest)",
]
)
defer {
self.log.trace(
"ContentStoreService: exit",
metadata: [
"func": "\(#function)",
"digest": "\(digest)",
]
)
}
return try await self.contentStore.get(digest: digest)?.path
}
@discardableResult
public func delete(digests: [String]) async throws -> ([String], UInt64) {
self.log.trace(
"ContentStoreService: enter",
metadata: [
"func": "\(#function)",
"digests": "\(digests)",
]
)
defer {
self.log.trace(
"ContentStoreService: exit",
metadata: [
"func": "\(#function)",
"digests": "\(digests)",
]
)
}
return try await self.contentStore.delete(digests: digests)
}
@discardableResult
public func delete(keeping: [String]) async throws -> ([String], UInt64) {
self.log.debug(
"ContentStoreService: enter",
metadata: [
"func": "\(#function)",
"keeping": "\(keeping)",
]
)
defer {
self.log.debug(
"ContentStoreService: exit",
metadata: [
"func": "\(#function)",
"keeping": "\(keeping)",
]
)
}
return try await self.contentStore.delete(keeping: keeping)
}
public func newIngestSession() async throws -> (id: String, ingestDir: URL) {
self.log.debug(
"ContentStoreService: enter",
metadata: [
"func": "\(#function)"
]
)
defer {
self.log.debug(
"ContentStoreService: exit",
metadata: [
"func": "\(#function)"
]
)
}
return try await self.contentStore.newIngestSession()
}
public func completeIngestSession(_ id: String) async throws -> [String] {
self.log.debug(
"ContentStoreService: enter",
metadata: [
"func": "\(#function)",
"id": "\(id)",
]
)
defer {
self.log.debug(
"ContentStoreService: exit",
metadata: [
"func": "\(#function)",
"id": "\(id)",
]
)
}
return try await self.contentStore.completeIngestSession(id)
}
public func cancelIngestSession(_ id: String) async throws {
self.log.debug(
"ContentStoreService: enter",
metadata: [
"func": "\(#function)",
"id": "\(id)",
]
)
defer {
self.log.debug(
"ContentStoreService: exit",
metadata: [
"func": "\(#function)",
"id": "\(id)",
]
)
}
return try await self.contentStore.cancelIngestSession(id)
}
/// Total bytes allocated on disk for the content store.
public func totalAllocatedSize() async throws -> UInt64 {
try await self.contentStore.totalAllocatedSize()
}
}
@@ -0,0 +1,475 @@
//===----------------------------------------------------------------------===//
// Copyright © 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 ContainerImagesServiceClient
import ContainerResource
import Containerization
import ContainerizationArchive
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import Foundation
import Logging
import TerminalProgress
public actor ImagesService {
private let log: Logger
private let contentStore: ContentStore
private let imageStore: ImageStore
private let snapshotStore: SnapshotStore
public init(
contentStore: ContentStore,
imageStore: ImageStore,
snapshotStore: SnapshotStore,
log: Logger
) throws {
self.contentStore = contentStore
self.imageStore = imageStore
self.snapshotStore = snapshotStore
self.log = log
}
private func _list() async throws -> [Containerization.Image] {
try await imageStore.list()
}
private func _get(_ reference: String) async throws -> Containerization.Image {
try await imageStore.get(reference: reference)
}
private func _get(_ description: ImageDescription) async throws -> Containerization.Image {
let exists = try await self._get(description.reference)
guard exists.descriptor == description.descriptor else {
throw ContainerizationError(.invalidState, message: "descriptor mismatch: expected \(description.descriptor), got \(exists.descriptor)")
}
return exists
}
public func list() async throws -> [ImageDescription] {
self.log.debug(
"ImagesService: enter",
metadata: [
"func": "\(#function)"
]
)
defer {
self.log.debug(
"ImagesService: exit",
metadata: [
"func": "\(#function)"
]
)
}
return try await imageStore.list().map { $0.description.fromCZ }
}
public func pull(reference: String, platform: Platform?, insecure: Bool, progressUpdate: ProgressUpdateHandler?, maxConcurrentDownloads: Int = 3) async throws
-> ImageDescription
{
self.log.debug(
"ImagesService: enter",
metadata: [
"func": "\(#function)",
"ref": "\(reference)",
"platform": "\(String(describing: platform))",
"insecure": "\(insecure)",
"maxConcurrentDownloads": "\(maxConcurrentDownloads)",
]
)
defer {
self.log.debug(
"ImagesService: exit",
metadata: [
"func": "\(#function)",
"ref": "\(reference)",
"platform": "\(String(describing: platform))",
]
)
}
let img = try await Self.withAuthentication(ref: reference) { auth in
try await self.imageStore.pull(
reference: reference, platform: platform, insecure: insecure, auth: auth, progress: ContainerizationProgressAdapter.handler(from: progressUpdate),
maxConcurrentDownloads: maxConcurrentDownloads)
}
guard let img else {
throw ContainerizationError(.internalError, message: "failed to pull image \(reference)")
}
return img.description.fromCZ
}
public func push(reference: String, platform: Platform?, insecure: Bool, progressUpdate: ProgressUpdateHandler?) async throws {
self.log.debug(
"ImagesService: enter",
metadata: [
"func": "\(#function)",
"ref": "\(reference)",
"platform": "\(String(describing: platform))",
"insecure": "\(insecure)",
]
)
defer {
self.log.debug(
"ImagesService: exit",
metadata: [
"func": "\(#function)",
"ref": "\(reference)",
"platform": "\(String(describing: platform))",
]
)
}
try await Self.withAuthentication(ref: reference) { auth in
try await self.imageStore.push(
reference: reference, platform: platform, insecure: insecure, auth: auth, progress: ContainerizationProgressAdapter.handler(from: progressUpdate))
}
}
public func tag(old: String, new: String) async throws -> ImageDescription {
self.log.debug(
"ImagesService: enter",
metadata: [
"func": "\(#function)",
"old": "\(old)",
"new": "\(new)",
]
)
defer {
self.log.debug(
"ImagesService: exit",
metadata: [
"func": "\(#function)",
"old": "\(old)",
"new": "\(new)",
]
)
}
let img = try await self.imageStore.tag(existing: old, new: new)
return img.description.fromCZ
}
public func delete(reference: String, garbageCollect: Bool) async throws {
self.log.debug(
"ImagesService: enter",
metadata: [
"func": "\(#function)",
"ref": "\(reference)",
]
)
defer {
self.log.debug(
"ImagesService: exit",
metadata: [
"func": "\(#function)",
"ref": "\(reference)",
]
)
}
try await self.imageStore.delete(reference: reference, performCleanup: garbageCollect)
}
public func save(references: [String], out: URL, platform: Platform?) async throws {
self.log.debug(
"ImagesService: enter",
metadata: [
"func": "\(#function)",
"references": "\(references)",
]
)
defer {
self.log.debug(
"ImagesService: exit",
metadata: [
"func": "\(#function)",
"references": "\(references)",
]
)
}
let tempDir = FileManager.default.uniqueTemporaryDirectory()
defer {
try? FileManager.default.removeItem(at: tempDir)
}
try await self.imageStore.save(references: references, out: tempDir, platform: platform)
let writer = try ArchiveWriter(format: .pax, filter: .none, file: out)
try writer.archiveDirectory(tempDir)
try writer.finishEncoding()
}
public func load(from tarFile: URL, force: Bool) async throws -> ([ImageDescription], [String]) {
let archivePathname = tarFile.absolutePath()
self.log.debug(
"ImagesService: enter",
metadata: [
"func": "\(#function)",
"archivePath": "\(archivePathname)",
]
)
defer {
self.log.debug(
"ImagesService: exit",
metadata: [
"func": "\(#function)",
"archivePath": "\(archivePathname)",
]
)
}
let reader = try ArchiveReader(file: tarFile)
let tempDir = FileManager.default.uniqueTemporaryDirectory()
defer {
try? FileManager.default.removeItem(at: tempDir)
}
let rejectedMembers = try reader.extractContents(to: tempDir)
guard rejectedMembers.isEmpty || force else {
throw ContainerizationError(.invalidArgument, message: "cannot load tar image with rejected paths: \(rejectedMembers)")
}
let loaded = try await self.imageStore.load(from: tempDir)
var images: [ImageDescription] = []
for image in loaded {
images.append(image.description.fromCZ)
}
return (images, rejectedMembers)
}
public func cleanUpOrphanedBlobs() async throws -> ([String], UInt64) {
self.log.debug(
"ImagesService: enter",
metadata: [
"func": "\(#function)"
]
)
defer {
self.log.debug(
"ImagesService: exit",
metadata: [
"func": "\(#function)"
]
)
}
let images = try await self._list()
let freedSnapshotBytes = try await self.snapshotStore.clean(keepingSnapshotsFor: images)
let (deleted, freedContentBytes) = try await self.imageStore.cleanUpOrphanedBlobs()
return (deleted, freedContentBytes + freedSnapshotBytes)
}
/// Calculate disk usage for images
/// - Parameter activeReferences: Set of image references currently in use by containers
public func calculateDiskUsage(activeReferences: Set<String>) async throws -> (totalCount: Int, activeCount: Int, totalSize: UInt64, reclaimableSize: UInt64) {
self.log.debug(
"ImagesService: enter",
metadata: [
"func": "\(#function)",
"references": "\(activeReferences)",
]
)
defer {
self.log.debug(
"ImagesService: exit",
metadata: [
"func": "\(#function)",
"references": "\(activeReferences)",
]
)
}
let images = try await self._list()
var activeCount = 0
var activeContentSizes: [String: UInt64] = [:]
var activeSnapshotSizes: [String: UInt64] = [:]
var processedDigests = Set<String>()
for image in images {
guard activeReferences.contains(image.reference) else { continue }
activeCount += 1
let imageDigest = image.digest.trimmingDigestPrefix
guard processedDigests.insert(imageDigest).inserted else { continue }
for digest in try await image.referencedDigests() where activeContentSizes[digest] == nil {
guard let content: Content = try await self.contentStore.get(digest: digest) else { continue }
activeContentSizes[digest] = try self.contentDiskSize(content)
}
for (digest, size) in try await self.snapshotStore.getSnapshotSizes(for: image) {
activeSnapshotSizes[digest] = size
}
}
let snapshotDiskSize = await self.snapshotStore.totalAllocatedSize()
let contentDiskTotal = try await self.contentStore.totalAllocatedSize()
let totalOnDisk = contentDiskTotal + snapshotDiskSize
let activeSize = activeContentSizes.values.reduce(0, +) + activeSnapshotSizes.values.reduce(0, +)
let reclaimable = totalOnDisk > activeSize ? totalOnDisk - activeSize : 0
return (images.count, activeCount, totalOnDisk, reclaimable)
}
private func contentDiskSize(_ content: Content) throws -> UInt64 {
let values = try? content.path.resourceValues(forKeys: [.totalFileAllocatedSizeKey])
if let allocatedSize = values?.totalFileAllocatedSize {
return UInt64(allocatedSize)
}
return try content.size()
}
}
// MARK: Image Snapshot Methods
extension ImagesService {
public func unpack(description: ImageDescription, platform: Platform?, progressUpdate: ProgressUpdateHandler?) async throws {
self.log.debug(
"ImagesService: enter",
metadata: [
"func": "\(#function)",
"description": "\(description)",
"platform": "\(String(describing: platform))",
]
)
defer {
self.log.debug(
"ImagesService: exit",
metadata: [
"func": "\(#function)",
"description": "\(description)",
"platform": "\(String(describing: platform))",
]
)
}
let img = try await self._get(description)
try await self.snapshotStore.unpack(image: img, platform: platform, progressUpdate: progressUpdate)
}
public func deleteImageSnapshot(description: ImageDescription, platform: Platform?) async throws {
self.log.debug(
"ImagesService: enter",
metadata: [
"func": "\(#function)",
"description": "\(description)",
"platform": "\(String(describing: platform))",
]
)
defer {
self.log.debug(
"ImagesService: exit",
metadata: [
"func": "\(#function)",
"description": "\(description)",
"platform": "\(String(describing: platform))",
]
)
}
let img = try await self._get(description)
try await self.snapshotStore.delete(for: img, platform: platform)
}
public func getImageSnapshot(description: ImageDescription, platform: Platform) async throws -> Filesystem {
self.log.debug(
"ImagesService: enter",
metadata: [
"func": "\(#function)",
"description": "\(description)",
"platform": "\(String(describing: platform))",
]
)
defer {
self.log.debug(
"ImagesService: exit",
metadata: [
"func": "\(#function)",
"description": "\(description)",
"platform": "\(String(describing: platform))",
]
)
}
let img = try await self._get(description)
return try await self.snapshotStore.get(for: img, platform: platform)
}
}
// MARK: Static Methods
extension ImagesService {
private static func withAuthentication<T>(
ref: String, _ body: @Sendable @escaping (_ auth: Authentication?) async throws -> T?
) async throws -> T? {
var authentication: Authentication?
let ref = try Reference.parse(ref)
guard let host = ref.resolvedDomain else {
throw ContainerizationError(.invalidArgument, message: "no host specified in image reference: \(ref)")
}
authentication = Self.authenticationFromEnv(host: host)
if let authentication {
return try await body(authentication)
}
let keychain = KeychainHelper(securityDomain: Constants.keychainID)
do {
authentication = try keychain.lookup(hostname: host)
} catch let err as KeychainHelper.Error {
guard case .keyNotFound = err else {
throw ContainerizationError(.internalError, message: "error querying keychain for \(host)", cause: err)
}
}
do {
return try await body(authentication)
} catch let err as RegistryClient.Error {
guard case .invalidStatus(_, let status, _) = err else {
throw err
}
guard status == .unauthorized || status == .forbidden else {
throw err
}
guard authentication != nil else {
throw ContainerizationError(.internalError, message: "\(String(describing: err)), no credentials found for host \(host)")
}
throw err
}
}
private static func authenticationFromEnv(host: String) -> Authentication? {
let env = ProcessInfo.processInfo.environment
guard env["CONTAINER_REGISTRY_HOST"] == host else {
return nil
}
guard let user = env["CONTAINER_REGISTRY_USER"], let password = env["CONTAINER_REGISTRY_TOKEN"] else {
return nil
}
return BasicAuthentication(username: user, password: password)
}
}
extension ImageDescription {
public var toCZ: Containerization.Image.Description {
.init(reference: self.reference, descriptor: self.descriptor)
}
}
extension Containerization.Image.Description {
public var fromCZ: ImageDescription {
.init(
reference: self.reference,
descriptor: self.descriptor
)
}
}
@@ -0,0 +1,285 @@
//===----------------------------------------------------------------------===//
// 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 ContainerImagesServiceClient
import ContainerResource
import ContainerXPC
import Containerization
import ContainerizationError
import ContainerizationOCI
import Foundation
import Logging
public struct ImagesServiceHarness: Sendable {
let log: Logging.Logger
let service: ImagesService
public init(service: ImagesService, log: Logging.Logger) {
self.log = log
self.service = service
}
@Sendable
public func pull(_ message: XPCMessage) async throws -> XPCMessage {
let ref = message.string(key: .imageReference)
guard let ref else {
throw ContainerizationError(
.invalidArgument,
message: "missing image reference"
)
}
let platformData = message.dataNoCopy(key: .ociPlatform)
var platform: Platform? = nil
if let platformData {
platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData)
}
let insecure = message.bool(key: .insecureFlag)
let maxConcurrentDownloads = message.int64(key: .maxConcurrentDownloads)
let progressUpdateService = ProgressUpdateService(message: message)
let imageDescription = try await service.pull(
reference: ref, platform: platform, insecure: insecure, progressUpdate: progressUpdateService?.handler, maxConcurrentDownloads: Int(maxConcurrentDownloads))
let imageData = try JSONEncoder().encode(imageDescription)
let reply = message.reply()
reply.set(key: .imageDescription, value: imageData)
return reply
}
@Sendable
public func push(_ message: XPCMessage) async throws -> XPCMessage {
let ref = message.string(key: .imageReference)
guard let ref else {
throw ContainerizationError(
.invalidArgument,
message: "missing image reference"
)
}
let platformData = message.dataNoCopy(key: .ociPlatform)
var platform: Platform? = nil
if let platformData {
platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData)
}
let insecure = message.bool(key: .insecureFlag)
let progressUpdateService = ProgressUpdateService(message: message)
try await service.push(reference: ref, platform: platform, insecure: insecure, progressUpdate: progressUpdateService?.handler)
let reply = message.reply()
return reply
}
@Sendable
public func tag(_ message: XPCMessage) async throws -> XPCMessage {
let old = message.string(key: .imageReference)
guard let old else {
throw ContainerizationError(
.invalidArgument,
message: "missing image reference"
)
}
let new = message.string(key: .imageNewReference)
guard let new else {
throw ContainerizationError(
.invalidArgument,
message: "missing new image reference"
)
}
let newDescription = try await service.tag(old: old, new: new)
let descData = try JSONEncoder().encode(newDescription)
let reply = message.reply()
reply.set(key: .imageDescription, value: descData)
return reply
}
@Sendable
public func list(_ message: XPCMessage) async throws -> XPCMessage {
let images = try await service.list()
let imageData = try JSONEncoder().encode(images)
let reply = message.reply()
reply.set(key: .imageDescriptions, value: imageData)
return reply
}
@Sendable
public func delete(_ message: XPCMessage) async throws -> XPCMessage {
let ref = message.string(key: .imageReference)
guard let ref else {
throw ContainerizationError(
.invalidArgument,
message: "missing image reference"
)
}
let garbageCollect = message.bool(key: .garbageCollect)
try await self.service.delete(reference: ref, garbageCollect: garbageCollect)
let reply = message.reply()
return reply
}
@Sendable
public func save(_ message: XPCMessage) async throws -> XPCMessage {
let data = message.dataNoCopy(key: .imageDescriptions)
guard let data else {
throw ContainerizationError(
.invalidArgument,
message: "missing image description"
)
}
let imageDescriptions = try JSONDecoder().decode([ImageDescription].self, from: data)
let references = imageDescriptions.map { $0.reference }
let platformData = message.dataNoCopy(key: .ociPlatform)
var platform: Platform? = nil
if let platformData {
platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData)
}
let out = message.string(key: .filePath)
guard let out else {
throw ContainerizationError(
.invalidArgument,
message: "missing output file path"
)
}
try await service.save(references: references, out: URL(filePath: out), platform: platform)
let reply = message.reply()
return reply
}
@Sendable
public func load(_ message: XPCMessage) async throws -> XPCMessage {
let input = message.string(key: .filePath)
let force = message.bool(key: .forceLoad)
guard let input else {
throw ContainerizationError(
.invalidArgument,
message: "missing input file path"
)
}
let (images, rejectedMembers) = try await service.load(
from: URL(filePath: input),
force: force
)
let reply = message.reply()
let imagesData = try JSONEncoder().encode(images)
reply.set(key: .imageDescriptions, value: imagesData)
let rejectedData = try JSONEncoder().encode(rejectedMembers)
reply.set(key: .rejectedMembers, value: rejectedData)
return reply
}
@Sendable
public func cleanUpOrphanedBlobs(_ message: XPCMessage) async throws -> XPCMessage {
let (deleted, size) = try await service.cleanUpOrphanedBlobs()
let reply = message.reply()
let data = try JSONEncoder().encode(deleted)
reply.set(key: .digests, value: data)
reply.set(key: .imageSize, value: size)
return reply
}
@Sendable
public func calculateDiskUsage(_ message: XPCMessage) async throws -> XPCMessage {
// Decode active image references from the message
let activeRefsData = message.dataNoCopy(key: .activeImageReferences)
let activeRefs: Set<String>
if let activeRefsData {
activeRefs = try JSONDecoder().decode(Set<String>.self, from: activeRefsData)
} else {
activeRefs = Set<String>()
}
let (total, active, size, reclaimable) = try await service.calculateDiskUsage(activeReferences: activeRefs)
let reply = message.reply()
reply.set(key: .totalCount, value: Int64(total))
reply.set(key: .activeCount, value: Int64(active))
reply.set(key: .imageSize, value: size)
reply.set(key: .reclaimableSize, value: reclaimable)
return reply
}
}
// MARK: Image Snapshot Methods
extension ImagesServiceHarness {
@Sendable
public func unpack(_ message: XPCMessage) async throws -> XPCMessage {
let descriptionData = message.dataNoCopy(key: .imageDescription)
guard let descriptionData else {
throw ContainerizationError(
.invalidArgument,
message: "missing Image description"
)
}
let description = try JSONDecoder().decode(ImageDescription.self, from: descriptionData)
var platform: Platform?
if let platformData = message.dataNoCopy(key: .ociPlatform) {
platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData)
}
let progressUpdateService = ProgressUpdateService(message: message)
try await self.service.unpack(description: description, platform: platform, progressUpdate: progressUpdateService?.handler)
let reply = message.reply()
return reply
}
@Sendable
public func deleteSnapshot(_ message: XPCMessage) async throws -> XPCMessage {
let descriptionData = message.dataNoCopy(key: .imageDescription)
guard let descriptionData else {
throw ContainerizationError(
.invalidArgument,
message: "missing image description"
)
}
let description = try JSONDecoder().decode(ImageDescription.self, from: descriptionData)
let platformData = message.dataNoCopy(key: .ociPlatform)
var platform: Platform?
if let platformData {
platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData)
}
try await self.service.deleteImageSnapshot(description: description, platform: platform)
let reply = message.reply()
return reply
}
@Sendable
public func getSnapshot(_ message: XPCMessage) async throws -> XPCMessage {
let descriptionData = message.dataNoCopy(key: .imageDescription)
guard let descriptionData else {
throw ContainerizationError(
.invalidArgument,
message: "missing image description"
)
}
let description = try JSONDecoder().decode(ImageDescription.self, from: descriptionData)
let platformData = message.dataNoCopy(key: .ociPlatform)
guard let platformData else {
throw ContainerizationError(
.invalidArgument,
message: "missing OCI platform"
)
}
let platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData)
let fs = try await self.service.getImageSnapshot(description: description, platform: platform)
let fsData = try JSONEncoder().encode(fs)
let reply = message.reply()
reply.set(key: .filesystem, value: fsData)
return reply
}
}
@@ -0,0 +1,254 @@
//===----------------------------------------------------------------------===//
// 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 += self.fm.allocatedSize(of: unpackedPath)
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) -> UInt64 {
let snapshotPath = self.snapshotDir(descriptor)
guard self.fm.fileExists(atPath: snapshotPath.path) else {
return 0
}
return self.fm.allocatedSize(of: snapshotPath)
}
/// Returns (trimmed digest, size) pairs for every unpackable snapshot owned by the image.
public func getSnapshotSizes(for image: Containerization.Image) async throws -> [(digest: String, size: UInt64)] {
var results: [(digest: String, size: UInt64)] = []
for descriptor in try await image.unpackableDescriptors() {
let size = self.getSnapshotSize(descriptor: descriptor)
guard size > 0 else { continue }
results.append((descriptor.digest.trimmingDigestPrefix, size))
}
return results
}
/// Total allocated bytes across all snapshot storage (including orphans).
public func totalAllocatedSize() -> UInt64 {
self.fm.allocatedSize(of: self.path)
}
}
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
}
}
}