mirror of
https://github.com/apple/container.git
synced 2026-09-15 20:25:46 +00:00
- Closes #884. ## Type of Change - [ ] Bug fix - [x] New feature - [ ] Breaking change - [ ] Documentation update ## Motivation and Context This PR implements the `container system df` command to display disk usage statistics for images, containers, and volumes, along with their total count, active count, size, and reclaimable space for each resource type. Active resources are determined by container mount references and running state, while reclaimable space is calculated from inactive or stopped resources. Example output: ``` ~/container ❯ container system df TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 4 3 4.42 GB 516.5 MB (11%) Containers 4 2 2.69 GB 1.51 GB (56%) Local Volumes 3 2 208.5 MB 66.2 MB (32%) ``` I'll have some follow-on PRs that will add `-v/--verbose` flag for detailed per-resource information, `--filter` flag for filtering output by resource type, and a `--debug` flag for debug statistics like block usage, clone counts etc. ## Testing - [x] Tested locally - [x] Added/updated tests - [ ] Added/updated docs
263 lines
10 KiB
Swift
263 lines
10 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2025 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 ContainerClient
|
|
import ContainerPersistence
|
|
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 let defaultUnpackStrategy: UnpackStrategy = { image, platform in
|
|
guard platform.os == "linux" else {
|
|
return nil
|
|
}
|
|
var minBlockSize = 512.gib()
|
|
if image.reference == DefaultsStore.get(key: .defaultInitImage) {
|
|
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("Skipping unpack for \(image.reference) for platform \(platform.description). No unpacker configured.")
|
|
continue
|
|
}
|
|
let currentSubTask = await taskManager.startTask()
|
|
if let progressUpdate {
|
|
let _taskUpdateProgress = ProgressTaskCoordinator.handler(for: currentSubTask, from: progressUpdate)
|
|
await _taskUpdateProgress([
|
|
.setSubDescription("for platform \(platform.description)")
|
|
])
|
|
taskUpdateProgress = _taskUpdateProgress
|
|
}
|
|
|
|
let tempDir = try self.tempUnpackDir()
|
|
|
|
let tempSnapshotPath = tempDir.appendingPathComponent(Self.snapshotFileName, isDirectory: false)
|
|
let infoPath = tempDir.appendingPathComponent(Self.snapshotInfoFileName, isDirectory: false)
|
|
do {
|
|
let progress = ContainerizationProgressAdapter.handler(from: taskUpdateProgress)
|
|
let mount = try await unpacker.unpack(image, for: platform, at: tempSnapshotPath, progress: progress)
|
|
let fs = Filesystem.block(
|
|
format: mount.type,
|
|
source: self.snapshotPath(desc).absolutePath(),
|
|
destination: mount.destination,
|
|
options: mount.options
|
|
)
|
|
let snapshotInfo = try JSONEncoder().encode(fs)
|
|
self.fm.createFile(atPath: infoPath.absolutePath(), contents: snapshotInfo)
|
|
} catch {
|
|
try? self.fm.removeItem(at: tempDir)
|
|
throw error
|
|
}
|
|
do {
|
|
try fm.moveItem(at: tempDir, to: snapshotDir)
|
|
} catch let err as NSError {
|
|
guard err.code == NSFileWriteFileExistsError else {
|
|
throw err
|
|
}
|
|
try? self.fm.removeItem(at: tempDir)
|
|
}
|
|
}
|
|
await taskManager.finish()
|
|
}
|
|
|
|
public func delete(for image: Containerization.Image, platform: Platform? = nil) async throws {
|
|
var toDelete: [Descriptor] = []
|
|
if let platform {
|
|
let desc = try await image.descriptor(for: platform)
|
|
toDelete.append(desc)
|
|
} else {
|
|
toDelete = try await image.unpackableDescriptors()
|
|
}
|
|
for desc in toDelete {
|
|
let p = self.snapshotDir(desc)
|
|
guard self.fm.fileExists(atPath: p.absolutePath()) else {
|
|
continue
|
|
}
|
|
try self.fm.removeItem(at: p)
|
|
}
|
|
}
|
|
|
|
public func get(for image: Containerization.Image, platform: Platform) async throws -> Filesystem {
|
|
let desc = try await image.descriptor(for: platform)
|
|
let infoPath = snapshotInfoPath(desc)
|
|
let fsPath = snapshotPath(desc)
|
|
|
|
guard self.fm.fileExists(atPath: infoPath.absolutePath()),
|
|
self.fm.fileExists(atPath: fsPath.absolutePath())
|
|
else {
|
|
throw ContainerizationError(.notFound, message: "image snapshot for \(image.reference) with platform \(platform.description)")
|
|
}
|
|
let decoder = JSONDecoder()
|
|
let data = try Data(contentsOf: infoPath)
|
|
let fs = try decoder.decode(Filesystem.self, from: data)
|
|
return fs
|
|
}
|
|
|
|
public func clean(keepingSnapshotsFor images: [Containerization.Image] = []) async throws -> UInt64 {
|
|
var toKeep: [String] = [Self.ingestDirName]
|
|
for image in images {
|
|
for manifest in try await image.index().manifests {
|
|
guard let platform = manifest.platform else {
|
|
continue
|
|
}
|
|
let desc = try await image.descriptor(for: platform)
|
|
toKeep.append(desc.digest.trimmingDigestPrefix)
|
|
}
|
|
}
|
|
let all = try self.fm.contentsOfDirectory(at: self.path, includingPropertiesForKeys: [.totalFileAllocatedSizeKey]).map {
|
|
$0.lastPathComponent
|
|
}
|
|
let delete = Set(all).subtracting(Set(toKeep))
|
|
var deletedBytes: UInt64 = 0
|
|
for dir in delete {
|
|
let unpackedPath = self.path.appending(path: dir, directoryHint: .isDirectory)
|
|
guard self.fm.fileExists(atPath: unpackedPath.absolutePath()) else {
|
|
continue
|
|
}
|
|
deletedBytes += (try? self.fm.directorySize(dir: unpackedPath)) ?? 0
|
|
try self.fm.removeItem(at: unpackedPath)
|
|
}
|
|
return deletedBytes
|
|
}
|
|
|
|
private func snapshotDir(_ desc: Descriptor) -> URL {
|
|
let p = self.path.appendingPathComponent(desc.digest.trimmingDigestPrefix, isDirectory: true)
|
|
return p
|
|
}
|
|
|
|
private func snapshotPath(_ desc: Descriptor) -> URL {
|
|
let p = self.snapshotDir(desc)
|
|
.appendingPathComponent(Self.snapshotFileName, isDirectory: false)
|
|
return p
|
|
}
|
|
|
|
private func snapshotInfoPath(_ desc: Descriptor) -> URL {
|
|
let p = self.snapshotDir(desc)
|
|
.appendingPathComponent(Self.snapshotInfoFileName, isDirectory: false)
|
|
return p
|
|
}
|
|
|
|
private func tempUnpackDir() throws -> URL {
|
|
let uniqueDirectoryURL = ingestDir.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
try self.fm.createDirectory(at: uniqueDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
|
return uniqueDirectoryURL
|
|
}
|
|
|
|
/// Get the disk size for a specific snapshot descriptor
|
|
public func getSnapshotSize(descriptor: Descriptor) throws -> UInt64 {
|
|
let snapshotPath = self.snapshotDir(descriptor)
|
|
guard self.fm.fileExists(atPath: snapshotPath.path) else {
|
|
return 0
|
|
}
|
|
return try self.fm.directorySize(dir: snapshotPath)
|
|
}
|
|
}
|
|
|
|
extension FileManager {
|
|
fileprivate func directorySize(dir: URL) throws -> UInt64 {
|
|
var size: UInt64 = 0
|
|
let resourceKeys: [URLResourceKey] = [.totalFileAllocatedSizeKey]
|
|
|
|
guard
|
|
let enumerator = self.enumerator(
|
|
at: dir,
|
|
includingPropertiesForKeys: resourceKeys,
|
|
options: [.skipsHiddenFiles]
|
|
)
|
|
else {
|
|
return 0
|
|
}
|
|
|
|
for case let fileURL as URL in enumerator {
|
|
if let resourceValues = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey]),
|
|
let fileSize = resourceValues.totalFileAllocatedSize
|
|
{
|
|
size += UInt64(fileSize)
|
|
}
|
|
}
|
|
return size
|
|
}
|
|
}
|
|
|
|
extension Containerization.Image {
|
|
fileprivate func unpackableDescriptors() async throws -> [Descriptor] {
|
|
let index = try await self.index()
|
|
return index.manifests.filter { desc in
|
|
guard desc.platform != nil else {
|
|
return false
|
|
}
|
|
if let referenceType = desc.annotations?["vnd.docker.reference.type"], referenceType == "attestation-manifest" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
}
|