mirror of
https://github.com/apple/container.git
synced 2026-07-16 06:27:07 +00:00
449f1d23df
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
474 lines
14 KiB
Swift
474 lines
14 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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 AsyncHTTPClient
|
|
import ContainerClient
|
|
import ContainerNetworkService
|
|
import Containerization
|
|
import ContainerizationOS
|
|
import Foundation
|
|
import Testing
|
|
|
|
class CLITest {
|
|
struct Image: Codable {
|
|
let reference: String
|
|
}
|
|
|
|
// These structs need to track their counterpart presentation structs in CLI.
|
|
struct ImageInspectOutput: Codable {
|
|
let name: String
|
|
let variants: [variant]
|
|
struct variant: Codable {
|
|
let platform: imagePlatform
|
|
struct imagePlatform: Codable {
|
|
let os: String
|
|
let architecture: String
|
|
}
|
|
}
|
|
}
|
|
|
|
struct NetworkInspectOutput: Codable {
|
|
let id: String
|
|
let state: String
|
|
let config: NetworkConfiguration
|
|
let status: NetworkStatus?
|
|
}
|
|
|
|
init() throws {}
|
|
|
|
let testUUID = UUID().uuidString
|
|
|
|
var testDir: URL! {
|
|
let tempDir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
|
.appendingPathComponent(".clitests")
|
|
.appendingPathComponent(testUUID)
|
|
try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
return tempDir
|
|
}
|
|
|
|
let alpine = "ghcr.io/linuxcontainers/alpine:3.20"
|
|
let alpine318 = "ghcr.io/linuxcontainers/alpine:3.18"
|
|
let busybox = "ghcr.io/containerd/busybox:1.36"
|
|
|
|
let defaultContainerArgs = ["sleep", "infinity"]
|
|
|
|
var executablePath: URL {
|
|
get throws {
|
|
let containerPath = ProcessInfo.processInfo.environment["CONTAINER_CLI_PATH"]
|
|
if let containerPath {
|
|
return URL(filePath: containerPath)
|
|
}
|
|
let fileManager = FileManager.default
|
|
let currentDir = fileManager.currentDirectoryPath
|
|
|
|
let releaseURL = URL(fileURLWithPath: currentDir)
|
|
.appendingPathComponent(".build")
|
|
.appendingPathComponent("release")
|
|
.appendingPathComponent("container")
|
|
|
|
let debugURL = URL(fileURLWithPath: currentDir)
|
|
.appendingPathComponent(".build")
|
|
.appendingPathComponent("debug")
|
|
.appendingPathComponent("container")
|
|
|
|
let releaseExists = fileManager.fileExists(atPath: releaseURL.path)
|
|
let debugExists = fileManager.fileExists(atPath: debugURL.path)
|
|
|
|
if releaseExists && debugExists { // choose the latest build
|
|
do {
|
|
let releaseAttributes = try fileManager.attributesOfItem(atPath: releaseURL.path)
|
|
let debugAttributes = try fileManager.attributesOfItem(atPath: debugURL.path)
|
|
|
|
if let releaseDate = releaseAttributes[.modificationDate] as? Date,
|
|
let debugDate = debugAttributes[.modificationDate] as? Date
|
|
{
|
|
return (releaseDate > debugDate) ? releaseURL : debugURL
|
|
}
|
|
} catch {
|
|
throw CLIError.binaryAttributesNotFound(error)
|
|
}
|
|
} else if releaseExists {
|
|
return releaseURL
|
|
} else if debugExists {
|
|
return debugURL
|
|
}
|
|
// both do not exist
|
|
throw CLIError.binaryNotFound
|
|
}
|
|
}
|
|
|
|
func run(arguments: [String], currentDirectory: URL? = nil) throws -> (output: String, error: String, status: Int32) {
|
|
let process = Process()
|
|
process.executableURL = try executablePath
|
|
process.arguments = arguments
|
|
if let directory = currentDirectory {
|
|
process.currentDirectoryURL = directory
|
|
}
|
|
|
|
let outputPipe = Pipe()
|
|
let errorPipe = Pipe()
|
|
process.standardOutput = outputPipe
|
|
process.standardError = errorPipe
|
|
|
|
do {
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
} catch {
|
|
throw CLIError.executionFailed("Failed to run CLI: \(error)")
|
|
}
|
|
|
|
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
|
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
|
let output = String(data: outputData, encoding: .utf8) ?? ""
|
|
let error = String(data: errorData, encoding: .utf8) ?? ""
|
|
|
|
return (output: output, error: error, status: process.terminationStatus)
|
|
}
|
|
|
|
func runInteractive(arguments: [String], currentDirectory: URL? = nil) throws -> Terminal {
|
|
let process = Process()
|
|
process.executableURL = try executablePath
|
|
process.arguments = arguments
|
|
if let directory = currentDirectory {
|
|
process.currentDirectoryURL = directory
|
|
}
|
|
|
|
do {
|
|
let (parent, child) = try Terminal.create()
|
|
process.standardInput = child.handle
|
|
process.standardOutput = child.handle
|
|
process.standardError = child.handle
|
|
|
|
try process.run()
|
|
return parent
|
|
} catch {
|
|
fatalError(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
func waitForContainerRunning(_ name: String, _ totalAttempts: Int64 = 100) throws {
|
|
var attempt = 0
|
|
var found = false
|
|
while attempt < totalAttempts && !found {
|
|
attempt += 1
|
|
let status = try? getContainerStatus(name)
|
|
if status == "running" {
|
|
found = true
|
|
continue
|
|
}
|
|
sleep(1)
|
|
}
|
|
if !found {
|
|
throw CLIError.containerNotFound(name)
|
|
}
|
|
}
|
|
|
|
enum CLIError: Error {
|
|
case executionFailed(String)
|
|
case invalidInput(String)
|
|
case invalidOutput(String)
|
|
case containerNotFound(String)
|
|
case containerRunFailed(String)
|
|
case binaryNotFound
|
|
case binaryAttributesNotFound(Error)
|
|
}
|
|
|
|
func doLongRun(
|
|
name: String,
|
|
image: String? = nil,
|
|
args: [String]? = nil,
|
|
containerArgs: [String]? = nil
|
|
) throws {
|
|
var runArgs = [
|
|
"run",
|
|
"--rm",
|
|
"--name",
|
|
name,
|
|
"-d",
|
|
]
|
|
if let args {
|
|
runArgs.append(contentsOf: args)
|
|
}
|
|
|
|
if let image {
|
|
runArgs.append(image)
|
|
} else {
|
|
runArgs.append(alpine)
|
|
}
|
|
|
|
if let containerArgs {
|
|
runArgs.append(contentsOf: containerArgs)
|
|
} else {
|
|
runArgs.append(contentsOf: defaultContainerArgs)
|
|
}
|
|
|
|
let (_, error, status) = try run(arguments: runArgs)
|
|
if status != 0 {
|
|
throw CLIError.executionFailed("command failed: \(error)")
|
|
}
|
|
}
|
|
|
|
func doExec(name: String, cmd: [String]) throws -> String {
|
|
var execArgs = [
|
|
"exec",
|
|
name,
|
|
]
|
|
execArgs.append(contentsOf: cmd)
|
|
let (resp, error, status) = try run(arguments: execArgs)
|
|
if status != 0 {
|
|
throw CLIError.executionFailed("command failed: \(error)")
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func doStop(name: String, signal: String = "SIGKILL") throws {
|
|
let (_, error, status) = try run(arguments: [
|
|
"stop",
|
|
"-s",
|
|
signal,
|
|
name,
|
|
])
|
|
if status != 0 {
|
|
throw CLIError.executionFailed("command failed: \(error)")
|
|
}
|
|
}
|
|
|
|
func doCreate(
|
|
name: String,
|
|
image: String? = nil,
|
|
args: [String]? = nil,
|
|
volumes: [String] = [],
|
|
networks: [String] = []
|
|
) throws {
|
|
let image = image ?? alpine
|
|
let args: [String] = args ?? ["sleep", "infinity"]
|
|
|
|
var arguments = ["create", "--rm", "--name", name]
|
|
|
|
// Add volume mounts
|
|
for volume in volumes {
|
|
arguments += ["-v", volume]
|
|
}
|
|
|
|
// Add networks
|
|
for network in networks {
|
|
arguments += ["--network", network]
|
|
}
|
|
|
|
arguments += [image] + args
|
|
|
|
let (_, error, status) = try run(arguments: arguments)
|
|
if status != 0 {
|
|
throw CLIError.executionFailed("command failed: \(error)")
|
|
}
|
|
}
|
|
|
|
func doStart(name: String) throws {
|
|
let (_, error, status) = try run(arguments: [
|
|
"start",
|
|
name,
|
|
])
|
|
if status != 0 {
|
|
throw CLIError.executionFailed("command failed: \(error)")
|
|
}
|
|
}
|
|
|
|
struct inspectOutput: Codable {
|
|
let status: String
|
|
let configuration: ContainerConfiguration
|
|
let networks: [ContainerNetworkService.Attachment]
|
|
}
|
|
|
|
func getContainerStatus(_ name: String) throws -> String {
|
|
try inspectContainer(name).status
|
|
}
|
|
|
|
func inspectContainer(_ name: String) throws -> inspectOutput {
|
|
let response = try run(arguments: [
|
|
"inspect",
|
|
name,
|
|
])
|
|
let cmdStatus = response.status
|
|
guard cmdStatus == 0 else {
|
|
throw CLIError.executionFailed("container inspect failed: exit \(cmdStatus)")
|
|
}
|
|
|
|
let output = response.output
|
|
guard let jsonData = output.data(using: .utf8) else {
|
|
throw CLIError.invalidOutput("container inspect output invalid")
|
|
}
|
|
|
|
let decoder = JSONDecoder()
|
|
|
|
typealias inspectOutputs = [inspectOutput]
|
|
|
|
let io = try decoder.decode(inspectOutputs.self, from: jsonData)
|
|
guard io.count > 0 else {
|
|
throw CLIError.containerNotFound(name)
|
|
}
|
|
return io[0]
|
|
}
|
|
|
|
func inspectImage(_ name: String) throws -> String {
|
|
let response = try run(arguments: [
|
|
"image",
|
|
"inspect",
|
|
name,
|
|
])
|
|
let cmdStatus = response.status
|
|
guard cmdStatus == 0 else {
|
|
throw CLIError.executionFailed("container inspect failed: exit \(cmdStatus)")
|
|
}
|
|
|
|
let output = response.output
|
|
guard let jsonData = output.data(using: .utf8) else {
|
|
throw CLIError.invalidOutput("container inspect output invalid")
|
|
}
|
|
|
|
let decoder = JSONDecoder()
|
|
|
|
struct inspectOutput: Codable {
|
|
let name: String
|
|
}
|
|
|
|
typealias inspectOutputs = [inspectOutput]
|
|
|
|
let io = try decoder.decode(inspectOutputs.self, from: jsonData)
|
|
guard io.count > 0 else {
|
|
throw CLIError.containerNotFound(name)
|
|
}
|
|
return io[0].name
|
|
}
|
|
|
|
func doPull(imageName: String, args: [String]? = nil) throws {
|
|
var pullArgs = [
|
|
"image",
|
|
"pull",
|
|
]
|
|
if let args {
|
|
pullArgs.append(contentsOf: args)
|
|
}
|
|
pullArgs.append(imageName)
|
|
|
|
let (_, error, status) = try run(arguments: pullArgs)
|
|
if status != 0 {
|
|
throw CLIError.executionFailed("command failed: \(error)")
|
|
}
|
|
}
|
|
|
|
func doImageListQuite() throws -> [String] {
|
|
let args = [
|
|
"image",
|
|
"list",
|
|
"-q",
|
|
]
|
|
|
|
let (out, error, status) = try run(arguments: args)
|
|
if status != 0 {
|
|
throw CLIError.executionFailed("command failed: \(error)")
|
|
}
|
|
return out.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines)
|
|
}
|
|
|
|
func doInspectImages(image: String) throws -> [ImageInspectOutput] {
|
|
let (output, error, status) = try run(arguments: [
|
|
"image",
|
|
"inspect",
|
|
image,
|
|
])
|
|
|
|
if status != 0 {
|
|
throw CLIError.executionFailed("command failed: \(error)")
|
|
}
|
|
|
|
guard let jsonData = output.data(using: .utf8) else {
|
|
throw CLIError.invalidOutput("image inspect output invalid \(output)")
|
|
}
|
|
|
|
let decoder = JSONDecoder()
|
|
return try decoder.decode([ImageInspectOutput].self, from: jsonData)
|
|
}
|
|
|
|
func doDefaultRegistrySet(domain: String) throws {
|
|
let args = [
|
|
"system",
|
|
"property",
|
|
"set",
|
|
"registry.domain",
|
|
domain,
|
|
]
|
|
let (_, error, status) = try run(arguments: args)
|
|
if status != 0 {
|
|
throw CLIError.executionFailed("command failed: \(error)")
|
|
}
|
|
}
|
|
|
|
func doDefaultRegistryUnset() throws {
|
|
let args = [
|
|
"system",
|
|
"property",
|
|
"clear",
|
|
"registry.domain",
|
|
]
|
|
let (_, error, status) = try run(arguments: args)
|
|
if status != 0 {
|
|
throw CLIError.executionFailed("command failed: \(error)")
|
|
}
|
|
}
|
|
|
|
func doRemove(name: String, force: Bool = false) throws {
|
|
var args = ["delete"]
|
|
if force {
|
|
args.append("--force")
|
|
}
|
|
args.append(name)
|
|
|
|
let (_, error, status) = try run(arguments: args)
|
|
if status != 0 {
|
|
throw CLIError.executionFailed("command failed: \(error)")
|
|
}
|
|
}
|
|
|
|
func getClient() -> HTTPClient {
|
|
var httpConfiguration = HTTPClient.Configuration()
|
|
let proxyConfig: HTTPClient.Configuration.Proxy? = {
|
|
let proxyEnv = ProcessInfo.processInfo.environment["HTTP_PROXY"]
|
|
guard let proxyEnv else {
|
|
return nil
|
|
}
|
|
guard let url = URL(string: proxyEnv), let host = url.host(), let port = url.port else {
|
|
return nil
|
|
}
|
|
return .server(host: host, port: port)
|
|
}()
|
|
httpConfiguration.proxy = proxyConfig
|
|
return HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration)
|
|
}
|
|
|
|
func withTempDir<T>(_ body: (URL) async throws -> T) async throws -> T {
|
|
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
|
|
defer {
|
|
try? FileManager.default.removeItem(at: tempDir)
|
|
}
|
|
|
|
return try await body(tempDir)
|
|
}
|
|
}
|