diff --git a/Sources/tart/Commands/Push.swift b/Sources/tart/Commands/Push.swift index ff4bf9e..79ede79 100644 --- a/Sources/tart/Commands/Push.swift +++ b/Sources/tart/Commands/Push.swift @@ -26,6 +26,11 @@ struct Push: AsyncParsableCommand { """)) var chunkSize: Int = 0 + + @Option(name: [.customLong("label")], help: ArgumentHelp("additional metadata to attach to the OCI image configuration in key=value format", + discussion: "Can be specified multiple times to attach multiple labels.")) + var labels: [String] = [] + @Option(help: .hidden) var diskFormat: String = "v2" @@ -81,7 +86,8 @@ struct Push: AsyncParsableCommand { references: references, chunkSizeMb: chunkSize, diskFormat: diskFormat, - concurrency: concurrency + concurrency: concurrency, + labels: parseLabels() ) // Populate the local cache (if requested) if populateCache { @@ -115,6 +121,28 @@ struct Push: AsyncParsableCommand { return RemoteName(host: registry.host!, namespace: registry.namespace, reference: Reference(digest: digest)) } + + // Helper method to convert labels array to dictionary + func parseLabels() -> [String: String] { + var result = [String: String]() + + for label in labels { + let parts = label.trimmingCharacters(in: .whitespaces).split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + + let key = parts.count > 0 ? String(parts[0]) : "" + let value = parts.count > 1 ? String(parts[1]) : "" + + // It sometimes makes sense to provide an empty value, + // but not an empty key + if key.isEmpty { + continue + } + + result[key] = value + } + + return result + } } extension Collection where Element == RemoteName { diff --git a/Sources/tart/OCI/Manifest.swift b/Sources/tart/OCI/Manifest.swift index 53769b9..2d04f0c 100644 --- a/Sources/tart/OCI/Manifest.swift +++ b/Sources/tart/OCI/Manifest.swift @@ -66,6 +66,11 @@ struct OCIManifest: Codable, Equatable { struct OCIConfig: Codable { var architecture: Architecture = .arm64 var os: OS = .darwin + var config: ConfigContainer? + + struct ConfigContainer: Codable { + var Labels: [String: String]? + } func toJSON() throws -> Data { try Config.jsonEncoder().encode(self) diff --git a/Sources/tart/VMDirectory+OCI.swift b/Sources/tart/VMDirectory+OCI.swift index 98d1d11..71ef930 100644 --- a/Sources/tart/VMDirectory+OCI.swift +++ b/Sources/tart/VMDirectory+OCI.swift @@ -87,7 +87,7 @@ extension VMDirectory { try manifest.toJSON().write(to: manifestURL) } - func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String, concurrency: UInt) async throws -> RemoteName { + func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String, concurrency: UInt, labels: [String: String] = [:]) async throws -> RemoteName { var layers = Array() // Read VM's config and push it as blob @@ -121,7 +121,8 @@ extension VMDirectory { layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest)) // Craft a stub OCI config for Docker Hub compatibility - let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON() + let ociConfigContainer = OCIConfig.ConfigContainer(Labels: labels) + let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os, config: ociConfigContainer).toJSON() let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb) let manifest = OCIManifest( config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),