chore: import upstream snapshot with attribution
container project - merge build / Invoke build (push) Successful in 1s
container project - merge build / Invoke build (push) Successful in 1s
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user