Files
container/Sources/Services/ContainerImagesService/Server/ImagesService.swift
T
J Logan 3ad0914fd2 Fix CI integration test coldstart issues. (#1230)
- 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.
2026-02-19 09:58:09 -08:00

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
)
}
}