mirror of
https://github.com/apple/container.git
synced 2026-07-16 06:27:07 +00:00
3ad0914fd2
- Closes #1206. - Closes #1185. - Closes #507. - Addresses existing log messages for #642. - Nondeterministic CI errors are resulting from very slow launch times for the first runtime helper, which causes ContainersService to be locked for longer than our 20 sec timeout. Bumping the timeout to 60 seconds addresses this case for now. - Since many log messages needed to be changed to troubleshoot the issue, updated all log messages to use structured logging, and implemented consistent entry/exit logging for all service operations. - Added logging for ContainerService lock acquisition to help with finding root cause for the slow service startup. - Plumbed the `--debug` flag on both `container system start` and `container system logs` so that the flag is actually useful. - Updated the `install-init.sh` script so that can install in a custom app root directory.
480 lines
16 KiB
Swift
480 lines
16 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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
|
|
/// - Returns: Tuple of (total count, active count, total size, reclaimable size)
|
|
public func calculateDiskUsage(activeReferences: Set<String>) async throws -> (Int, Int, UInt64, 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 totalSize: UInt64 = 0
|
|
var reclaimableSize: UInt64 = 0
|
|
var activeCount = 0
|
|
|
|
for image in images {
|
|
// Calculate size for all platform variants
|
|
let imageSize = try await self.calculateImageSize(image)
|
|
totalSize += imageSize
|
|
|
|
// Check if image is referenced by any container
|
|
let isActive = activeReferences.contains(image.reference)
|
|
if isActive {
|
|
activeCount += 1
|
|
} else {
|
|
reclaimableSize += imageSize
|
|
}
|
|
}
|
|
|
|
return (images.count, activeCount, totalSize, reclaimableSize)
|
|
}
|
|
|
|
/// Calculate total size for an image including all platform variants
|
|
private func calculateImageSize(_ image: Containerization.Image) async throws -> UInt64 {
|
|
var totalSize: UInt64 = 0
|
|
let index = try await image.index()
|
|
|
|
for descriptor in index.manifests {
|
|
// Skip attestation manifests
|
|
if let refType = descriptor.annotations?["vnd.docker.reference.type"],
|
|
refType == "attestation-manifest"
|
|
{
|
|
continue
|
|
}
|
|
|
|
guard descriptor.platform != nil else { continue }
|
|
|
|
// Get snapshot size for this platform
|
|
if let snapshotSize = try? await self.snapshotStore.getSnapshotSize(descriptor: descriptor) {
|
|
totalSize += snapshotSize
|
|
}
|
|
}
|
|
|
|
return totalSize
|
|
}
|
|
}
|
|
|
|
// 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
|
|
)
|
|
}
|
|
}
|