Replace scattered defaults subcommands with system property. (#604)

Common subcommands for all defaults.

- Closes #384.
- Replaces `registry default` and `system dns default` subcommands with
`system property`.
- Users can use `system property ls` to see details about each supported
default value.
- `system property set` implements reasonable validation for all
properties.
- NOTE: Probing of the registry for `registry default set` was removed,
which means users will find out about a botched setting when pulling or
pushing.
- Updates docs.

## Type of Change
- [ ] Bug fix
- [x] New feature  
- [x] Breaking change
- [x] Documentation update

## Motivation and Context
See #384.

## Testing
- [x] Tested locally
- [x] Added/updated tests
- [x] Added/updated docs
This commit is contained in:
J Logan
2025-09-16 13:37:55 -07:00
committed by GitHub
parent 386fd87b5d
commit 449f1d23df
19 changed files with 634 additions and 386 deletions
@@ -24,7 +24,6 @@ extension Application {
subcommands: [
Login.self,
Logout.self,
RegistryDefault.self,
],
aliases: ["r"]
)
@@ -1,99 +0,0 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ArgumentParser
import ContainerClient
import ContainerPersistence
import ContainerizationError
import ContainerizationOCI
import Foundation
extension Application {
struct RegistryDefault: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "default",
abstract: "Manage the default image registry",
subcommands: [
DefaultSetCommand.self,
DefaultUnsetCommand.self,
DefaultInspectCommand.self,
]
)
}
struct DefaultSetCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "set",
abstract: "Set the default registry"
)
@OptionGroup
var global: Flags.Global
@OptionGroup
var registry: Flags.Registry
@Argument
var host: String
func run() async throws {
let scheme = try RequestScheme(registry.scheme).schemeFor(host: host)
let _url = "\(scheme)://\(host)"
guard let url = URL(string: _url), let domain = url.host() else {
throw ContainerizationError(.invalidArgument, message: "Cannot convert \(_url) to URL")
}
let resolvedDomain = Reference.resolveDomain(domain: domain)
let client = RegistryClient(host: resolvedDomain, scheme: scheme.rawValue, port: url.port)
do {
try await client.ping()
} catch let err as RegistryClient.Error {
switch err {
case .invalidStatus(url: _, .unauthorized, _), .invalidStatus(url: _, .forbidden, _):
break
default:
throw err
}
}
DefaultsStore.set(value: host, key: .defaultRegistryDomain)
print("Set default registry to \(host)")
}
}
struct DefaultUnsetCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "unset",
abstract: "Unset the default registry",
aliases: ["clear"]
)
func run() async throws {
DefaultsStore.unset(key: .defaultRegistryDomain)
print("Unset the default registry domain")
}
}
struct DefaultInspectCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "inspect",
abstract: "Display the default registry domain"
)
func run() async throws {
print(DefaultsStore.get(key: .defaultRegistryDomain))
}
}
}
-72
View File
@@ -1,72 +0,0 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ArgumentParser
import ContainerPersistence
extension Application {
struct DNSDefault: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "default",
abstract: "Set or unset the default local DNS domain",
subcommands: [
DefaultSetCommand.self,
DefaultUnsetCommand.self,
DefaultInspectCommand.self,
]
)
struct DefaultSetCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "set",
abstract: "Set the default local DNS domain"
)
@Argument(help: "the default `--domain-name` to use for the `create` or `run` command")
var domainName: String
func run() async throws {
DefaultsStore.set(value: domainName, key: .defaultDNSDomain)
print(domainName)
}
}
struct DefaultUnsetCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "unset",
abstract: "Unset the default local DNS domain",
aliases: ["clear"]
)
func run() async throws {
DefaultsStore.unset(key: .defaultDNSDomain)
print("Unset the default local DNS domain")
}
}
struct DefaultInspectCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "inspect",
abstract: "Display the default local DNS domain"
)
func run() async throws {
print(DefaultsStore.getOptional(key: .defaultDNSDomain) ?? "")
}
}
}
}
@@ -0,0 +1,44 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ArgumentParser
import ContainerClient
import ContainerPersistence
import ContainerizationError
import Foundation
extension Application {
struct PropertyClear: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "clear",
abstract: "Clear a property value"
)
@OptionGroup
var global: Flags.Global
@Argument(help: "the property ID")
var id: String
func run() async throws {
guard let key = DefaultsStore.Keys(rawValue: id) else {
throw ContainerizationError(.invalidArgument, message: "invalid property ID: \(id)")
}
DefaultsStore.unset(key: key)
}
}
}
@@ -0,0 +1,51 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ArgumentParser
import ContainerClient
import ContainerPersistence
import ContainerizationError
import Foundation
extension Application {
struct PropertyGet: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "get",
abstract: "Retrieve a property value"
)
@OptionGroup
var global: Flags.Global
@Argument(help: "the property ID")
var id: String
func run() async throws {
let value = DefaultsStore.allValues()
.filter { id == $0.id }
.first
guard let value else {
throw ContainerizationError(.invalidArgument, message: "property ID \(id) not found")
}
guard let val = value.value?.description else {
return
}
print(val)
}
}
}
@@ -0,0 +1,93 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ArgumentParser
import ContainerClient
import ContainerPersistence
import Foundation
extension Application {
struct PropertyList: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List system properties",
aliases: ["ls"]
)
@Flag(name: .shortAndLong, help: "Only output the network name")
var quiet = false
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@OptionGroup
var global: Flags.Global
func run() async throws {
let vals = DefaultsStore.allValues()
try printValues(vals, format: format)
}
private func createHeader() -> [[String]] {
[["ID", "TYPE", "VALUE", "DESCRIPTION"]]
}
private func printValues(_ vals: [DefaultsStoreValue], format: ListFormat) throws {
if format == .json {
let data = try JSONEncoder().encode(vals)
print(String(data: data, encoding: .utf8)!)
return
}
if self.quiet {
vals.forEach {
print($0.id)
}
return
}
var rows = createHeader()
for property in vals {
rows.append(property.asRow)
}
let formatter = TableOutput(rows: rows)
print(formatter.format())
}
}
}
extension DefaultsStoreValue {
var asRow: [String] {
[id, String(describing: type), value?.description.elided(to: 40) ?? "*undefined*", description]
}
}
extension String {
func elided(to maxCount: Int) -> String {
let ellipsis = "..."
guard self.count > maxCount else {
return self
}
if maxCount < ellipsis.count {
return ellipsis
}
let prefixCount = maxCount - ellipsis.count
return self.prefix(prefixCount) + ellipsis
}
}
@@ -0,0 +1,78 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ArgumentParser
import ContainerClient
import ContainerPersistence
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import Foundation
extension Application {
struct PropertySet: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "set",
abstract: "Set a property value"
)
@OptionGroup
var global: Flags.Global
@Argument(help: "the property ID")
var id: String
@Argument(help: "the property value")
var value: String
func run() async throws {
guard let key = DefaultsStore.Keys(rawValue: id) else {
throw ContainerizationError(.invalidArgument, message: "invalid property ID: \(id)")
}
switch key {
case .buildRosetta:
guard let boolValue = Parser.parseBool(string: value) else {
throw ContainerizationError(.invalidArgument, message: "invalid boolean value: \(value)")
}
DefaultsStore.setBool(value: boolValue, key: key)
case .defaultDNSDomain, .defaultRegistryDomain:
guard Parser.isValidDomainName(value) else {
throw ContainerizationError(.invalidArgument, message: "invalid domain name: \(value)")
}
DefaultsStore.set(value: value, key: key)
case .defaultBuilderImage, .defaultInitImage:
guard (try? Reference.parse(value)) != nil else {
throw ContainerizationError(.invalidArgument, message: "invalid image reference: \(value)")
}
DefaultsStore.set(value: value, key: key)
case .defaultKernelBinaryPath:
DefaultsStore.set(value: value, key: key)
case .defaultKernelURL:
guard URL(string: value) != nil else {
throw ContainerizationError(.invalidArgument, message: "invalid URL: \(value)")
}
DefaultsStore.set(value: value, key: key)
return
case .defaultSubnet:
guard (try? CIDRAddress(value)) != nil else {
throw ContainerizationError(.invalidArgument, message: "invalid CIDRv4 address: \(value)")
}
DefaultsStore.set(value: value, key: key)
}
}
}
}
+5 -4
View File
@@ -23,11 +23,12 @@ extension Application {
abstract: "Manage system components",
subcommands: [
SystemDNS.self,
SystemLogs.self,
SystemStart.self,
SystemStop.self,
SystemStatus.self,
SystemKernel.self,
SystemLogs.self,
SystemProperty.self,
SystemStart.self,
SystemStatus.self,
SystemStop.self,
],
aliases: ["s"]
)
-1
View File
@@ -27,7 +27,6 @@ extension Application {
DNSCreate.self,
DNSDelete.self,
DNSList.self,
DNSDefault.self,
]
)
}
+35
View File
@@ -0,0 +1,35 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ArgumentParser
import ContainerPersistence
import ContainerizationError
import Foundation
extension Application {
struct SystemProperty: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "property",
abstract: "Manage system property values",
subcommands: [
PropertyClear.self,
PropertyGet.self,
PropertyList.self,
PropertySet.self,
]
)
}
}
+28
View File
@@ -640,4 +640,32 @@ public struct Parser {
"invalid publish-socket format \(socketText). Expected: host_path:container_path")
}
}
// MARK: DNS
public static func isValidDomainName(_ name: String) -> Bool {
guard !name.isEmpty && name.count <= 255 else {
return false
}
return name.components(separatedBy: ".").allSatisfy { Self.isValidDomainNameLabel($0) }
}
public static func isValidDomainNameLabel(_ label: String) -> Bool {
guard !label.isEmpty && label.count <= 63 else {
return false
}
let pattern = #/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/#
return !label.ranges(of: pattern).isEmpty
}
// MARK: Miscellaneous
public static func parseBool(string: String) -> Bool? {
let lower = string.lowercased()
switch lower {
case "true", "t": return true
case "false", "f": return false
default: return nil
}
}
}
+102 -18
View File
@@ -22,14 +22,14 @@ public enum DefaultsStore {
private static let userDefaultDomain = "com.apple.container.defaults"
public enum Keys: String {
case defaultBuilderImage = "image.builder"
case defaultDNSDomain = "dns.domain"
case defaultRegistryDomain = "registry.domain"
case defaultInitImage = "image.init"
case defaultKernelURL = "kernel.url"
case defaultKernelBinaryPath = "kernel.binaryPath"
case defaultSubnet = "network.subnet"
case buildRosetta = "build.rosetta"
case defaultDNSDomain = "dns.domain"
case defaultBuilderImage = "image.builder"
case defaultInitImage = "image.init"
case defaultKernelBinaryPath = "kernel.binaryPath"
case defaultKernelURL = "kernel.url"
case defaultSubnet = "network.subnet"
case defaultRegistryDomain = "registry.domain"
}
public static func set(value: String, key: DefaultsStore.Keys) {
@@ -58,6 +58,23 @@ public enum DefaultsStore {
return udSuite.bool(forKey: key.rawValue)
}
public static func allValues() -> [DefaultsStoreValue] {
let allKeys: [(Self.Keys, (Self.Keys) -> Any?)] = [
(.buildRosetta, { Self.getBool(key: $0) }),
(.defaultBuilderImage, { Self.get(key: $0) }),
(.defaultInitImage, { Self.get(key: $0) }),
(.defaultKernelBinaryPath, { Self.get(key: $0) }),
(.defaultKernelURL, { Self.get(key: $0) }),
(.defaultSubnet, { Self.getOptional(key: $0) }),
(.defaultDNSDomain, { Self.getOptional(key: $0) }),
(.defaultRegistryDomain, { Self.get(key: $0) }),
]
return
allKeys
.map { DefaultsStoreValue(id: $0.rawValue, description: $0.summary, value: $1($0) as? (Encodable & CustomStringConvertible), type: $0.type) }
.sorted(by: { $0.id < $1.id })
}
private static var udSuite: UserDefaults {
guard let ud = UserDefaults.init(suiteName: self.userDefaultDomain) else {
fatalError("Failed to initialize UserDefaults for domain \(self.userDefaultDomain)")
@@ -66,31 +83,98 @@ public enum DefaultsStore {
}
}
public struct DefaultsStoreValue: Identifiable, CustomStringConvertible, Encodable {
public let id: String
public let description: String
public let value: (Encodable & CustomStringConvertible)?
public let type: Any.Type
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(description, forKey: .description)
if let value = value {
try container.encode(value, forKey: .value)
} else {
try container.encodeNil(forKey: .value)
}
try container.encode(String(describing: type), forKey: .type)
}
enum CodingKeys: String, CodingKey {
case id, description, value, type
}
}
extension DefaultsStore.Keys {
public var summary: String {
switch self {
case .buildRosetta:
return "Build amd64 images on arm64 using Rosetta, instead of QEMU."
case .defaultDNSDomain:
return "If defined, the local DNS domain to use for containers with unqualified names."
case .defaultBuilderImage:
return "The image reference for the utility container that `container build` uses."
case .defaultInitImage:
return "The image reference for the default initial filesystem image."
case .defaultKernelBinaryPath:
return "If the kernel URL is for an archive, the archive member pathname for the kernel file."
case .defaultKernelURL:
return "The URL for the kernel file to install, or the URL for an archive containing the kernel file."
case .defaultSubnet:
return "Default subnet for IP allocation (used on macOS 15 only)."
case .defaultRegistryDomain:
return "The default registry to use for image references that do not specify a registry."
}
}
public var type: Any.Type {
switch self {
case .buildRosetta:
return Bool.self
case .defaultDNSDomain:
return String.self
case .defaultBuilderImage:
return String.self
case .defaultInitImage:
return String.self
case .defaultKernelBinaryPath:
return String.self
case .defaultKernelURL:
return String.self
case .defaultSubnet:
return String.self
case .defaultRegistryDomain:
return String.self
}
}
fileprivate var defaultValue: String {
switch self {
case .defaultKernelURL:
return "https://github.com/kata-containers/kata-containers/releases/download/3.17.0/kata-static-3.17.0-arm64.tar.xz"
case .defaultKernelBinaryPath:
return "opt/kata/share/kata-containers/vmlinux-6.12.28-153"
case .buildRosetta:
// This is a boolean key, not used with the string get() method
return "true"
case .defaultDNSDomain:
return "test"
case .defaultBuilderImage:
let tag = String(cString: get_container_builder_shim_version())
return "ghcr.io/apple/container-builder-shim/builder:\(tag)"
case .defaultDNSDomain:
return "test"
case .defaultRegistryDomain:
return "docker.io"
case .defaultInitImage:
let tag = String(cString: get_swift_containerization_version())
guard tag != "latest" else {
return "vminit:latest"
}
return "ghcr.io/apple/containerization/vminit:\(tag)"
case .defaultKernelBinaryPath:
return "opt/kata/share/kata-containers/vmlinux-6.12.28-153"
case .defaultKernelURL:
return "https://github.com/kata-containers/kata-containers/releases/download/3.17.0/kata-static-3.17.0-arm64.tar.xz"
case .defaultSubnet:
return "192.168.64.1/24"
case .buildRosetta:
// This is a boolean key, not used with the string get() method
return "true"
case .defaultRegistryDomain:
return "docker.io"
}
}
}