From 0b86489d902bca32b7acb367c222b02667d71f30 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Thu, 19 Jun 2025 02:18:53 -0700 Subject: [PATCH 01/80] add compose to cli --- Package.resolved | 11 +- Package.swift | 2 + Sources/CLI/Application.swift | 8 + .../CLI/Compose/Codable Structs/Build.swift | 33 + .../CLI/Compose/Codable Structs/Config.swift | 35 + .../CLI/Compose/Codable Structs/Deploy.swift | 15 + .../Codable Structs/DeployResources.swift | 13 + .../Codable Structs/DeployRestartPolicy.swift | 15 + .../Codable Structs/DeviceReservation.swift | 15 + .../Codable Structs/DockerCompose.swift | 37 + .../Codable Structs/ExternalConfig.swift | 13 + .../Codable Structs/ExternalNetwork.swift | 13 + .../Codable Structs/ExternalSecret.swift | 13 + .../Codable Structs/ExternalVolume.swift | 13 + .../Compose/Codable Structs/Healthcheck.swift | 16 + .../CLI/Compose/Codable Structs/Network.swift | 44 + .../Codable Structs/ResourceLimits.swift | 13 + .../ResourceReservations.swift | 15 + .../CLI/Compose/Codable Structs/Secret.swift | 37 + .../CLI/Compose/Codable Structs/Service.swift | 94 +++ .../Codable Structs/ServiceConfig.swift | 39 + .../Codable Structs/ServiceSecret.swift | 39 + .../CLI/Compose/Codable Structs/Volume.swift | 45 + .../CLI/Compose/Commands/ComposeDown.swift | 142 ++++ Sources/CLI/Compose/Commands/ComposeUp.swift | 793 ++++++++++++++++++ Sources/CLI/Compose/ComposeCommand.swift | 32 + Sources/CLI/Compose/Errors.swift | 48 ++ Sources/CLI/Compose/Helper Functions.swift | 76 ++ 28 files changed, 1668 insertions(+), 1 deletion(-) create mode 100644 Sources/CLI/Compose/Codable Structs/Build.swift create mode 100644 Sources/CLI/Compose/Codable Structs/Config.swift create mode 100644 Sources/CLI/Compose/Codable Structs/Deploy.swift create mode 100644 Sources/CLI/Compose/Codable Structs/DeployResources.swift create mode 100644 Sources/CLI/Compose/Codable Structs/DeployRestartPolicy.swift create mode 100644 Sources/CLI/Compose/Codable Structs/DeviceReservation.swift create mode 100644 Sources/CLI/Compose/Codable Structs/DockerCompose.swift create mode 100644 Sources/CLI/Compose/Codable Structs/ExternalConfig.swift create mode 100644 Sources/CLI/Compose/Codable Structs/ExternalNetwork.swift create mode 100644 Sources/CLI/Compose/Codable Structs/ExternalSecret.swift create mode 100644 Sources/CLI/Compose/Codable Structs/ExternalVolume.swift create mode 100644 Sources/CLI/Compose/Codable Structs/Healthcheck.swift create mode 100644 Sources/CLI/Compose/Codable Structs/Network.swift create mode 100644 Sources/CLI/Compose/Codable Structs/ResourceLimits.swift create mode 100644 Sources/CLI/Compose/Codable Structs/ResourceReservations.swift create mode 100644 Sources/CLI/Compose/Codable Structs/Secret.swift create mode 100644 Sources/CLI/Compose/Codable Structs/Service.swift create mode 100644 Sources/CLI/Compose/Codable Structs/ServiceConfig.swift create mode 100644 Sources/CLI/Compose/Codable Structs/ServiceSecret.swift create mode 100644 Sources/CLI/Compose/Codable Structs/Volume.swift create mode 100644 Sources/CLI/Compose/Commands/ComposeDown.swift create mode 100644 Sources/CLI/Compose/Commands/ComposeUp.swift create mode 100644 Sources/CLI/Compose/ComposeCommand.swift create mode 100644 Sources/CLI/Compose/Errors.swift create mode 100644 Sources/CLI/Compose/Helper Functions.swift diff --git a/Package.resolved b/Package.resolved index 289c7fd2b..f288eaed6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4ac93777a9a369fb7c46f1af4cd15c516926a8f4b23679f1ee2bc40b9a422313", + "originHash" : "fbfb4c6151c110549642a5b6b3bd47eda56b349c4c98c09e34f4451a35e18a23", "pins" : [ { "identity" : "async-http-client", @@ -252,6 +252,15 @@ "revision" : "61e4ca4b81b9e09e2ec863b00c340eb13497dac6", "version" : "1.5.0" } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "3d6871d5b4a5cd519adf233fbb576e0a2af71c17", + "version" : "5.4.0" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 8fa4ba087..991cd5f2c 100644 --- a/Package.swift +++ b/Package.swift @@ -58,6 +58,7 @@ let package = Package( .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.20.1"), .package(url: "https://github.com/orlandos-nl/DNSClient.git", from: "2.4.1"), .package(url: "https://github.com/Bouke/DNS.git", from: "1.2.0"), + .package(url: "https://github.com/jpsim/Yams.git", from: "5.0.6"), scDependency, ], targets: [ @@ -76,6 +77,7 @@ let package = Package( "ContainerClient", "ContainerPlugin", "ContainerLog", + "Yams", ], path: "Sources/CLI" ), diff --git a/Sources/CLI/Application.swift b/Sources/CLI/Application.swift index cd7c22fb2..b57dc0c05 100644 --- a/Sources/CLI/Application.swift +++ b/Sources/CLI/Application.swift @@ -83,6 +83,14 @@ struct Application: AsyncParsableCommand { SystemCommand.self, ] ), + CommandGroup( + name: "Compose", + subcommands: [ + ComposeCommand.self, + ComposeUp.self, + ComposeDown.self, + ] + ), ], // Hidden command to handle plugins on unrecognized input. defaultSubcommand: DefaultCommand.self diff --git a/Sources/CLI/Compose/Codable Structs/Build.swift b/Sources/CLI/Compose/Codable Structs/Build.swift new file mode 100644 index 000000000..47301be71 --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/Build.swift @@ -0,0 +1,33 @@ +// +// Build.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents the `build` configuration for a service. +struct Build: Codable, Hashable { + let context: String // Path to the build context + let dockerfile: String? // Optional path to the Dockerfile within the context + let args: [String: String]? // Build arguments + + // Custom initializer to handle `build: .` (string) or `build: { context: . }` (object) + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let contextString = try? container.decode(String.self) { + self.context = contextString + self.dockerfile = nil + self.args = nil + } else { + let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) + self.context = try keyedContainer.decode(String.self, forKey: .context) + self.dockerfile = try keyedContainer.decodeIfPresent(String.self, forKey: .dockerfile) + self.args = try keyedContainer.decodeIfPresent([String: String].self, forKey: .args) + } + } + + enum CodingKeys: String, CodingKey { + case context, dockerfile, args + } +} diff --git a/Sources/CLI/Compose/Codable Structs/Config.swift b/Sources/CLI/Compose/Codable Structs/Config.swift new file mode 100644 index 000000000..943557964 --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/Config.swift @@ -0,0 +1,35 @@ +// +// Config.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level config definition (primarily for Swarm). +struct Config: Codable { + let file: String? // Path to the file containing the config content + let external: ExternalConfig? // Indicates if the config is external (pre-existing) + let name: String? // Explicit name for the config + let labels: [String: String]? // Labels for the config + + enum CodingKeys: String, CodingKey { + case file, external, name, labels + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_cfg" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + file = try container.decodeIfPresent(String.self, forKey: .file) + name = try container.decodeIfPresent(String.self, forKey: .name) + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalConfig(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalConfig(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } +} \ No newline at end of file diff --git a/Sources/CLI/Compose/Codable Structs/Deploy.swift b/Sources/CLI/Compose/Codable Structs/Deploy.swift new file mode 100644 index 000000000..19cd9dfe8 --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/Deploy.swift @@ -0,0 +1,15 @@ +// +// Deploy.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents the `deploy` configuration for a service (primarily for Swarm orchestration). +struct Deploy: Codable, Hashable { + let mode: String? // Deployment mode (e.g., 'replicated', 'global') + let replicas: Int? // Number of replicated service tasks + let resources: DeployResources? // Resource constraints (limits, reservations) + let restart_policy: DeployRestartPolicy? // Restart policy for tasks +} diff --git a/Sources/CLI/Compose/Codable Structs/DeployResources.swift b/Sources/CLI/Compose/Codable Structs/DeployResources.swift new file mode 100644 index 000000000..54e807581 --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/DeployResources.swift @@ -0,0 +1,13 @@ +// +// DeployResources.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Resource constraints for deployment. +struct DeployResources: Codable, Hashable { + let limits: ResourceLimits? // Hard limits on resources + let reservations: ResourceReservations? // Guarantees for resources +} diff --git a/Sources/CLI/Compose/Codable Structs/DeployRestartPolicy.swift b/Sources/CLI/Compose/Codable Structs/DeployRestartPolicy.swift new file mode 100644 index 000000000..234cc534d --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/DeployRestartPolicy.swift @@ -0,0 +1,15 @@ +// +// DeployRestartPolicy.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Restart policy for deployed tasks. +struct DeployRestartPolicy: Codable, Hashable { + let condition: String? // Condition to restart on (e.g., 'on-failure', 'any') + let delay: String? // Delay before attempting restart + let max_attempts: Int? // Maximum number of restart attempts + let window: String? // Window to evaluate restart policy +} diff --git a/Sources/CLI/Compose/Codable Structs/DeviceReservation.swift b/Sources/CLI/Compose/Codable Structs/DeviceReservation.swift new file mode 100644 index 000000000..1da9bf812 --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/DeviceReservation.swift @@ -0,0 +1,15 @@ +// +// DeviceReservation.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Device reservations for GPUs or other devices. +struct DeviceReservation: Codable, Hashable { + let capabilities: [String]? // Device capabilities + let driver: String? // Device driver + let count: String? // Number of devices + let device_ids: [String]? // Specific device IDs +} diff --git a/Sources/CLI/Compose/Codable Structs/DockerCompose.swift b/Sources/CLI/Compose/Codable Structs/DockerCompose.swift new file mode 100644 index 000000000..a406cbc08 --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/DockerCompose.swift @@ -0,0 +1,37 @@ +// +// DockerCompose.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents the top-level structure of a docker-compose.yml file. +struct DockerCompose: Codable { + let version: String? // The Compose file format version (e.g., '3.8') + let name: String? // Optional project name + let services: [String: Service] // Dictionary of service definitions, keyed by service name + let volumes: [String: Volume]? // Optional top-level volume definitions + let networks: [String: Network]? // Optional top-level network definitions + let configs: [String: Config]? // Optional top-level config definitions (primarily for Swarm) + let secrets: [String: Secret]? // Optional top-level secret definitions (primarily for Swarm) + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decodeIfPresent(String.self, forKey: .version) + name = try container.decodeIfPresent(String.self, forKey: .name) + services = try container.decode([String: Service].self, forKey: .services) + + if let volumes = try container.decodeIfPresent([String: Optional].self, forKey: .volumes) { + let safeVolumes: [String : Volume] = volumes.mapValues { value in + value ?? Volume() + } + self.volumes = safeVolumes + } else { + self.volumes = nil + } + networks = try container.decodeIfPresent([String: Network].self, forKey: .networks) + configs = try container.decodeIfPresent([String: Config].self, forKey: .configs) + secrets = try container.decodeIfPresent([String: Secret].self, forKey: .secrets) + } +} diff --git a/Sources/CLI/Compose/Codable Structs/ExternalConfig.swift b/Sources/CLI/Compose/Codable Structs/ExternalConfig.swift new file mode 100644 index 000000000..a661be692 --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/ExternalConfig.swift @@ -0,0 +1,13 @@ +// +// ExternalConfig.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external config reference. +struct ExternalConfig: Codable { + let isExternal: Bool // True if the config is external + let name: String? // Optional name of the external config if different from key +} \ No newline at end of file diff --git a/Sources/CLI/Compose/Codable Structs/ExternalNetwork.swift b/Sources/CLI/Compose/Codable Structs/ExternalNetwork.swift new file mode 100644 index 000000000..2d74c2785 --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/ExternalNetwork.swift @@ -0,0 +1,13 @@ +// +// ExternalNetwork.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external network reference. +struct ExternalNetwork: Codable { + let isExternal: Bool // True if the network is external + let name: String? // Optional name of the external network if different from key +} \ No newline at end of file diff --git a/Sources/CLI/Compose/Codable Structs/ExternalSecret.swift b/Sources/CLI/Compose/Codable Structs/ExternalSecret.swift new file mode 100644 index 000000000..b4aca8b6f --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/ExternalSecret.swift @@ -0,0 +1,13 @@ +// +// ExternalSecret.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external secret reference. +struct ExternalSecret: Codable { + let isExternal: Bool // True if the secret is external + let name: String? // Optional name of the external secret if different from key +} \ No newline at end of file diff --git a/Sources/CLI/Compose/Codable Structs/ExternalVolume.swift b/Sources/CLI/Compose/Codable Structs/ExternalVolume.swift new file mode 100644 index 000000000..161e88c63 --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/ExternalVolume.swift @@ -0,0 +1,13 @@ +// +// ExternalVolume.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external volume reference. +struct ExternalVolume: Codable { + let isExternal: Bool // True if the volume is external + let name: String? // Optional name of the external volume if different from key +} \ No newline at end of file diff --git a/Sources/CLI/Compose/Codable Structs/Healthcheck.swift b/Sources/CLI/Compose/Codable Structs/Healthcheck.swift new file mode 100644 index 000000000..a5a64f63d --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/Healthcheck.swift @@ -0,0 +1,16 @@ +// +// Healthcheck.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Healthcheck configuration for a service. +struct Healthcheck: Codable, Hashable { + let test: [String]? // Command to run to check health + let start_period: String? // Grace period for the container to start + let interval: String? // How often to run the check + let retries: Int? // Number of consecutive failures to consider unhealthy + let timeout: String? // Timeout for each check +} diff --git a/Sources/CLI/Compose/Codable Structs/Network.swift b/Sources/CLI/Compose/Codable Structs/Network.swift new file mode 100644 index 000000000..58970c5de --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/Network.swift @@ -0,0 +1,44 @@ +// +// Network.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level network definition. +struct Network: Codable { + let driver: String? // Network driver (e.g., 'bridge', 'overlay') + let driver_opts: [String: String]? // Driver-specific options + let attachable: Bool? // Allow standalone containers to attach to this network + let enable_ipv6: Bool? // Enable IPv6 networking + let isInternal: Bool? // RENAMED: from `internal` to `isInternal` to avoid keyword clash + let labels: [String: String]? // Labels for the network + let name: String? // Explicit name for the network + let external: ExternalNetwork? // Indicates if the network is external (pre-existing) + + // Updated CodingKeys to map 'internal' from YAML to 'isInternal' Swift property + enum CodingKeys: String, CodingKey { + case driver, driver_opts, attachable, enable_ipv6, isInternal = "internal", labels, name, external + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_net" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + driver = try container.decodeIfPresent(String.self, forKey: .driver) + driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts) + attachable = try container.decodeIfPresent(Bool.self, forKey: .attachable) + enable_ipv6 = try container.decodeIfPresent(Bool.self, forKey: .enable_ipv6) + isInternal = try container.decodeIfPresent(Bool.self, forKey: .isInternal) // Use isInternal here + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + name = try container.decodeIfPresent(String.self, forKey: .name) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalNetwork(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalNetwork(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } +} \ No newline at end of file diff --git a/Sources/CLI/Compose/Codable Structs/ResourceLimits.swift b/Sources/CLI/Compose/Codable Structs/ResourceLimits.swift new file mode 100644 index 000000000..4d78d66b7 --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/ResourceLimits.swift @@ -0,0 +1,13 @@ +// +// ResourceLimits.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// CPU and memory limits. +struct ResourceLimits: Codable, Hashable { + let cpus: String? // CPU limit (e.g., "0.5") + let memory: String? // Memory limit (e.g., "512M") +} diff --git a/Sources/CLI/Compose/Codable Structs/ResourceReservations.swift b/Sources/CLI/Compose/Codable Structs/ResourceReservations.swift new file mode 100644 index 000000000..77cf7bed2 --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/ResourceReservations.swift @@ -0,0 +1,15 @@ +// +// ResourceReservations.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// **FIXED**: Renamed from `ResourceReservables` to `ResourceReservations` and made `Codable`. +/// CPU and memory reservations. +struct ResourceReservations: Codable, Hashable { // Changed from ResourceReservables to ResourceReservations + let cpus: String? // CPU reservation (e.g., "0.25") + let memory: String? // Memory reservation (e.g., "256M") + let devices: [DeviceReservation]? // Device reservations for GPUs or other devices +} diff --git a/Sources/CLI/Compose/Codable Structs/Secret.swift b/Sources/CLI/Compose/Codable Structs/Secret.swift new file mode 100644 index 000000000..54042d134 --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/Secret.swift @@ -0,0 +1,37 @@ +// +// Secret.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level secret definition (primarily for Swarm). +struct Secret: Codable { + let file: String? // Path to the file containing the secret content + let environment: String? // Environment variable to populate with the secret content + let external: ExternalSecret? // Indicates if the secret is external (pre-existing) + let name: String? // Explicit name for the secret + let labels: [String: String]? // Labels for the secret + + enum CodingKeys: String, CodingKey { + case file, environment, external, name, labels + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_sec" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + file = try container.decodeIfPresent(String.self, forKey: .file) + environment = try container.decodeIfPresent(String.self, forKey: .environment) + name = try container.decodeIfPresent(String.self, forKey: .name) + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalSecret(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalSecret(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } +} \ No newline at end of file diff --git a/Sources/CLI/Compose/Codable Structs/Service.swift b/Sources/CLI/Compose/Codable Structs/Service.swift new file mode 100644 index 000000000..22e42cb5c --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/Service.swift @@ -0,0 +1,94 @@ +// +// Service.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a single service definition within the `services` section. +struct Service: Codable, Hashable { + let image: String? // Docker image name + let build: Build? // Build configuration if the service is built from a Dockerfile + let deploy: Deploy? // Deployment configuration (primarily for Swarm) + let restart: String? // Restart policy (e.g., 'unless-stopped', 'always') + let healthcheck: Healthcheck? // Healthcheck configuration + let volumes: [String]? // List of volume mounts (e.g., "hostPath:containerPath", "namedVolume:/path") + let environment: [String: String]? // Environment variables to set in the container + let env_file: [String]? // List of .env files to load environment variables from + let ports: [String]? // Port mappings (e.g., "hostPort:containerPort") + let command: [String]? // Command to execute in the container, overriding the image's default + let depends_on: [String]? // Services this service depends on (for startup order) + let user: String? // User or UID to run the container as + + let container_name: String? // Explicit name for the container instance + let networks: [String]? // List of networks the service will connect to + let hostname: String? // Container hostname + let entrypoint: [String]? // Entrypoint to execute in the container, overriding the image's default + let privileged: Bool? // Run container in privileged mode + let read_only: Bool? // Mount container's root filesystem as read-only + let working_dir: String? // Working directory inside the container + let configs: [ServiceConfig]? // Service-specific config usage (primarily for Swarm) + let secrets: [ServiceSecret]? // Service-specific secret usage (primarily for Swarm) + let stdin_open: Bool? // Keep STDIN open (-i flag for `container run`) + let tty: Bool? // Allocate a pseudo-TTY (-t flag for `container run`) + + // Defines custom coding keys to map YAML keys to Swift properties + enum CodingKeys: String, CodingKey { + case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, + container_name, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty + } + + /// Custom initializer to handle decoding and basic validation. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + image = try container.decodeIfPresent(String.self, forKey: .image) + build = try container.decodeIfPresent(Build.self, forKey: .build) + deploy = try container.decodeIfPresent(Deploy.self, forKey: .deploy) + + // Ensure that a service has either an image or a build context. + guard image != nil || build != nil else { + throw DecodingError.dataCorruptedError(forKey: .image, in: container, debugDescription: "Service must have either 'image' or 'build' specified.") + } + + restart = try container.decodeIfPresent(String.self, forKey: .restart) + healthcheck = try container.decodeIfPresent(Healthcheck.self, forKey: .healthcheck) + volumes = try container.decodeIfPresent([String].self, forKey: .volumes) + environment = try container.decodeIfPresent([String: String].self, forKey: .environment) + env_file = try container.decodeIfPresent([String].self, forKey: .env_file) + ports = try container.decodeIfPresent([String].self, forKey: .ports) + + // Decode 'command' which can be either a single string or an array of strings. + if let cmdArray = try? container.decodeIfPresent([String].self, forKey: .command) { + command = cmdArray + } else if let cmdString = try? container.decodeIfPresent(String.self, forKey: .command) { + command = [cmdString] + } else { + command = nil + } + + depends_on = try container.decodeIfPresent([String].self, forKey: .depends_on) + user = try container.decodeIfPresent(String.self, forKey: .user) + + container_name = try container.decodeIfPresent(String.self, forKey: .container_name) + networks = try container.decodeIfPresent([String].self, forKey: .networks) + hostname = try container.decodeIfPresent(String.self, forKey: .hostname) + + // Decode 'entrypoint' which can be either a single string or an array of strings. + if let entrypointArray = try? container.decodeIfPresent([String].self, forKey: .entrypoint) { + entrypoint = entrypointArray + } else if let entrypointString = try? container.decodeIfPresent(String.self, forKey: .entrypoint) { + entrypoint = [entrypointString] + } else { + entrypoint = nil + } + + privileged = try container.decodeIfPresent(Bool.self, forKey: .privileged) + read_only = try container.decodeIfPresent(Bool.self, forKey: .read_only) + working_dir = try container.decodeIfPresent(String.self, forKey: .working_dir) + configs = try container.decodeIfPresent([ServiceConfig].self, forKey: .configs) + secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets) + stdin_open = try container.decodeIfPresent(Bool.self, forKey: .stdin_open) + tty = try container.decodeIfPresent(Bool.self, forKey: .tty) + } +} diff --git a/Sources/CLI/Compose/Codable Structs/ServiceConfig.swift b/Sources/CLI/Compose/Codable Structs/ServiceConfig.swift new file mode 100644 index 000000000..daf7fbc5e --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/ServiceConfig.swift @@ -0,0 +1,39 @@ +// +// ServiceConfig.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a service's usage of a config. +struct ServiceConfig: Codable, Hashable { + let source: String // Name of the config being used + let target: String? // Path in the container where the config will be mounted + let uid: String? // User ID for the mounted config file + let gid: String? // Group ID for the mounted config file + let mode: Int? // Permissions mode for the mounted config file + + /// Custom initializer to handle `config_name` (string) or `{ source: config_name, target: /path }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let sourceName = try? container.decode(String.self) { + self.source = sourceName + self.target = nil + self.uid = nil + self.gid = nil + self.mode = nil + } else { + let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) + self.source = try keyedContainer.decode(String.self, forKey: .source) + self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) + self.uid = try keyedContainer.decodeIfPresent(String.self, forKey: .uid) + self.gid = try keyedContainer.decodeIfPresent(String.self, forKey: .gid) + self.mode = try keyedContainer.decodeIfPresent(Int.self, forKey: .mode) + } + } + + enum CodingKeys: String, CodingKey { + case source, target, uid, gid, mode + } +} diff --git a/Sources/CLI/Compose/Codable Structs/ServiceSecret.swift b/Sources/CLI/Compose/Codable Structs/ServiceSecret.swift new file mode 100644 index 000000000..19e5daa6d --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/ServiceSecret.swift @@ -0,0 +1,39 @@ +// +// ServiceSecret.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a service's usage of a secret. +struct ServiceSecret: Codable, Hashable { + let source: String // Name of the secret being used + let target: String? // Path in the container where the secret will be mounted + let uid: String? // User ID for the mounted secret file + let gid: String? // Group ID for the mounted secret file + let mode: Int? // Permissions mode for the mounted secret file + + /// Custom initializer to handle `secret_name` (string) or `{ source: secret_name, target: /path }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let sourceName = try? container.decode(String.self) { + self.source = sourceName + self.target = nil + self.uid = nil + self.gid = nil + self.mode = nil + } else { + let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) + self.source = try keyedContainer.decode(String.self, forKey: .source) + self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) + self.uid = try keyedContainer.decodeIfPresent(String.self, forKey: .uid) + self.gid = try keyedContainer.decodeIfPresent(String.self, forKey: .gid) + self.mode = try keyedContainer.decodeIfPresent(Int.self, forKey: .mode) + } + } + + enum CodingKeys: String, CodingKey { + case source, target, uid, gid, mode + } +} diff --git a/Sources/CLI/Compose/Codable Structs/Volume.swift b/Sources/CLI/Compose/Codable Structs/Volume.swift new file mode 100644 index 000000000..702a329fd --- /dev/null +++ b/Sources/CLI/Compose/Codable Structs/Volume.swift @@ -0,0 +1,45 @@ +// +// Volume.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level volume definition. +struct Volume: Codable { + let driver: String? // Volume driver (e.g., 'local') + let driver_opts: [String: String]? // Driver-specific options + let name: String? // Explicit name for the volume + let labels: [String: String]? // Labels for the volume + let external: ExternalVolume? // Indicates if the volume is external (pre-existing) + + enum CodingKeys: String, CodingKey { + case driver, driver_opts, name, labels, external + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_vol" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + driver = try container.decodeIfPresent(String.self, forKey: .driver) + driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts) + name = try container.decodeIfPresent(String.self, forKey: .name) + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalVolume(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalVolume(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } + + init(driver: String? = nil, driver_opts: [String : String]? = nil, name: String? = nil, labels: [String : String]? = nil, external: ExternalVolume? = nil) { + self.driver = driver + self.driver_opts = driver_opts + self.name = name + self.labels = labels + self.external = external + } +} diff --git a/Sources/CLI/Compose/Commands/ComposeDown.swift b/Sources/CLI/Compose/Commands/ComposeDown.swift new file mode 100644 index 000000000..d81ffe923 --- /dev/null +++ b/Sources/CLI/Compose/Commands/ComposeDown.swift @@ -0,0 +1,142 @@ +// +// ComposeDown.swift +// Container-Compose +// +// Created by Morris Richman on 6/19/25. +// + +import Foundation +import ArgumentParser +import ContainerClient +import Yams + +struct ComposeDown: AsyncParsableCommand { + static let configuration: CommandConfiguration = .init( + commandName: "down", + abstract: "Stop containers with container-compose" + ) + + @OptionGroup + var process: Flags.Process + + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + + private var fileManager: FileManager { FileManager.default } + private var projectName: String? + + mutating func run() async throws { + // Read docker-compose.yml content + guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { + throw YamlError.dockerfileNotFound(dockerComposePath) + } + + // Decode the YAML file into the DockerCompose struct + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + + // Determine project name for container naming + if let name = dockerCompose.name { + projectName = name + print("Info: Docker Compose project name parsed as: \(name)") + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + } else { + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") + } + + try await stopOldStuff(remove: false) + } + + /// Returns the names of all containers whose names start with a given prefix. + /// - Parameter prefix: The container name prefix (e.g. `"Assignment"`). + /// - Returns: An array of matching container names. + func getContainersWithPrefix(_ prefix: String) async throws -> [String] { + let result = try await runCommand("container", args: ["list", "-a"]) + let lines = result.stdout.split(separator: "\n") + + return lines.compactMap { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + let components = trimmed.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + guard let name = components.first else { return nil } + return name.hasPrefix(prefix) ? String(name) : nil + } + } + + func stopOldStuff(remove: Bool) async throws { + guard let projectName else { return } + let containers = try await getContainersWithPrefix(projectName) + + for container in containers { + print("Removing old container: \(container)") + do { + try await runCommand("container", args: ["stop", container]) + if remove { + try await runCommand("container", args: ["rm", container]) + } + } catch { + } + } + } + + /// Runs a command-line tool asynchronously and captures its output and exit code. + /// + /// This function uses async/await and `Process` to launch a command-line tool, + /// returning a `CommandResult` containing the output, error, and exit code upon completion. + /// + /// - Parameters: + /// - command: The full path to the executable to run (e.g., `/bin/ls`). + /// - args: An array of arguments to pass to the command. Defaults to an empty array. + /// - Returns: A `CommandResult` containing `stdout`, `stderr`, and `exitCode`. + /// - Throws: An error if the process fails to launch. + /// - Example: + /// ```swift + /// let result = try await runCommand("/bin/echo", args: ["Hello"]) + /// print(result.stdout) // "Hello\n" + /// ``` + @discardableResult + func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult { + return try await withCheckedThrowingContinuation { continuation in + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [command] + args + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + // Manually set PATH so it can find `container` + process.environment = ProcessInfo.processInfo.environment.merging([ + "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + ]) { _, new in new } + + do { + try process.run() + } catch { + continuation.resume(throwing: error) + return + } + + process.terminationHandler = { proc in + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + + guard stderrData.isEmpty else { + continuation.resume(throwing: TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self))) + return + } + + let result = CommandResult( + stdout: String(decoding: stdoutData, as: UTF8.self), + stderr: String(decoding: stderrData, as: UTF8.self), + exitCode: proc.terminationStatus + ) + + continuation.resume(returning: result) + } + } + } +} diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift new file mode 100644 index 000000000..21cefb968 --- /dev/null +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -0,0 +1,793 @@ +// +// ComposeUp.swift +// Container-Compose +// +// Created by Morris Richman on 6/19/25. +// + +import Foundation +import ArgumentParser +import ContainerClient +import Yams + +struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { + static let configuration: CommandConfiguration = .init( + commandName: "up", + abstract: "Start containers with container-compose" + ) + + @Flag(name: [.customShort("d"), .customLong("detach")], help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") + var detatch: Bool = false + + @Flag(name: [.customShort("b"), .customLong("build")]) + var rebuild: Bool = false + + @OptionGroup + var process: Flags.Process + + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file +// + private var fileManager: FileManager { FileManager.default } + private var projectName: String? + private var environmentVariables: [String : String] = [:] + private var containerIps: [String : String] = [:] + + mutating func run() async throws { + // Read docker-compose.yml content + guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { + throw YamlError.dockerfileNotFound(dockerComposePath) + } + + // Decode the YAML file into the DockerCompose struct + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + + // Load environment variables from .env file + environmentVariables = loadEnvFile(path: envFilePath) + + // Handle 'version' field + if let version = dockerCompose.version { + print("Info: Docker Compose file version parsed as: \(version)") + print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") + } + + // Determine project name for container naming + if let name = dockerCompose.name { + projectName = name + print("Info: Docker Compose project name parsed as: \(name)") + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + } else { + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") + } + + try await stopOldStuff(remove: true) + + // Process top-level networks + // This creates named networks defined in the docker-compose.yml + if let networks = dockerCompose.networks { + print("\n--- Processing Networks ---") + for (networkName, networkConfig) in networks { + try await setupNetwork(name: networkName, config: networkConfig) + } + print("--- Networks Processed ---\n") + } + + // Process top-level volumes + // This creates named volumes defined in the docker-compose.yml + if let volumes = dockerCompose.volumes { + print("\n--- Processing Volumes ---") + for (volumeName, volumeConfig) in volumes { + await createVolumeHardLink(name: volumeName, config: volumeConfig) + } + print("--- Volumes Processed ---\n") + } + + // Process each service defined in the docker-compose.yml + print("\n--- Processing Services ---") + + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) + services = try topoSortConfiguredServices(services) + + print(services.map(\.serviceName)) + for (serviceName, service) in services { + try await configService(service, serviceName: serviceName, from: dockerCompose) + } + + if !detatch { + await waitForever() + } + } + + func waitForever() async -> Never { + for await _ in AsyncStream(unfolding: { }) { + // This will never run + } + fatalError("unreachable") + } + + func getIPForRunningService(_ serviceName: String) async throws -> String? { + guard let projectName else { return nil } + + let containerName = "\(projectName)-\(serviceName)" + + // Run the container list command + let containerCommandOutput = try await runCommand("container", args: ["list", "-a"]) + let allLines = containerCommandOutput.stdout.components(separatedBy: .newlines) + + // Find the line matching the full container name + guard let matchingLine = allLines.first(where: { $0.contains(containerName) }) else { + return nil + } + + // Extract IP using regex + let pattern = #"\b(?:\d{1,3}\.){3}\d{1,3}\b"# + let regex = try NSRegularExpression(pattern: pattern) + + let range = NSRange(matchingLine.startIndex.. [String] { + let result = try await runCommand("container", args: ["list", "-a"]) + let lines = result.stdout.split(separator: "\n") + + return lines.compactMap { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + let components = trimmed.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + guard let name = components.first else { return nil } + return name.hasPrefix(prefix) ? String(name) : nil + } + } + + // MARK: Compose Top Level Functions + + mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { + let ip = try await getIPForRunningService(serviceName) + self.containerIps[serviceName] = ip + for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { + self.environmentVariables[key] = ip ?? value + } + } + + /// Returns the services in topological order based on `depends_on` relationships. + func topoSortConfiguredServices( + _ services: [(serviceName: String, service: Service)] + ) throws -> [(serviceName: String, service: Service)] { + + var visited = Set() + var visiting = Set() + var sorted: [(String, Service)] = [] + + func visit(_ name: String) throws { + guard let serviceTuple = services.first(where: { $0.serviceName == name }) else { return } + + if visiting.contains(name) { + throw NSError(domain: "ComposeError", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" + ]) + } + guard !visited.contains(name) else { return } + + visiting.insert(name) + for depName in serviceTuple.service.depends_on ?? [] { + try visit(depName) + } + visiting.remove(name) + visited.insert(name) + sorted.append(serviceTuple) + } + + for (serviceName, _) in services { + if !visited.contains(serviceName) { + try visit(serviceName) + } + } + + return sorted + } + + func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { + guard let projectName else { return } + let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name + + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") + let volumePath = volumeUrl.path(percentEncoded: false) + + print("Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead.") + try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + } + + func setupNetwork(name networkName: String, config networkConfig: Network) async throws { + let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name + + if let externalNetwork = networkConfig.external, externalNetwork.isExternal { + print("Info: Network '\(networkName)' is declared as external.") + print("This tool assumes external network '\(externalNetwork.name ?? actualNetworkName)' already exists and will not attempt to create it.") + } else { + var networkCreateArgs: [String] = ["network", "create"] + + // Add driver and driver options + if let driver = networkConfig.driver { + networkCreateArgs.append("--driver") + networkCreateArgs.append(driver) + } + if let driverOpts = networkConfig.driver_opts { + for (optKey, optValue) in driverOpts { + networkCreateArgs.append("--opt") + networkCreateArgs.append("\(optKey)=\(optValue)") + } + } + // Add various network flags + if networkConfig.attachable == true { networkCreateArgs.append("--attachable") } + if networkConfig.enable_ipv6 == true { networkCreateArgs.append("--ipv6") } + if networkConfig.isInternal == true { networkCreateArgs.append("--internal") } // CORRECTED: Use isInternal + + // Add labels + if let labels = networkConfig.labels { + for (labelKey, labelValue) in labels { + networkCreateArgs.append("--label") + networkCreateArgs.append("\(labelKey)=\(labelValue)") + } + } + + networkCreateArgs.append(actualNetworkName) // Add the network name + + print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") + print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") + let _ = try await runCommand("container", args: networkCreateArgs) + #warning("Network creation output not used") + print("Network '\(networkName)' created or already exists.") + } + } + + // MARK: Compose Service Level Functions + mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { + guard let projectName else { throw ComposeError.invalidProjectName } + + var imageToRun: String + + // Handle 'build' configuration + if let buildConfig = service.build { + imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) + } else if let img = service.image { + // Use specified image if no build config + imageToRun = resolveVariable(img, with: environmentVariables) + } else { + // Should not happen due to Service init validation, but as a fallback + throw ComposeError.imageNotFound(serviceName) + } + + // Handle 'deploy' configuration (note that this tool doesn't fully support it) + if service.deploy != nil { + print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") + print("However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands.") + print("The service will be run as a single container based on other configurations.") + } + + var runCommandArgs: [String] = [] + + // Add detach flag if specified on the CLI + if detatch { + runCommandArgs.append("-d") + } + + // Determine container name + let containerName: String + if let explicitContainerName = service.container_name { + containerName = explicitContainerName + print("Info: Using explicit container_name: \(containerName)") + } else { + // Default container name based on project and service name + containerName = "\(projectName)-\(serviceName)" + } + runCommandArgs.append("--name") + runCommandArgs.append(containerName) + + // REMOVED: Restart policy is not supported by `container run` + // if let restart = service.restart { + // runCommandArgs.append("--restart") + // runCommandArgs.append(restart) + // } + + // Add user + if let user = service.user { + runCommandArgs.append("--user") + runCommandArgs.append(user) + } + + // Add volume mounts + if let volumes = service.volumes { + for volume in volumes { + let args = try await configVolume(volume) + runCommandArgs.append(contentsOf: args) + } + } + + // Combine environment variables from .env files and service environment + var combinedEnv: [String: String] = environmentVariables + + if let envFiles = service.env_file { + for envFile in envFiles { + let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") + combinedEnv.merge(additionalEnvVars) { (current, _) in current } + } + } + + if let serviceEnv = service.environment { + combinedEnv.merge(serviceEnv) { (old, new) in + if !new.contains("${") { + return new + } else { + return old + } + } // Service env overrides .env files + } + + // Fill in variables + combinedEnv = combinedEnv.mapValues({ value in + guard value.contains("${") else { return value } + + let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) + return combinedEnv[variableName] ?? value + }) + + // Fill in IPs + combinedEnv = combinedEnv.mapValues({ value in + containerIps[value] ?? value + }) + + // MARK: Spinning Spot + // Add environment variables to run command + for (key, value) in combinedEnv { + runCommandArgs.append("-e") + runCommandArgs.append("\(key)=\(value)") + } + + // REMOVED: Port mappings (-p) are not supported by `container run` + // if let ports = service.ports { + // for port in ports { + // let resolvedPort = resolveVariable(port, with: envVarsFromFile) + // runCommandArgs.append("-p") + // runCommandArgs.append(resolvedPort) + // } + // } + + // Connect to specified networks + if let serviceNetworks = service.networks { + for network in serviceNetworks { + let resolvedNetwork = resolveVariable(network, with: environmentVariables) + // Use the explicit network name from top-level definition if available, otherwise resolved name + let networkToConnect = dockerCompose.networks?[network]?.name ?? resolvedNetwork + runCommandArgs.append("--network") + runCommandArgs.append(networkToConnect) + } + print("Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml.") + print("Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level.") + } else { + print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.") + } + + // Add hostname + if let hostname = service.hostname { + let resolvedHostname = resolveVariable(hostname, with: environmentVariables) + runCommandArgs.append("--hostname") + runCommandArgs.append(resolvedHostname) + } + + // Add working directory + if let workingDir = service.working_dir { + let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) + runCommandArgs.append("--workdir") + runCommandArgs.append(resolvedWorkingDir) + } + + // Add privileged flag + if service.privileged == true { + runCommandArgs.append("--privileged") + } + + // Add read-only flag + if service.read_only == true { + runCommandArgs.append("--read-only") + } + + // Handle service-level configs (note: still only parsing/logging, not attaching) + if let serviceConfigs = service.configs { + print("Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands.") + print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.") + for serviceConfig in serviceConfigs { + print(" - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))") + } + } +// + // Handle service-level secrets (note: still only parsing/logging, not attaching) + if let serviceSecrets = service.secrets { + print("Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands.") + print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.") + for serviceSecret in serviceSecrets { + print(" - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))") + } + } + + // Add interactive and TTY flags + if service.stdin_open == true { + runCommandArgs.append("-i") // --interactive + } + if service.tty == true { + runCommandArgs.append("-t") // --tty + } + + runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint + + // Add entrypoint or command + if let entrypointParts = service.entrypoint { + runCommandArgs.append("--entrypoint") + runCommandArgs.append(contentsOf: entrypointParts) + } else if let commandParts = service.command { + runCommandArgs.append(contentsOf: commandParts) + } + + Task { [self] in + + @Sendable + func handleOutput(_ string: String) { + print("\(serviceName): \(string)") + } + + print("\nStarting service: \(serviceName)") + print("Starting \(serviceName)") + print("----------------------------------------\n") + let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) + } + + do { + try await waitUntilServiceIsRunning(serviceName) + try await updateEnvironmentWithServiceIP(serviceName) + } catch { + print(error) + } + } + + /// Builds Docker Service + /// + /// - Parameters: + /// - buildConfig: The configuration for the build + /// - service: The service you would like to build + /// - serviceName: The fallback name for the image + /// + /// - Returns: Image Name (`String`) + func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { + + var buildCommandArgs: [String] = ["build"] + + // Determine image tag for built image + let imageToRun = service.image ?? "\(serviceName):latest" + + let imagesList = try await runCommand("container", args: ["images", "list"]).stdout + if !rebuild, imagesList.contains(serviceName) { + return imageToRun + } + + do { + try await runCommand("container", args: ["images", "rm", imageToRun]) + } catch { + } + + buildCommandArgs.append("--tag") + buildCommandArgs.append(imageToRun) + + // Resolve build context path + let resolvedContext = resolveVariable(buildConfig.context, with: environmentVariables) + buildCommandArgs.append(resolvedContext) + + // Add Dockerfile path if specified + if let dockerfile = buildConfig.dockerfile { + let resolvedDockerfile = resolveVariable(dockerfile, with: environmentVariables) + buildCommandArgs.append("--file") + buildCommandArgs.append(resolvedDockerfile) + } + + // Add build arguments + if let args = buildConfig.args { + for (key, value) in args { + let resolvedValue = resolveVariable(value, with: environmentVariables) + buildCommandArgs.append("--build-arg") + buildCommandArgs.append("\(key)=\(resolvedValue)") + } + } + + print("\n----------------------------------------") + print("Building image for service: \(serviceName) (Tag: \(imageToRun))") + print("Executing container build: container \(buildCommandArgs.joined(separator: " "))") + try await streamCommand("container", args: buildCommandArgs, onStdout: { print($0) }, onStderr: { print($0) }) + print("Image build for \(serviceName) completed.") + print("----------------------------------------") + + return imageToRun + } + + func configVolume(_ volume: String) async throws -> [String] { + let resolvedVolume = resolveVariable(volume, with: environmentVariables) + + var runCommandArgs: [String] = [] + + // Parse the volume string: destination[:mode] + let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) + + guard components.count >= 2 else { + print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") + return [] + } + + let source = components[0] + let destination = components[1] + + // Check if the source looks like a host path (contains '/' or starts with '.') + // This heuristic helps distinguish bind mounts from named volume references. + if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { + // This is likely a bind mount (local path to container path) + var isDirectory: ObjCBool = false + // Ensure the path is absolute or relative to the current directory for FileManager + let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) + + if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { + if isDirectory.boolValue { + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + } else { + // Host path exists but is a file + print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") + } + } else { + // Host path does not exist, assume it's meant to be a directory and try to create it. + do { + try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) + print("Info: Created missing host directory for volume: \(fullHostPath)") + runCommandArgs.append("-v") + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + } catch { + print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") + } + } + } else { + guard let projectName else { return [] } + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") + let volumePath = volumeUrl.path(percentEncoded: false) + + let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() + let destinationPath = destinationUrl.path(percentEncoded: false) + + print("Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead.") + try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument + } + + return runCommandArgs + } +} + +// MARK: CommandLine Functions +extension ComposeUp { + + /// Runs a command-line tool asynchronously and captures its output and exit code. + /// + /// This function uses async/await and `Process` to launch a command-line tool, + /// returning a `CommandResult` containing the output, error, and exit code upon completion. + /// + /// - Parameters: + /// - command: The full path to the executable to run (e.g., `/bin/ls`). + /// - args: An array of arguments to pass to the command. Defaults to an empty array. + /// - Returns: A `CommandResult` containing `stdout`, `stderr`, and `exitCode`. + /// - Throws: An error if the process fails to launch. + /// - Example: + /// ```swift + /// let result = try await runCommand("/bin/echo", args: ["Hello"]) + /// print(result.stdout) // "Hello\n" + /// ``` + @discardableResult + func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult { + return try await withCheckedThrowingContinuation { continuation in + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [command] + args + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + // Manually set PATH so it can find `container` + process.environment = ProcessInfo.processInfo.environment.merging([ + "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + ]) { _, new in new } + + do { + try process.run() + } catch { + continuation.resume(throwing: error) + return + } + + process.terminationHandler = { proc in + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + + guard stderrData.isEmpty else { + continuation.resume(throwing: TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self))) + return + } + + let result = CommandResult( + stdout: String(decoding: stdoutData, as: UTF8.self), + stderr: String(decoding: stderrData, as: UTF8.self), + exitCode: proc.terminationStatus + ) + + continuation.resume(returning: result) + } + } + } + + /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. + /// + /// - Parameters: + /// - command: The name of the command to run (e.g., `"container"`). + /// - args: Command-line arguments to pass to the command. + /// - onStdout: Closure called with streamed stdout data. + /// - onStderr: Closure called with streamed stderr data. + /// - Returns: The process's exit code. + /// - Throws: If the process fails to launch. + @discardableResult + func streamCommand( + _ command: String, + args: [String] = [], + onStdout: @escaping (@Sendable (String) -> Void), + onStderr: @escaping (@Sendable (String) -> Void) + ) async throws -> Int32 { + return try await withCheckedThrowingContinuation { continuation in + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [command] + args + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + process.environment = ProcessInfo.processInfo.environment.merging([ + "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + ]) { _, new in new } + + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + + stdoutHandle.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let string = String(data: data, encoding: .utf8) { + onStdout(string) + } + } + + stderrHandle.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let string = String(data: data, encoding: .utf8) { + onStderr(string) + } + } + + process.terminationHandler = { proc in + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil + continuation.resume(returning: proc.terminationStatus) + } + + do { + try process.run() + } catch { + continuation.resume(throwing: error) + } + } + } + + /// Launches a detached command-line process without waiting for its output or termination. + /// + /// This function is useful when you want to spawn a process that runs in the background + /// independently of the current ComposeUp. Output streams are redirected to null devices. + /// + /// - Parameters: + /// - command: The full path to the executable to launch (e.g., `/usr/bin/open`). + /// - args: An array of arguments to pass to the command. Defaults to an empty array. + /// - Returns: The `Process` instance that was launched, in case you want to retain or manage it. + /// - Throws: An error if the process fails to launch. + /// - Example: + /// ```swift + /// try launchDetachedCommand("/usr/bin/open", args: ["/ComposeUps/Calculator.app"]) + /// ``` + @discardableResult + func launchDetachedCommand(_ command: String, args: [String] = []) throws -> Process { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [command] + args + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + process.standardInput = FileHandle.nullDevice + // Manually set PATH so it can find `container` + process.environment = ProcessInfo.processInfo.environment.merging([ + "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + ]) { _, new in new } + + // Set this to true to run independently of the launching app + process.qualityOfService = .background + + try process.run() + return process + } +} diff --git a/Sources/CLI/Compose/ComposeCommand.swift b/Sources/CLI/Compose/ComposeCommand.swift new file mode 100644 index 000000000..7f6807b86 --- /dev/null +++ b/Sources/CLI/Compose/ComposeCommand.swift @@ -0,0 +1,32 @@ +// +// File.swift +// Container-Compose +// +// Created by Morris Richman on 6/18/25. +// + +import Foundation +import Yams +import ArgumentParser + +struct ComposeCommand: AsyncParsableCommand { + static let configuration: CommandConfiguration = .init( + commandName: "compose", + abstract: "A tool to use manage Docker Compose files with Apple Container", + subcommands: [ + ComposeUp.self, + ComposeDown.self + ]) +} + +/// A structure representing the result of a command-line process execution. +struct CommandResult { + /// The standard output captured from the process. + let stdout: String + + /// The standard error output captured from the process. + let stderr: String + + /// The exit code returned by the process upon termination. + let exitCode: Int32 +} diff --git a/Sources/CLI/Compose/Errors.swift b/Sources/CLI/Compose/Errors.swift new file mode 100644 index 000000000..32ac5c8cc --- /dev/null +++ b/Sources/CLI/Compose/Errors.swift @@ -0,0 +1,48 @@ +// +// Errors.swift +// Container-Compose +// +// Created by Morris Richman on 6/18/25. +// + +import Foundation + +enum YamlError: Error, LocalizedError { + case dockerfileNotFound(String) + + var errorDescription: String? { + switch self { + case .dockerfileNotFound(let path): + return "docker-compose.yml not found at \(path)" + } + } +} + +enum ComposeError: Error, LocalizedError { + case imageNotFound(String) + case invalidProjectName + + var errorDescription: String? { + switch self { + case .imageNotFound(let name): + return "Service \(name) must define either 'image' or 'build'." + case .invalidProjectName: + return "Could not find project name." + } + } +} + +enum TerminalError: Error, LocalizedError { + case commandFailed(String) + + var errorDescription: String? { + return "Command failed: \(self)" + } +} + +/// An enum representing streaming output from either `stdout` or `stderr`. +enum CommandOutput { + case stdout(String) + case stderr(String) + case exitCode(Int32) +} diff --git a/Sources/CLI/Compose/Helper Functions.swift b/Sources/CLI/Compose/Helper Functions.swift new file mode 100644 index 000000000..50549fdd9 --- /dev/null +++ b/Sources/CLI/Compose/Helper Functions.swift @@ -0,0 +1,76 @@ +// +// Helper Functions.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + +import Foundation +import Yams + +/// Loads environment variables from a .env file. +/// - Parameter path: The full path to the .env file. +/// - Returns: A dictionary of key-value pairs representing environment variables. +func loadEnvFile(path: String) -> [String: String] { + var envVars: [String: String] = [:] + let fileURL = URL(fileURLWithPath: path) + do { + let content = try String(contentsOf: fileURL, encoding: .utf8) + let lines = content.split(separator: "\n") + for line in lines { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + // Ignore empty lines and comments + if !trimmedLine.isEmpty && !trimmedLine.starts(with: "#") { + // Parse key=value pairs + if let eqIndex = trimmedLine.firstIndex(of: "=") { + let key = String(trimmedLine[.. String { + var resolvedValue = value + // Regex to find ${VAR}, ${VAR:-default}, ${VAR:?error} + let regex = try! NSRegularExpression(pattern: "\\$\\{([A-Z0-9_]+)(:?-(.*?))?(:\\?(.*?))?\\}", options: []) + + // Combine process environment with loaded .env file variables, prioritizing process environment + let combinedEnv = ProcessInfo.processInfo.environment.merging(envVars) { (current, _) in current } + + // Loop to resolve all occurrences of variables in the string + while let match = regex.firstMatch(in: resolvedValue, options: [], range: NSRange(resolvedValue.startIndex.. Date: Thu, 19 Jun 2025 02:23:19 -0700 Subject: [PATCH 02/80] build optimization fix --- Sources/CLI/Compose/Commands/ComposeUp.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index 21cefb968..898a7f837 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -519,9 +519,10 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Determine image tag for built image let imageToRun = service.image ?? "\(serviceName):latest" + let searchName = imageToRun.split(separator: ":").first let imagesList = try await runCommand("container", args: ["images", "list"]).stdout - if !rebuild, imagesList.contains(serviceName) { + if !rebuild, let searchName, imagesList.contains(searchName) { return imageToRun } From 316e136c43000efe027b6e66e8703eac1e9befb4 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Thu, 19 Jun 2025 02:27:23 -0700 Subject: [PATCH 03/80] Formatting fixes --- .../CLI/Compose/Codable Structs/Build.swift | 16 ++ .../CLI/Compose/Codable Structs/Config.swift | 16 ++ .../CLI/Compose/Codable Structs/Deploy.swift | 16 ++ .../Codable Structs/DeployResources.swift | 16 ++ .../Codable Structs/DeployRestartPolicy.swift | 16 ++ .../Codable Structs/DeviceReservation.swift | 16 ++ .../Codable Structs/DockerCompose.swift | 16 ++ .../Codable Structs/ExternalConfig.swift | 16 ++ .../Codable Structs/ExternalNetwork.swift | 16 ++ .../Codable Structs/ExternalSecret.swift | 16 ++ .../Codable Structs/ExternalVolume.swift | 16 ++ .../Compose/Codable Structs/Healthcheck.swift | 16 ++ .../CLI/Compose/Codable Structs/Network.swift | 16 ++ .../Codable Structs/ResourceLimits.swift | 16 ++ .../ResourceReservations.swift | 16 ++ .../CLI/Compose/Codable Structs/Secret.swift | 16 ++ .../CLI/Compose/Codable Structs/Service.swift | 16 ++ .../Codable Structs/ServiceConfig.swift | 16 ++ .../Codable Structs/ServiceSecret.swift | 16 ++ .../CLI/Compose/Codable Structs/Volume.swift | 16 ++ .../CLI/Compose/Commands/ComposeDown.swift | 56 ++-- Sources/CLI/Compose/Commands/ComposeUp.swift | 264 ++++++++++-------- Sources/CLI/Compose/ComposeCommand.swift | 20 +- Sources/CLI/Compose/Errors.swift | 24 +- Sources/CLI/Compose/Helper Functions.swift | 16 ++ 25 files changed, 564 insertions(+), 136 deletions(-) diff --git a/Sources/CLI/Compose/Codable Structs/Build.swift b/Sources/CLI/Compose/Codable Structs/Build.swift index 47301be71..50aeac337 100644 --- a/Sources/CLI/Compose/Codable Structs/Build.swift +++ b/Sources/CLI/Compose/Codable Structs/Build.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // Build.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/Config.swift b/Sources/CLI/Compose/Codable Structs/Config.swift index 943557964..d74a5e031 100644 --- a/Sources/CLI/Compose/Codable Structs/Config.swift +++ b/Sources/CLI/Compose/Codable Structs/Config.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // Config.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/Deploy.swift b/Sources/CLI/Compose/Codable Structs/Deploy.swift index 19cd9dfe8..33e817b11 100644 --- a/Sources/CLI/Compose/Codable Structs/Deploy.swift +++ b/Sources/CLI/Compose/Codable Structs/Deploy.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // Deploy.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/DeployResources.swift b/Sources/CLI/Compose/Codable Structs/DeployResources.swift index 54e807581..5f539adcd 100644 --- a/Sources/CLI/Compose/Codable Structs/DeployResources.swift +++ b/Sources/CLI/Compose/Codable Structs/DeployResources.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // DeployResources.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/DeployRestartPolicy.swift b/Sources/CLI/Compose/Codable Structs/DeployRestartPolicy.swift index 234cc534d..e56987bd6 100644 --- a/Sources/CLI/Compose/Codable Structs/DeployRestartPolicy.swift +++ b/Sources/CLI/Compose/Codable Structs/DeployRestartPolicy.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // DeployRestartPolicy.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/DeviceReservation.swift b/Sources/CLI/Compose/Codable Structs/DeviceReservation.swift index 1da9bf812..f40b32a7d 100644 --- a/Sources/CLI/Compose/Codable Structs/DeviceReservation.swift +++ b/Sources/CLI/Compose/Codable Structs/DeviceReservation.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // DeviceReservation.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/DockerCompose.swift b/Sources/CLI/Compose/Codable Structs/DockerCompose.swift index a406cbc08..6f3b7cc2d 100644 --- a/Sources/CLI/Compose/Codable Structs/DockerCompose.swift +++ b/Sources/CLI/Compose/Codable Structs/DockerCompose.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // DockerCompose.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/ExternalConfig.swift b/Sources/CLI/Compose/Codable Structs/ExternalConfig.swift index a661be692..ec6866e9c 100644 --- a/Sources/CLI/Compose/Codable Structs/ExternalConfig.swift +++ b/Sources/CLI/Compose/Codable Structs/ExternalConfig.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // ExternalConfig.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/ExternalNetwork.swift b/Sources/CLI/Compose/Codable Structs/ExternalNetwork.swift index 2d74c2785..d2df0da28 100644 --- a/Sources/CLI/Compose/Codable Structs/ExternalNetwork.swift +++ b/Sources/CLI/Compose/Codable Structs/ExternalNetwork.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // ExternalNetwork.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/ExternalSecret.swift b/Sources/CLI/Compose/Codable Structs/ExternalSecret.swift index b4aca8b6f..ec854546f 100644 --- a/Sources/CLI/Compose/Codable Structs/ExternalSecret.swift +++ b/Sources/CLI/Compose/Codable Structs/ExternalSecret.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // ExternalSecret.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/ExternalVolume.swift b/Sources/CLI/Compose/Codable Structs/ExternalVolume.swift index 161e88c63..f42c4bff1 100644 --- a/Sources/CLI/Compose/Codable Structs/ExternalVolume.swift +++ b/Sources/CLI/Compose/Codable Structs/ExternalVolume.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // ExternalVolume.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/Healthcheck.swift b/Sources/CLI/Compose/Codable Structs/Healthcheck.swift index a5a64f63d..9bb5131db 100644 --- a/Sources/CLI/Compose/Codable Structs/Healthcheck.swift +++ b/Sources/CLI/Compose/Codable Structs/Healthcheck.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // Healthcheck.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/Network.swift b/Sources/CLI/Compose/Codable Structs/Network.swift index 58970c5de..df14bcbbb 100644 --- a/Sources/CLI/Compose/Codable Structs/Network.swift +++ b/Sources/CLI/Compose/Codable Structs/Network.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // Network.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/ResourceLimits.swift b/Sources/CLI/Compose/Codable Structs/ResourceLimits.swift index 4d78d66b7..d75dfa497 100644 --- a/Sources/CLI/Compose/Codable Structs/ResourceLimits.swift +++ b/Sources/CLI/Compose/Codable Structs/ResourceLimits.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // ResourceLimits.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/ResourceReservations.swift b/Sources/CLI/Compose/Codable Structs/ResourceReservations.swift index 77cf7bed2..db495c1a1 100644 --- a/Sources/CLI/Compose/Codable Structs/ResourceReservations.swift +++ b/Sources/CLI/Compose/Codable Structs/ResourceReservations.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // ResourceReservations.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/Secret.swift b/Sources/CLI/Compose/Codable Structs/Secret.swift index 54042d134..dcb533d01 100644 --- a/Sources/CLI/Compose/Codable Structs/Secret.swift +++ b/Sources/CLI/Compose/Codable Structs/Secret.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // Secret.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/Service.swift b/Sources/CLI/Compose/Codable Structs/Service.swift index 22e42cb5c..d493189fc 100644 --- a/Sources/CLI/Compose/Codable Structs/Service.swift +++ b/Sources/CLI/Compose/Codable Structs/Service.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // Service.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/ServiceConfig.swift b/Sources/CLI/Compose/Codable Structs/ServiceConfig.swift index daf7fbc5e..fe4cd7c1a 100644 --- a/Sources/CLI/Compose/Codable Structs/ServiceConfig.swift +++ b/Sources/CLI/Compose/Codable Structs/ServiceConfig.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // ServiceConfig.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/ServiceSecret.swift b/Sources/CLI/Compose/Codable Structs/ServiceSecret.swift index 19e5daa6d..8ae352822 100644 --- a/Sources/CLI/Compose/Codable Structs/ServiceSecret.swift +++ b/Sources/CLI/Compose/Codable Structs/ServiceSecret.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // ServiceSecret.swift // container-compose-app diff --git a/Sources/CLI/Compose/Codable Structs/Volume.swift b/Sources/CLI/Compose/Codable Structs/Volume.swift index 702a329fd..bb4b5c201 100644 --- a/Sources/CLI/Compose/Codable Structs/Volume.swift +++ b/Sources/CLI/Compose/Codable Structs/Volume.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // Volume.swift // container-compose-app diff --git a/Sources/CLI/Compose/Commands/ComposeDown.swift b/Sources/CLI/Compose/Commands/ComposeDown.swift index d81ffe923..0ad554f80 100644 --- a/Sources/CLI/Compose/Commands/ComposeDown.swift +++ b/Sources/CLI/Compose/Commands/ComposeDown.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // ComposeDown.swift // Container-Compose @@ -5,50 +21,52 @@ // Created by Morris Richman on 6/19/25. // -import Foundation import ArgumentParser import ContainerClient +import Foundation import Yams struct ComposeDown: AsyncParsableCommand { static let configuration: CommandConfiguration = .init( commandName: "down", abstract: "Stop containers with container-compose" - ) - + ) + @OptionGroup var process: Flags.Process - + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - - var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml - + + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + private var fileManager: FileManager { FileManager.default } private var projectName: String? - + mutating func run() async throws { // Read docker-compose.yml content guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { throw YamlError.dockerfileNotFound(dockerComposePath) } - + // Decode the YAML file into the DockerCompose struct let dockerComposeString = String(data: yamlData, encoding: .utf8)! let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - + // Determine project name for container naming if let name = dockerCompose.name { projectName = name print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + print( + "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." + ) } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") } - + try await stopOldStuff(remove: false) } - + /// Returns the names of all containers whose names start with a given prefix. /// - Parameter prefix: The container name prefix (e.g. `"Assignment"`). /// - Returns: An array of matching container names. @@ -63,11 +81,11 @@ struct ComposeDown: AsyncParsableCommand { return name.hasPrefix(prefix) ? String(name) : nil } } - + func stopOldStuff(remove: Bool) async throws { guard let projectName else { return } let containers = try await getContainersWithPrefix(projectName) - + for container in containers { print("Removing old container: \(container)") do { @@ -79,7 +97,7 @@ struct ComposeDown: AsyncParsableCommand { } } } - + /// Runs a command-line tool asynchronously and captures its output and exit code. /// /// This function uses async/await and `Process` to launch a command-line tool, @@ -97,7 +115,7 @@ struct ComposeDown: AsyncParsableCommand { /// ``` @discardableResult func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult { - return try await withCheckedThrowingContinuation { continuation in + try await withCheckedThrowingContinuation { continuation in let process = Process() let stdoutPipe = Pipe() let stderrPipe = Pipe() @@ -123,7 +141,7 @@ struct ComposeDown: AsyncParsableCommand { process.terminationHandler = { proc in let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - + guard stderrData.isEmpty else { continuation.resume(throwing: TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self))) return diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index 898a7f837..a7663d46a 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // ComposeUp.swift // Container-Compose @@ -5,48 +21,50 @@ // Created by Morris Richman on 6/19/25. // -import Foundation import ArgumentParser import ContainerClient +import Foundation import Yams struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { static let configuration: CommandConfiguration = .init( commandName: "up", abstract: "Start containers with container-compose" - ) - - @Flag(name: [.customShort("d"), .customLong("detach")], help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") + ) + + @Flag( + name: [.customShort("d"), .customLong("detach")], + help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") var detatch: Bool = false - + @Flag(name: [.customShort("b"), .customLong("build")]) var rebuild: Bool = false - + @OptionGroup var process: Flags.Process - + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml - var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file -// + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file + // private var fileManager: FileManager { FileManager.default } private var projectName: String? - private var environmentVariables: [String : String] = [:] - private var containerIps: [String : String] = [:] - + private var environmentVariables: [String: String] = [:] + private var containerIps: [String: String] = [:] + mutating func run() async throws { // Read docker-compose.yml content guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { throw YamlError.dockerfileNotFound(dockerComposePath) } - + // Decode the YAML file into the DockerCompose struct let dockerComposeString = String(data: yamlData, encoding: .utf8)! let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - + // Load environment variables from .env file environmentVariables = loadEnvFile(path: envFilePath) - + // Handle 'version' field if let version = dockerCompose.version { print("Info: Docker Compose file version parsed as: \(version)") @@ -57,14 +75,16 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { if let name = dockerCompose.name { projectName = name print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + print( + "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." + ) } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") } - + try await stopOldStuff(remove: true) - + // Process top-level networks // This creates named networks defined in the docker-compose.yml if let networks = dockerCompose.networks { @@ -74,7 +94,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } print("--- Networks Processed ---\n") } - + // Process top-level volumes // This creates named volumes defined in the docker-compose.yml if let volumes = dockerCompose.volumes { @@ -84,39 +104,39 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } print("--- Volumes Processed ---\n") } - + // Process each service defined in the docker-compose.yml print("\n--- Processing Services ---") - + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) services = try topoSortConfiguredServices(services) - + print(services.map(\.serviceName)) for (serviceName, service) in services { try await configService(service, serviceName: serviceName, from: dockerCompose) } - + if !detatch { await waitForever() } } - + func waitForever() async -> Never { - for await _ in AsyncStream(unfolding: { }) { + for await _ in AsyncStream(unfolding: {}) { // This will never run } fatalError("unreachable") } - + func getIPForRunningService(_ serviceName: String) async throws -> String? { guard let projectName else { return nil } - + let containerName = "\(projectName)-\(serviceName)" - + // Run the container list command let containerCommandOutput = try await runCommand("container", args: ["list", "-a"]) let allLines = containerCommandOutput.stdout.components(separatedBy: .newlines) - + // Find the line matching the full container name guard let matchingLine = allLines.first(where: { $0.contains(containerName) }) else { return nil @@ -125,16 +145,17 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Extract IP using regex let pattern = #"\b(?:\d{1,3}\.){3}\d{1,3}\b"# let regex = try NSRegularExpression(pattern: pattern) - + let range = NSRange(matchingLine.startIndex.. [(serviceName: String, service: Service)] { - + var visited = Set() var visiting = Set() var sorted: [(String, Service)] = [] @@ -219,9 +242,11 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { guard let serviceTuple = services.first(where: { $0.serviceName == name }) else { return } if visiting.contains(name) { - throw NSError(domain: "ComposeError", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" - ]) + throw NSError( + domain: "ComposeError", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" + ]) } guard !visited.contains(name) else { return } @@ -242,20 +267,22 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return sorted } - + func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { guard let projectName else { return } - let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name - + let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") let volumePath = volumeUrl.path(percentEncoded: false) - - print("Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead.") + + print( + "Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + ) try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) } - + func setupNetwork(name networkName: String, config networkConfig: Network) async throws { - let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name + let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name if let externalNetwork = networkConfig.external, externalNetwork.isExternal { print("Info: Network '\(networkName)' is declared as external.") @@ -277,8 +304,8 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Add various network flags if networkConfig.attachable == true { networkCreateArgs.append("--attachable") } if networkConfig.enable_ipv6 == true { networkCreateArgs.append("--ipv6") } - if networkConfig.isInternal == true { networkCreateArgs.append("--internal") } // CORRECTED: Use isInternal - + if networkConfig.isInternal == true { networkCreateArgs.append("--internal") } // CORRECTED: Use isInternal + // Add labels if let labels = networkConfig.labels { for (labelKey, labelValue) in labels { @@ -287,7 +314,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - networkCreateArgs.append(actualNetworkName) // Add the network name + networkCreateArgs.append(actualNetworkName) // Add the network name print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") @@ -296,11 +323,11 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print("Network '\(networkName)' created or already exists.") } } - + // MARK: Compose Service Level Functions mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { guard let projectName else { throw ComposeError.invalidProjectName } - + var imageToRun: String // Handle 'build' configuration @@ -317,7 +344,9 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Handle 'deploy' configuration (note that this tool doesn't fully support it) if service.deploy != nil { print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") - print("However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands.") + print( + "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." + ) print("The service will be run as a single container based on other configurations.") } @@ -362,7 +391,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Combine environment variables from .env files and service environment var combinedEnv: [String: String] = environmentVariables - + if let envFiles = service.env_file { for envFile in envFiles { let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") @@ -372,22 +401,21 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { if let serviceEnv = service.environment { combinedEnv.merge(serviceEnv) { (old, new) in - if !new.contains("${") { - return new - } else { + guard !new.contains("${") else { return old } - } // Service env overrides .env files + return new + } // Service env overrides .env files } - + // Fill in variables combinedEnv = combinedEnv.mapValues({ value in guard value.contains("${") else { return value } - + let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) return combinedEnv[variableName] ?? value }) - + // Fill in IPs combinedEnv = combinedEnv.mapValues({ value in containerIps[value] ?? value @@ -418,8 +446,12 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append("--network") runCommandArgs.append(networkToConnect) } - print("Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml.") - print("Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level.") + print( + "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml." + ) + print( + "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." + ) } else { print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.") } @@ -450,31 +482,39 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Handle service-level configs (note: still only parsing/logging, not attaching) if let serviceConfigs = service.configs { - print("Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands.") + print( + "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.") for serviceConfig in serviceConfigs { - print(" - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))") + print( + " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" + ) } } -// + // // Handle service-level secrets (note: still only parsing/logging, not attaching) if let serviceSecrets = service.secrets { - print("Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands.") + print( + "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.") for serviceSecret in serviceSecrets { - print(" - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))") + print( + " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" + ) } } // Add interactive and TTY flags if service.stdin_open == true { - runCommandArgs.append("-i") // --interactive + runCommandArgs.append("-i") // --interactive } if service.tty == true { - runCommandArgs.append("-t") // --tty + runCommandArgs.append("-t") // --tty } - runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint + runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint // Add entrypoint or command if let entrypointParts = service.entrypoint { @@ -483,20 +523,20 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } else if let commandParts = service.command { runCommandArgs.append(contentsOf: commandParts) } - + Task { [self] in - + @Sendable func handleOutput(_ string: String) { print("\(serviceName): \(string)") } - + print("\nStarting service: \(serviceName)") print("Starting \(serviceName)") print("----------------------------------------\n") let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) } - + do { try await waitUntilServiceIsRunning(serviceName) try await updateEnvironmentWithServiceIP(serviceName) @@ -504,7 +544,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print(error) } } - + /// Builds Docker Service /// /// - Parameters: @@ -514,18 +554,18 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// /// - Returns: Image Name (`String`) func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { - + var buildCommandArgs: [String] = ["build"] // Determine image tag for built image let imageToRun = service.image ?? "\(serviceName):latest" let searchName = imageToRun.split(separator: ":").first - + let imagesList = try await runCommand("container", args: ["images", "list"]).stdout if !rebuild, let searchName, imagesList.contains(searchName) { return imageToRun } - + do { try await runCommand("container", args: ["images", "rm", imageToRun]) } catch { @@ -553,7 +593,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { buildCommandArgs.append("\(key)=\(resolvedValue)") } } - + print("\n----------------------------------------") print("Building image for service: \(serviceName) (Tag: \(imageToRun))") print("Executing container build: container \(buildCommandArgs.joined(separator: " "))") @@ -563,15 +603,15 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return imageToRun } - + func configVolume(_ volume: String) async throws -> [String] { let resolvedVolume = resolveVariable(volume, with: environmentVariables) - + var runCommandArgs: [String] = [] - + // Parse the volume string: destination[:mode] let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) - + guard components.count >= 2 else { print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") return [] @@ -579,7 +619,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { let source = components[0] let destination = components[1] - + // Check if the source looks like a host path (contains '/' or starts with '.') // This heuristic helps distinguish bind mounts from named volume references. if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { @@ -587,13 +627,13 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { var isDirectory: ObjCBool = false // Ensure the path is absolute or relative to the current directory for FileManager let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) - + if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { if isDirectory.boolValue { // Host path exists and is a directory, add the volume runCommandArgs.append("-v") // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument } else { // Host path exists but is a file print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") @@ -604,7 +644,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) print("Info: Created missing host directory for volume: \(fullHostPath)") runCommandArgs.append("-v") - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument } catch { print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") } @@ -613,19 +653,21 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { guard let projectName else { return [] } let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") let volumePath = volumeUrl.path(percentEncoded: false) - + let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() let destinationPath = destinationUrl.path(percentEncoded: false) - - print("Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead.") + + print( + "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + ) try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) - + // Host path exists and is a directory, add the volume runCommandArgs.append("-v") // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument + runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument } - + return runCommandArgs } } @@ -650,7 +692,7 @@ extension ComposeUp { /// ``` @discardableResult func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult { - return try await withCheckedThrowingContinuation { continuation in + try await withCheckedThrowingContinuation { continuation in let process = Process() let stdoutPipe = Pipe() let stderrPipe = Pipe() @@ -676,7 +718,7 @@ extension ComposeUp { process.terminationHandler = { proc in let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - + guard stderrData.isEmpty else { continuation.resume(throwing: TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self))) return @@ -692,7 +734,7 @@ extension ComposeUp { } } } - + /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. /// /// - Parameters: @@ -709,7 +751,7 @@ extension ComposeUp { onStdout: @escaping (@Sendable (String) -> Void), onStderr: @escaping (@Sendable (String) -> Void) ) async throws -> Int32 { - return try await withCheckedThrowingContinuation { continuation in + try await withCheckedThrowingContinuation { continuation in let process = Process() let stdoutPipe = Pipe() let stderrPipe = Pipe() diff --git a/Sources/CLI/Compose/ComposeCommand.swift b/Sources/CLI/Compose/ComposeCommand.swift index 7f6807b86..be0e18d7e 100644 --- a/Sources/CLI/Compose/ComposeCommand.swift +++ b/Sources/CLI/Compose/ComposeCommand.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // File.swift // Container-Compose @@ -5,9 +21,9 @@ // Created by Morris Richman on 6/18/25. // +import ArgumentParser import Foundation import Yams -import ArgumentParser struct ComposeCommand: AsyncParsableCommand { static let configuration: CommandConfiguration = .init( @@ -15,7 +31,7 @@ struct ComposeCommand: AsyncParsableCommand { abstract: "A tool to use manage Docker Compose files with Apple Container", subcommands: [ ComposeUp.self, - ComposeDown.self + ComposeDown.self, ]) } diff --git a/Sources/CLI/Compose/Errors.swift b/Sources/CLI/Compose/Errors.swift index 32ac5c8cc..030182d60 100644 --- a/Sources/CLI/Compose/Errors.swift +++ b/Sources/CLI/Compose/Errors.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // Errors.swift // Container-Compose @@ -9,7 +25,7 @@ import Foundation enum YamlError: Error, LocalizedError { case dockerfileNotFound(String) - + var errorDescription: String? { switch self { case .dockerfileNotFound(let path): @@ -21,7 +37,7 @@ enum YamlError: Error, LocalizedError { enum ComposeError: Error, LocalizedError { case imageNotFound(String) case invalidProjectName - + var errorDescription: String? { switch self { case .imageNotFound(let name): @@ -34,9 +50,9 @@ enum ComposeError: Error, LocalizedError { enum TerminalError: Error, LocalizedError { case commandFailed(String) - + var errorDescription: String? { - return "Command failed: \(self)" + "Command failed: \(self)" } } diff --git a/Sources/CLI/Compose/Helper Functions.swift b/Sources/CLI/Compose/Helper Functions.swift index 50549fdd9..acfbb7b55 100644 --- a/Sources/CLI/Compose/Helper Functions.swift +++ b/Sources/CLI/Compose/Helper Functions.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // Helper Functions.swift // container-compose-app From 3f8a30a04d4b0bab126bf9383464feb44dfd4016 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Thu, 19 Jun 2025 15:05:00 -0700 Subject: [PATCH 04/80] update compose command entrypoint --- Sources/CLI/Application.swift | 9 +-------- Sources/CLI/Compose/ComposeCommand.swift | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/Sources/CLI/Application.swift b/Sources/CLI/Application.swift index b57dc0c05..8069501b4 100644 --- a/Sources/CLI/Application.swift +++ b/Sources/CLI/Application.swift @@ -56,6 +56,7 @@ struct Application: AsyncParsableCommand { CommandGroup( name: "Container", subcommands: [ + ComposeCommand.self, ContainerCreate.self, ContainerDelete.self, ContainerExec.self, @@ -83,14 +84,6 @@ struct Application: AsyncParsableCommand { SystemCommand.self, ] ), - CommandGroup( - name: "Compose", - subcommands: [ - ComposeCommand.self, - ComposeUp.self, - ComposeDown.self, - ] - ), ], // Hidden command to handle plugins on unrecognized input. defaultSubcommand: DefaultCommand.self diff --git a/Sources/CLI/Compose/ComposeCommand.swift b/Sources/CLI/Compose/ComposeCommand.swift index be0e18d7e..116b8bfd7 100644 --- a/Sources/CLI/Compose/ComposeCommand.swift +++ b/Sources/CLI/Compose/ComposeCommand.swift @@ -28,7 +28,7 @@ import Yams struct ComposeCommand: AsyncParsableCommand { static let configuration: CommandConfiguration = .init( commandName: "compose", - abstract: "A tool to use manage Docker Compose files with Apple Container", + abstract: "Manage containers with Docker Compose files", subcommands: [ ComposeUp.self, ComposeDown.self, From d69c7b0e159d28cdb703282eb713f0b0e62bbef6 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:14:36 -0700 Subject: [PATCH 05/80] updated to fix image pulling and add color coding to image operations and container logs --- Package.resolved | 11 +- Package.swift | 2 + .../CLI/Compose/Commands/ComposeDown.swift | 46 +-- Sources/CLI/Compose/Commands/ComposeUp.swift | 295 +++++++++--------- Sources/CLI/Compose/ComposeCommand.swift | 5 + 5 files changed, 173 insertions(+), 186 deletions(-) diff --git a/Package.resolved b/Package.resolved index f288eaed6..f903a4945 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fbfb4c6151c110549642a5b6b3bd47eda56b349c4c98c09e34f4451a35e18a23", + "originHash" : "50469d515f99e29eddf38c444297a18f0e4f79cee31feb12f731b69e64d827c5", "pins" : [ { "identity" : "async-http-client", @@ -46,6 +46,15 @@ "version" : "1.26.1" } }, + { + "identity" : "rainbow", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Rainbow", + "state" : { + "revision" : "0c627a4f8a39ef37eadec1ceec02e4a7f55561ac", + "version" : "4.1.0" + } + }, { "identity" : "swift-algorithms", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 991cd5f2c..30d7b8db4 100644 --- a/Package.swift +++ b/Package.swift @@ -59,6 +59,7 @@ let package = Package( .package(url: "https://github.com/orlandos-nl/DNSClient.git", from: "2.4.1"), .package(url: "https://github.com/Bouke/DNS.git", from: "1.2.0"), .package(url: "https://github.com/jpsim/Yams.git", from: "5.0.6"), + .package(url: "https://github.com/onevcat/Rainbow", .upToNextMajor(from: "4.0.0")), scDependency, ], targets: [ @@ -78,6 +79,7 @@ let package = Package( "ContainerPlugin", "ContainerLog", "Yams", + "Rainbow", ], path: "Sources/CLI" ), diff --git a/Sources/CLI/Compose/Commands/ComposeDown.swift b/Sources/CLI/Compose/Commands/ComposeDown.swift index 0ad554f80..6663b8961 100644 --- a/Sources/CLI/Compose/Commands/ComposeDown.swift +++ b/Sources/CLI/Compose/Commands/ComposeDown.swift @@ -1,19 +1,3 @@ -//===----------------------------------------------------------------------===// -// 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. -//===----------------------------------------------------------------------===// - // // ComposeDown.swift // Container-Compose @@ -21,9 +5,9 @@ // Created by Morris Richman on 6/19/25. // -import ArgumentParser import ContainerClient import Foundation +import ArgumentParser import Yams struct ComposeDown: AsyncParsableCommand { @@ -41,32 +25,30 @@ struct ComposeDown: AsyncParsableCommand { private var fileManager: FileManager { FileManager.default } private var projectName: String? - + mutating func run() async throws { // Read docker-compose.yml content guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { throw YamlError.dockerfileNotFound(dockerComposePath) } - + // Decode the YAML file into the DockerCompose struct let dockerComposeString = String(data: yamlData, encoding: .utf8)! let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - + // Determine project name for container naming if let name = dockerCompose.name { projectName = name print("Info: Docker Compose project name parsed as: \(name)") - print( - "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." - ) + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") } - + try await stopOldStuff(remove: false) } - + /// Returns the names of all containers whose names start with a given prefix. /// - Parameter prefix: The container name prefix (e.g. `"Assignment"`). /// - Returns: An array of matching container names. @@ -81,13 +63,13 @@ struct ComposeDown: AsyncParsableCommand { return name.hasPrefix(prefix) ? String(name) : nil } } - + func stopOldStuff(remove: Bool) async throws { guard let projectName else { return } let containers = try await getContainersWithPrefix(projectName) - + for container in containers { - print("Removing old container: \(container)") + print("Stopping container: \(container)") do { try await runCommand("container", args: ["stop", container]) if remove { @@ -97,7 +79,7 @@ struct ComposeDown: AsyncParsableCommand { } } } - + /// Runs a command-line tool asynchronously and captures its output and exit code. /// /// This function uses async/await and `Process` to launch a command-line tool, @@ -115,7 +97,7 @@ struct ComposeDown: AsyncParsableCommand { /// ``` @discardableResult func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult { - try await withCheckedThrowingContinuation { continuation in + return try await withCheckedThrowingContinuation { continuation in let process = Process() let stdoutPipe = Pipe() let stderrPipe = Pipe() @@ -141,7 +123,7 @@ struct ComposeDown: AsyncParsableCommand { process.terminationHandler = { proc in let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - + guard stderrData.isEmpty else { continuation.resume(throwing: TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self))) return diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index a7663d46a..df71869fd 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -1,19 +1,3 @@ -//===----------------------------------------------------------------------===// -// 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. -//===----------------------------------------------------------------------===// - // // ComposeUp.swift // Container-Compose @@ -21,22 +5,21 @@ // Created by Morris Richman on 6/19/25. // +import Foundation import ArgumentParser import ContainerClient -import Foundation import Yams +@preconcurrency import Rainbow struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { static let configuration: CommandConfiguration = .init( commandName: "up", abstract: "Start containers with container-compose" - ) - - @Flag( - name: [.customShort("d"), .customLong("detach")], - help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") + ) + + @Flag(name: [.customShort("d"), .customLong("detach")], help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") var detatch: Bool = false - + @Flag(name: [.customShort("b"), .customLong("build")]) var rebuild: Bool = false @@ -49,22 +32,27 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // private var fileManager: FileManager { FileManager.default } private var projectName: String? - private var environmentVariables: [String: String] = [:] - private var containerIps: [String: String] = [:] - + private var environmentVariables: [String : String] = [:] + private var containerIps: [String : String] = [:] + private var containerConsoleColors: [String : NamedColor] = [:] + + private static let availableContainerConsoleColors: Set = [ + .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green + ] + mutating func run() async throws { // Read docker-compose.yml content guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { throw YamlError.dockerfileNotFound(dockerComposePath) } - + // Decode the YAML file into the DockerCompose struct let dockerComposeString = String(data: yamlData, encoding: .utf8)! let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - + // Load environment variables from .env file environmentVariables = loadEnvFile(path: envFilePath) - + // Handle 'version' field if let version = dockerCompose.version { print("Info: Docker Compose file version parsed as: \(version)") @@ -75,16 +63,14 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { if let name = dockerCompose.name { projectName = name print("Info: Docker Compose project name parsed as: \(name)") - print( - "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." - ) + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") } - + try await stopOldStuff(remove: true) - + // Process top-level networks // This creates named networks defined in the docker-compose.yml if let networks = dockerCompose.networks { @@ -94,7 +80,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } print("--- Networks Processed ---\n") } - + // Process top-level volumes // This creates named volumes defined in the docker-compose.yml if let volumes = dockerCompose.volumes { @@ -104,39 +90,39 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } print("--- Volumes Processed ---\n") } - + // Process each service defined in the docker-compose.yml print("\n--- Processing Services ---") - + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) services = try topoSortConfiguredServices(services) - + print(services.map(\.serviceName)) for (serviceName, service) in services { try await configService(service, serviceName: serviceName, from: dockerCompose) } - + if !detatch { await waitForever() } } - + func waitForever() async -> Never { - for await _ in AsyncStream(unfolding: {}) { + for await _ in AsyncStream(unfolding: { }) { // This will never run } fatalError("unreachable") } - + func getIPForRunningService(_ serviceName: String) async throws -> String? { guard let projectName else { return nil } - + let containerName = "\(projectName)-\(serviceName)" - + // Run the container list command let containerCommandOutput = try await runCommand("container", args: ["list", "-a"]) let allLines = containerCommandOutput.stdout.components(separatedBy: .newlines) - + // Find the line matching the full container name guard let matchingLine = allLines.first(where: { $0.contains(containerName) }) else { return nil @@ -145,17 +131,16 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Extract IP using regex let pattern = #"\b(?:\d{1,3}\.){3}\d{1,3}\b"# let regex = try NSRegularExpression(pattern: pattern) - + let range = NSRange(matchingLine.startIndex.. [(serviceName: String, service: Service)] { - + var visited = Set() var visiting = Set() var sorted: [(String, Service)] = [] @@ -242,11 +225,9 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { guard let serviceTuple = services.first(where: { $0.serviceName == name }) else { return } if visiting.contains(name) { - throw NSError( - domain: "ComposeError", code: 1, - userInfo: [ - NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" - ]) + throw NSError(domain: "ComposeError", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" + ]) } guard !visited.contains(name) else { return } @@ -267,22 +248,20 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return sorted } - + func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { guard let projectName else { return } - let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name - + let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") let volumePath = volumeUrl.path(percentEncoded: false) - - print( - "Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." - ) + + print("Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead.") try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) } - + func setupNetwork(name networkName: String, config networkConfig: Network) async throws { - let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name + let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name if let externalNetwork = networkConfig.external, externalNetwork.isExternal { print("Info: Network '\(networkName)' is declared as external.") @@ -304,8 +283,8 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Add various network flags if networkConfig.attachable == true { networkCreateArgs.append("--attachable") } if networkConfig.enable_ipv6 == true { networkCreateArgs.append("--ipv6") } - if networkConfig.isInternal == true { networkCreateArgs.append("--internal") } // CORRECTED: Use isInternal - + if networkConfig.isInternal == true { networkCreateArgs.append("--internal") } // CORRECTED: Use isInternal + // Add labels if let labels = networkConfig.labels { for (labelKey, labelValue) in labels { @@ -314,7 +293,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - networkCreateArgs.append(actualNetworkName) // Add the network name + networkCreateArgs.append(actualNetworkName) // Add the network name print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") @@ -323,11 +302,11 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print("Network '\(networkName)' created or already exists.") } } - + // MARK: Compose Service Level Functions mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { guard let projectName else { throw ComposeError.invalidProjectName } - + var imageToRun: String // Handle 'build' configuration @@ -335,7 +314,9 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) } else if let img = service.image { // Use specified image if no build config - imageToRun = resolveVariable(img, with: environmentVariables) + // Pull image if necessary + try await pullImage(img) + imageToRun = img } else { // Should not happen due to Service init validation, but as a fallback throw ComposeError.imageNotFound(serviceName) @@ -344,9 +325,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Handle 'deploy' configuration (note that this tool doesn't fully support it) if service.deploy != nil { print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") - print( - "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." - ) + print("However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands.") print("The service will be run as a single container based on other configurations.") } @@ -391,7 +370,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Combine environment variables from .env files and service environment var combinedEnv: [String: String] = environmentVariables - + if let envFiles = service.env_file { for envFile in envFiles { let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") @@ -401,21 +380,22 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { if let serviceEnv = service.environment { combinedEnv.merge(serviceEnv) { (old, new) in - guard !new.contains("${") else { + if !new.contains("${") { + return new + } else { return old } - return new - } // Service env overrides .env files + } // Service env overrides .env files } - + // Fill in variables combinedEnv = combinedEnv.mapValues({ value in guard value.contains("${") else { return value } - + let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) return combinedEnv[variableName] ?? value }) - + // Fill in IPs combinedEnv = combinedEnv.mapValues({ value in containerIps[value] ?? value @@ -446,12 +426,8 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append("--network") runCommandArgs.append(networkToConnect) } - print( - "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml." - ) - print( - "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." - ) + print("Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml.") + print("Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level.") } else { print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.") } @@ -482,39 +458,31 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Handle service-level configs (note: still only parsing/logging, not attaching) if let serviceConfigs = service.configs { - print( - "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." - ) + print("Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands.") print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.") for serviceConfig in serviceConfigs { - print( - " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" - ) + print(" - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))") } } - // +// // Handle service-level secrets (note: still only parsing/logging, not attaching) if let serviceSecrets = service.secrets { - print( - "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." - ) + print("Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands.") print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.") for serviceSecret in serviceSecrets { - print( - " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" - ) + print(" - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))") } } // Add interactive and TTY flags if service.stdin_open == true { - runCommandArgs.append("-i") // --interactive + runCommandArgs.append("-i") // --interactive } if service.tty == true { - runCommandArgs.append("-t") // --tty + runCommandArgs.append("-t") // --tty } - runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint + runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint // Add entrypoint or command if let entrypointParts = service.entrypoint { @@ -523,20 +491,29 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } else if let commandParts = service.command { runCommandArgs.append(contentsOf: commandParts) } - - Task { [self] in - + + var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! + + if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { + while containerConsoleColors.values.contains(serviceColor) { + serviceColor = Self.availableContainerConsoleColors.randomElement()! + } + } + + self.containerConsoleColors[serviceName] = serviceColor + + Task { [self, serviceColor] in @Sendable - func handleOutput(_ string: String) { - print("\(serviceName): \(string)") + func handleOutput(_ output: String) { + print("\(serviceName): \(output)".applyingColor(serviceColor)) } - + print("\nStarting service: \(serviceName)") print("Starting \(serviceName)") print("----------------------------------------\n") let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) } - + do { try await waitUntilServiceIsRunning(serviceName) try await updateEnvironmentWithServiceIP(serviceName) @@ -544,7 +521,21 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print(error) } } - + + func pullImage(_ image: String) async throws { + let imageList = try await runCommand("container", args: ["images", "ls"]).stdout.replacingOccurrences(of: " ", with: "") + guard !imageList.contains(image.replacingOccurrences(of: ":", with: "")) else { + return + } + + print("Pulling Image \(image)...") + try await streamCommand("container", args: ["image", "pull", image]) { str in + print(str.blue) + } onStderr: { str in + print(str.red) + } + } + /// Builds Docker Service /// /// - Parameters: @@ -554,18 +545,18 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// /// - Returns: Image Name (`String`) func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { - + var buildCommandArgs: [String] = ["build"] // Determine image tag for built image let imageToRun = service.image ?? "\(serviceName):latest" let searchName = imageToRun.split(separator: ":").first - + let imagesList = try await runCommand("container", args: ["images", "list"]).stdout if !rebuild, let searchName, imagesList.contains(searchName) { return imageToRun } - + do { try await runCommand("container", args: ["images", "rm", imageToRun]) } catch { @@ -593,25 +584,25 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { buildCommandArgs.append("\(key)=\(resolvedValue)") } } - + print("\n----------------------------------------") print("Building image for service: \(serviceName) (Tag: \(imageToRun))") print("Executing container build: container \(buildCommandArgs.joined(separator: " "))") - try await streamCommand("container", args: buildCommandArgs, onStdout: { print($0) }, onStderr: { print($0) }) + try await streamCommand("container", args: buildCommandArgs, onStdout: { print($0.blue) }, onStderr: { print($0.blue) }) print("Image build for \(serviceName) completed.") print("----------------------------------------") return imageToRun } - + func configVolume(_ volume: String) async throws -> [String] { let resolvedVolume = resolveVariable(volume, with: environmentVariables) - + var runCommandArgs: [String] = [] - + // Parse the volume string: destination[:mode] let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) - + guard components.count >= 2 else { print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") return [] @@ -619,7 +610,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { let source = components[0] let destination = components[1] - + // Check if the source looks like a host path (contains '/' or starts with '.') // This heuristic helps distinguish bind mounts from named volume references. if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { @@ -627,13 +618,13 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { var isDirectory: ObjCBool = false // Ensure the path is absolute or relative to the current directory for FileManager let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) - + if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { if isDirectory.boolValue { // Host path exists and is a directory, add the volume runCommandArgs.append("-v") // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument } else { // Host path exists but is a file print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") @@ -644,7 +635,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) print("Info: Created missing host directory for volume: \(fullHostPath)") runCommandArgs.append("-v") - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument } catch { print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") } @@ -653,21 +644,19 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { guard let projectName else { return [] } let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") let volumePath = volumeUrl.path(percentEncoded: false) - + let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() let destinationPath = destinationUrl.path(percentEncoded: false) - - print( - "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." - ) + + print("Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead.") try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) - + // Host path exists and is a directory, add the volume runCommandArgs.append("-v") // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument + runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument } - + return runCommandArgs } } @@ -692,7 +681,7 @@ extension ComposeUp { /// ``` @discardableResult func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult { - try await withCheckedThrowingContinuation { continuation in + return try await withCheckedThrowingContinuation { continuation in let process = Process() let stdoutPipe = Pipe() let stderrPipe = Pipe() @@ -718,7 +707,7 @@ extension ComposeUp { process.terminationHandler = { proc in let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - + guard stderrData.isEmpty else { continuation.resume(throwing: TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self))) return @@ -734,7 +723,7 @@ extension ComposeUp { } } } - + /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. /// /// - Parameters: @@ -751,7 +740,7 @@ extension ComposeUp { onStdout: @escaping (@Sendable (String) -> Void), onStderr: @escaping (@Sendable (String) -> Void) ) async throws -> Int32 { - try await withCheckedThrowingContinuation { continuation in + return try await withCheckedThrowingContinuation { continuation in let process = Process() let stdoutPipe = Pipe() let stderrPipe = Pipe() diff --git a/Sources/CLI/Compose/ComposeCommand.swift b/Sources/CLI/Compose/ComposeCommand.swift index 116b8bfd7..a8867307c 100644 --- a/Sources/CLI/Compose/ComposeCommand.swift +++ b/Sources/CLI/Compose/ComposeCommand.swift @@ -24,6 +24,7 @@ import ArgumentParser import Foundation import Yams +import Rainbow struct ComposeCommand: AsyncParsableCommand { static let configuration: CommandConfiguration = .init( @@ -46,3 +47,7 @@ struct CommandResult { /// The exit code returned by the process upon termination. let exitCode: Int32 } + +extension NamedColor: Codable { + +} From 7a74024365cbda967fd846079252638a50ddd76e Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:20:09 -0700 Subject: [PATCH 06/80] update formatting --- .../CLI/Compose/Commands/ComposeDown.swift | 44 ++- Sources/CLI/Compose/Commands/ComposeUp.swift | 270 ++++++++++-------- Sources/CLI/Compose/ComposeCommand.swift | 4 +- 3 files changed, 189 insertions(+), 129 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeDown.swift b/Sources/CLI/Compose/Commands/ComposeDown.swift index 6663b8961..66cb9521b 100644 --- a/Sources/CLI/Compose/Commands/ComposeDown.swift +++ b/Sources/CLI/Compose/Commands/ComposeDown.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // ComposeDown.swift // Container-Compose @@ -5,9 +21,9 @@ // Created by Morris Richman on 6/19/25. // +import ArgumentParser import ContainerClient import Foundation -import ArgumentParser import Yams struct ComposeDown: AsyncParsableCommand { @@ -25,30 +41,32 @@ struct ComposeDown: AsyncParsableCommand { private var fileManager: FileManager { FileManager.default } private var projectName: String? - + mutating func run() async throws { // Read docker-compose.yml content guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { throw YamlError.dockerfileNotFound(dockerComposePath) } - + // Decode the YAML file into the DockerCompose struct let dockerComposeString = String(data: yamlData, encoding: .utf8)! let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - + // Determine project name for container naming if let name = dockerCompose.name { projectName = name print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + print( + "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." + ) } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") } - + try await stopOldStuff(remove: false) } - + /// Returns the names of all containers whose names start with a given prefix. /// - Parameter prefix: The container name prefix (e.g. `"Assignment"`). /// - Returns: An array of matching container names. @@ -63,11 +81,11 @@ struct ComposeDown: AsyncParsableCommand { return name.hasPrefix(prefix) ? String(name) : nil } } - + func stopOldStuff(remove: Bool) async throws { guard let projectName else { return } let containers = try await getContainersWithPrefix(projectName) - + for container in containers { print("Stopping container: \(container)") do { @@ -79,7 +97,7 @@ struct ComposeDown: AsyncParsableCommand { } } } - + /// Runs a command-line tool asynchronously and captures its output and exit code. /// /// This function uses async/await and `Process` to launch a command-line tool, @@ -97,7 +115,7 @@ struct ComposeDown: AsyncParsableCommand { /// ``` @discardableResult func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult { - return try await withCheckedThrowingContinuation { continuation in + try await withCheckedThrowingContinuation { continuation in let process = Process() let stdoutPipe = Pipe() let stderrPipe = Pipe() @@ -123,7 +141,7 @@ struct ComposeDown: AsyncParsableCommand { process.terminationHandler = { proc in let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - + guard stderrData.isEmpty else { continuation.resume(throwing: TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self))) return diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index df71869fd..11d02b207 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + // // ComposeUp.swift // Container-Compose @@ -5,21 +21,23 @@ // Created by Morris Richman on 6/19/25. // -import Foundation import ArgumentParser import ContainerClient -import Yams +import Foundation @preconcurrency import Rainbow +import Yams struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { static let configuration: CommandConfiguration = .init( commandName: "up", abstract: "Start containers with container-compose" - ) - - @Flag(name: [.customShort("d"), .customLong("detach")], help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") + ) + + @Flag( + name: [.customShort("d"), .customLong("detach")], + help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") var detatch: Bool = false - + @Flag(name: [.customShort("b"), .customLong("build")]) var rebuild: Bool = false @@ -32,27 +50,27 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // private var fileManager: FileManager { FileManager.default } private var projectName: String? - private var environmentVariables: [String : String] = [:] - private var containerIps: [String : String] = [:] - private var containerConsoleColors: [String : NamedColor] = [:] - + private var environmentVariables: [String: String] = [:] + private var containerIps: [String: String] = [:] + private var containerConsoleColors: [String: NamedColor] = [:] + private static let availableContainerConsoleColors: Set = [ - .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green + .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, ] - + mutating func run() async throws { // Read docker-compose.yml content guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { throw YamlError.dockerfileNotFound(dockerComposePath) } - + // Decode the YAML file into the DockerCompose struct let dockerComposeString = String(data: yamlData, encoding: .utf8)! let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - + // Load environment variables from .env file environmentVariables = loadEnvFile(path: envFilePath) - + // Handle 'version' field if let version = dockerCompose.version { print("Info: Docker Compose file version parsed as: \(version)") @@ -63,14 +81,16 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { if let name = dockerCompose.name { projectName = name print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + print( + "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." + ) } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") } - + try await stopOldStuff(remove: true) - + // Process top-level networks // This creates named networks defined in the docker-compose.yml if let networks = dockerCompose.networks { @@ -80,7 +100,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } print("--- Networks Processed ---\n") } - + // Process top-level volumes // This creates named volumes defined in the docker-compose.yml if let volumes = dockerCompose.volumes { @@ -90,39 +110,39 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } print("--- Volumes Processed ---\n") } - + // Process each service defined in the docker-compose.yml print("\n--- Processing Services ---") - + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) services = try topoSortConfiguredServices(services) - + print(services.map(\.serviceName)) for (serviceName, service) in services { try await configService(service, serviceName: serviceName, from: dockerCompose) } - + if !detatch { await waitForever() } } - + func waitForever() async -> Never { - for await _ in AsyncStream(unfolding: { }) { + for await _ in AsyncStream(unfolding: {}) { // This will never run } fatalError("unreachable") } - + func getIPForRunningService(_ serviceName: String) async throws -> String? { guard let projectName else { return nil } - + let containerName = "\(projectName)-\(serviceName)" - + // Run the container list command let containerCommandOutput = try await runCommand("container", args: ["list", "-a"]) let allLines = containerCommandOutput.stdout.components(separatedBy: .newlines) - + // Find the line matching the full container name guard let matchingLine = allLines.first(where: { $0.contains(containerName) }) else { return nil @@ -131,16 +151,17 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Extract IP using regex let pattern = #"\b(?:\d{1,3}\.){3}\d{1,3}\b"# let regex = try NSRegularExpression(pattern: pattern) - + let range = NSRange(matchingLine.startIndex.. [(serviceName: String, service: Service)] { - + var visited = Set() var visiting = Set() var sorted: [(String, Service)] = [] @@ -225,9 +248,11 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { guard let serviceTuple = services.first(where: { $0.serviceName == name }) else { return } if visiting.contains(name) { - throw NSError(domain: "ComposeError", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" - ]) + throw NSError( + domain: "ComposeError", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" + ]) } guard !visited.contains(name) else { return } @@ -248,20 +273,22 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return sorted } - + func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { guard let projectName else { return } - let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name - + let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") let volumePath = volumeUrl.path(percentEncoded: false) - - print("Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead.") + + print( + "Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + ) try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) } - + func setupNetwork(name networkName: String, config networkConfig: Network) async throws { - let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name + let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name if let externalNetwork = networkConfig.external, externalNetwork.isExternal { print("Info: Network '\(networkName)' is declared as external.") @@ -283,8 +310,8 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Add various network flags if networkConfig.attachable == true { networkCreateArgs.append("--attachable") } if networkConfig.enable_ipv6 == true { networkCreateArgs.append("--ipv6") } - if networkConfig.isInternal == true { networkCreateArgs.append("--internal") } // CORRECTED: Use isInternal - + if networkConfig.isInternal == true { networkCreateArgs.append("--internal") } // CORRECTED: Use isInternal + // Add labels if let labels = networkConfig.labels { for (labelKey, labelValue) in labels { @@ -293,7 +320,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - networkCreateArgs.append(actualNetworkName) // Add the network name + networkCreateArgs.append(actualNetworkName) // Add the network name print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") @@ -302,11 +329,11 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print("Network '\(networkName)' created or already exists.") } } - + // MARK: Compose Service Level Functions mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { guard let projectName else { throw ComposeError.invalidProjectName } - + var imageToRun: String // Handle 'build' configuration @@ -325,7 +352,9 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Handle 'deploy' configuration (note that this tool doesn't fully support it) if service.deploy != nil { print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") - print("However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands.") + print( + "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." + ) print("The service will be run as a single container based on other configurations.") } @@ -370,7 +399,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Combine environment variables from .env files and service environment var combinedEnv: [String: String] = environmentVariables - + if let envFiles = service.env_file { for envFile in envFiles { let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") @@ -380,22 +409,21 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { if let serviceEnv = service.environment { combinedEnv.merge(serviceEnv) { (old, new) in - if !new.contains("${") { - return new - } else { + guard !new.contains("${") else { return old } - } // Service env overrides .env files + return new + } // Service env overrides .env files } - + // Fill in variables combinedEnv = combinedEnv.mapValues({ value in guard value.contains("${") else { return value } - + let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) return combinedEnv[variableName] ?? value }) - + // Fill in IPs combinedEnv = combinedEnv.mapValues({ value in containerIps[value] ?? value @@ -426,8 +454,12 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append("--network") runCommandArgs.append(networkToConnect) } - print("Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml.") - print("Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level.") + print( + "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml." + ) + print( + "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." + ) } else { print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.") } @@ -458,31 +490,39 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Handle service-level configs (note: still only parsing/logging, not attaching) if let serviceConfigs = service.configs { - print("Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands.") + print( + "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.") for serviceConfig in serviceConfigs { - print(" - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))") + print( + " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" + ) } } -// + // // Handle service-level secrets (note: still only parsing/logging, not attaching) if let serviceSecrets = service.secrets { - print("Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands.") + print( + "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.") for serviceSecret in serviceSecrets { - print(" - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))") + print( + " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" + ) } } // Add interactive and TTY flags if service.stdin_open == true { - runCommandArgs.append("-i") // --interactive + runCommandArgs.append("-i") // --interactive } if service.tty == true { - runCommandArgs.append("-t") // --tty + runCommandArgs.append("-t") // --tty } - runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint + runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint // Add entrypoint or command if let entrypointParts = service.entrypoint { @@ -491,29 +531,29 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } else if let commandParts = service.command { runCommandArgs.append(contentsOf: commandParts) } - + var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! - + if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { while containerConsoleColors.values.contains(serviceColor) { serviceColor = Self.availableContainerConsoleColors.randomElement()! } } - + self.containerConsoleColors[serviceName] = serviceColor - + Task { [self, serviceColor] in @Sendable func handleOutput(_ output: String) { print("\(serviceName): \(output)".applyingColor(serviceColor)) } - + print("\nStarting service: \(serviceName)") print("Starting \(serviceName)") print("----------------------------------------\n") let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) } - + do { try await waitUntilServiceIsRunning(serviceName) try await updateEnvironmentWithServiceIP(serviceName) @@ -521,13 +561,13 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print(error) } } - + func pullImage(_ image: String) async throws { let imageList = try await runCommand("container", args: ["images", "ls"]).stdout.replacingOccurrences(of: " ", with: "") guard !imageList.contains(image.replacingOccurrences(of: ":", with: "")) else { return } - + print("Pulling Image \(image)...") try await streamCommand("container", args: ["image", "pull", image]) { str in print(str.blue) @@ -535,7 +575,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print(str.red) } } - + /// Builds Docker Service /// /// - Parameters: @@ -545,18 +585,18 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// /// - Returns: Image Name (`String`) func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { - + var buildCommandArgs: [String] = ["build"] // Determine image tag for built image let imageToRun = service.image ?? "\(serviceName):latest" let searchName = imageToRun.split(separator: ":").first - + let imagesList = try await runCommand("container", args: ["images", "list"]).stdout if !rebuild, let searchName, imagesList.contains(searchName) { return imageToRun } - + do { try await runCommand("container", args: ["images", "rm", imageToRun]) } catch { @@ -584,7 +624,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { buildCommandArgs.append("\(key)=\(resolvedValue)") } } - + print("\n----------------------------------------") print("Building image for service: \(serviceName) (Tag: \(imageToRun))") print("Executing container build: container \(buildCommandArgs.joined(separator: " "))") @@ -594,15 +634,15 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return imageToRun } - + func configVolume(_ volume: String) async throws -> [String] { let resolvedVolume = resolveVariable(volume, with: environmentVariables) - + var runCommandArgs: [String] = [] - + // Parse the volume string: destination[:mode] let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) - + guard components.count >= 2 else { print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") return [] @@ -610,7 +650,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { let source = components[0] let destination = components[1] - + // Check if the source looks like a host path (contains '/' or starts with '.') // This heuristic helps distinguish bind mounts from named volume references. if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { @@ -618,13 +658,13 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { var isDirectory: ObjCBool = false // Ensure the path is absolute or relative to the current directory for FileManager let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) - + if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { if isDirectory.boolValue { // Host path exists and is a directory, add the volume runCommandArgs.append("-v") // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument } else { // Host path exists but is a file print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") @@ -635,7 +675,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) print("Info: Created missing host directory for volume: \(fullHostPath)") runCommandArgs.append("-v") - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument } catch { print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") } @@ -644,19 +684,21 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { guard let projectName else { return [] } let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") let volumePath = volumeUrl.path(percentEncoded: false) - + let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() let destinationPath = destinationUrl.path(percentEncoded: false) - - print("Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead.") + + print( + "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + ) try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) - + // Host path exists and is a directory, add the volume runCommandArgs.append("-v") // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument + runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument } - + return runCommandArgs } } @@ -681,7 +723,7 @@ extension ComposeUp { /// ``` @discardableResult func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult { - return try await withCheckedThrowingContinuation { continuation in + try await withCheckedThrowingContinuation { continuation in let process = Process() let stdoutPipe = Pipe() let stderrPipe = Pipe() @@ -707,7 +749,7 @@ extension ComposeUp { process.terminationHandler = { proc in let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - + guard stderrData.isEmpty else { continuation.resume(throwing: TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self))) return @@ -723,7 +765,7 @@ extension ComposeUp { } } } - + /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. /// /// - Parameters: @@ -740,7 +782,7 @@ extension ComposeUp { onStdout: @escaping (@Sendable (String) -> Void), onStderr: @escaping (@Sendable (String) -> Void) ) async throws -> Int32 { - return try await withCheckedThrowingContinuation { continuation in + try await withCheckedThrowingContinuation { continuation in let process = Process() let stdoutPipe = Pipe() let stderrPipe = Pipe() diff --git a/Sources/CLI/Compose/ComposeCommand.swift b/Sources/CLI/Compose/ComposeCommand.swift index a8867307c..964b8f958 100644 --- a/Sources/CLI/Compose/ComposeCommand.swift +++ b/Sources/CLI/Compose/ComposeCommand.swift @@ -23,8 +23,8 @@ import ArgumentParser import Foundation -import Yams import Rainbow +import Yams struct ComposeCommand: AsyncParsableCommand { static let configuration: CommandConfiguration = .init( @@ -49,5 +49,5 @@ struct CommandResult { } extension NamedColor: Codable { - + } From 88ce26c31f25713b180c625eae66f371f11ed248 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:26:23 -0700 Subject: [PATCH 07/80] update descriptions --- Sources/CLI/Compose/Commands/ComposeDown.swift | 2 +- Sources/CLI/Compose/Commands/ComposeUp.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeDown.swift b/Sources/CLI/Compose/Commands/ComposeDown.swift index 66cb9521b..19bf20616 100644 --- a/Sources/CLI/Compose/Commands/ComposeDown.swift +++ b/Sources/CLI/Compose/Commands/ComposeDown.swift @@ -29,7 +29,7 @@ import Yams struct ComposeDown: AsyncParsableCommand { static let configuration: CommandConfiguration = .init( commandName: "down", - abstract: "Stop containers with container-compose" + abstract: "Stop containers with compose" ) @OptionGroup diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index 11d02b207..87e89509a 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -30,7 +30,7 @@ import Yams struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { static let configuration: CommandConfiguration = .init( commandName: "up", - abstract: "Start containers with container-compose" + abstract: "Start containers with compose" ) @Flag( From c39a58a39ea315e7d92581e2ec2df16ddb49a46a Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:21:05 -0700 Subject: [PATCH 08/80] update compose codable structs to have docc documentation --- .../CLI/Compose/Codable Structs/Build.swift | 11 +- .../CLI/Compose/Codable Structs/Config.swift | 14 +- .../CLI/Compose/Codable Structs/Deploy.swift | 12 +- .../Codable Structs/DeployResources.swift | 6 +- .../Codable Structs/DeployRestartPolicy.swift | 12 +- .../Codable Structs/DeviceReservation.swift | 12 +- .../Codable Structs/DockerCompose.swift | 21 ++- .../Codable Structs/ExternalConfig.swift | 8 +- .../Codable Structs/ExternalNetwork.swift | 8 +- .../Codable Structs/ExternalSecret.swift | 8 +- .../Codable Structs/ExternalVolume.swift | 8 +- .../Compose/Codable Structs/Healthcheck.swift | 15 +- .../CLI/Compose/Codable Structs/Network.swift | 28 ++-- .../Codable Structs/ResourceLimits.swift | 6 +- .../ResourceReservations.swift | 11 +- .../CLI/Compose/Codable Structs/Secret.swift | 17 ++- .../CLI/Compose/Codable Structs/Service.swift | 137 +++++++++++++++--- .../Codable Structs/ServiceConfig.swift | 19 ++- .../Codable Structs/ServiceSecret.swift | 19 ++- .../CLI/Compose/Codable Structs/Volume.swift | 19 ++- 20 files changed, 283 insertions(+), 108 deletions(-) diff --git a/Sources/CLI/Compose/Codable Structs/Build.swift b/Sources/CLI/Compose/Codable Structs/Build.swift index 50aeac337..5dc9a7ffa 100644 --- a/Sources/CLI/Compose/Codable Structs/Build.swift +++ b/Sources/CLI/Compose/Codable Structs/Build.swift @@ -24,11 +24,14 @@ /// Represents the `build` configuration for a service. struct Build: Codable, Hashable { - let context: String // Path to the build context - let dockerfile: String? // Optional path to the Dockerfile within the context - let args: [String: String]? // Build arguments + /// Path to the build context + let context: String + /// Optional path to the Dockerfile within the context + let dockerfile: String? + /// Build arguments + let args: [String: String]? - // Custom initializer to handle `build: .` (string) or `build: { context: . }` (object) + /// Custom initializer to handle `build: .` (string) or `build: { context: . }` (object) init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let contextString = try? container.decode(String.self) { diff --git a/Sources/CLI/Compose/Codable Structs/Config.swift b/Sources/CLI/Compose/Codable Structs/Config.swift index d74a5e031..6b982bfdb 100644 --- a/Sources/CLI/Compose/Codable Structs/Config.swift +++ b/Sources/CLI/Compose/Codable Structs/Config.swift @@ -24,10 +24,14 @@ /// Represents a top-level config definition (primarily for Swarm). struct Config: Codable { - let file: String? // Path to the file containing the config content - let external: ExternalConfig? // Indicates if the config is external (pre-existing) - let name: String? // Explicit name for the config - let labels: [String: String]? // Labels for the config + /// Path to the file containing the config content + let file: String? + /// Indicates if the config is external (pre-existing) + let external: ExternalConfig? + /// Explicit name for the config + let name: String? + /// Labels for the config + let labels: [String: String]? enum CodingKeys: String, CodingKey { case file, external, name, labels @@ -48,4 +52,4 @@ struct Config: Codable { external = nil } } -} \ No newline at end of file +} diff --git a/Sources/CLI/Compose/Codable Structs/Deploy.swift b/Sources/CLI/Compose/Codable Structs/Deploy.swift index 33e817b11..d30f9ffa8 100644 --- a/Sources/CLI/Compose/Codable Structs/Deploy.swift +++ b/Sources/CLI/Compose/Codable Structs/Deploy.swift @@ -24,8 +24,12 @@ /// Represents the `deploy` configuration for a service (primarily for Swarm orchestration). struct Deploy: Codable, Hashable { - let mode: String? // Deployment mode (e.g., 'replicated', 'global') - let replicas: Int? // Number of replicated service tasks - let resources: DeployResources? // Resource constraints (limits, reservations) - let restart_policy: DeployRestartPolicy? // Restart policy for tasks + /// Deployment mode (e.g., 'replicated', 'global') + let mode: String? + /// Number of replicated service tasks + let replicas: Int? + /// Resource constraints (limits, reservations) + let resources: DeployResources? + /// Restart policy for tasks + let restart_policy: DeployRestartPolicy? } diff --git a/Sources/CLI/Compose/Codable Structs/DeployResources.swift b/Sources/CLI/Compose/Codable Structs/DeployResources.swift index 5f539adcd..370e61a46 100644 --- a/Sources/CLI/Compose/Codable Structs/DeployResources.swift +++ b/Sources/CLI/Compose/Codable Structs/DeployResources.swift @@ -24,6 +24,8 @@ /// Resource constraints for deployment. struct DeployResources: Codable, Hashable { - let limits: ResourceLimits? // Hard limits on resources - let reservations: ResourceReservations? // Guarantees for resources + /// Hard limits on resources + let limits: ResourceLimits? + /// Guarantees for resources + let reservations: ResourceReservations? } diff --git a/Sources/CLI/Compose/Codable Structs/DeployRestartPolicy.swift b/Sources/CLI/Compose/Codable Structs/DeployRestartPolicy.swift index e56987bd6..56daa6573 100644 --- a/Sources/CLI/Compose/Codable Structs/DeployRestartPolicy.swift +++ b/Sources/CLI/Compose/Codable Structs/DeployRestartPolicy.swift @@ -24,8 +24,12 @@ /// Restart policy for deployed tasks. struct DeployRestartPolicy: Codable, Hashable { - let condition: String? // Condition to restart on (e.g., 'on-failure', 'any') - let delay: String? // Delay before attempting restart - let max_attempts: Int? // Maximum number of restart attempts - let window: String? // Window to evaluate restart policy + /// Condition to restart on (e.g., 'on-failure', 'any') + let condition: String? + /// Delay before attempting restart + let delay: String? + /// Maximum number of restart attempts + let max_attempts: Int? + /// Window to evaluate restart policy + let window: String? } diff --git a/Sources/CLI/Compose/Codable Structs/DeviceReservation.swift b/Sources/CLI/Compose/Codable Structs/DeviceReservation.swift index f40b32a7d..47a58acad 100644 --- a/Sources/CLI/Compose/Codable Structs/DeviceReservation.swift +++ b/Sources/CLI/Compose/Codable Structs/DeviceReservation.swift @@ -24,8 +24,12 @@ /// Device reservations for GPUs or other devices. struct DeviceReservation: Codable, Hashable { - let capabilities: [String]? // Device capabilities - let driver: String? // Device driver - let count: String? // Number of devices - let device_ids: [String]? // Specific device IDs + /// Device capabilities + let capabilities: [String]? + /// Device driver + let driver: String? + /// Number of devices + let count: String? + /// Specific device IDs + let device_ids: [String]? } diff --git a/Sources/CLI/Compose/Codable Structs/DockerCompose.swift b/Sources/CLI/Compose/Codable Structs/DockerCompose.swift index 6f3b7cc2d..503d98664 100644 --- a/Sources/CLI/Compose/Codable Structs/DockerCompose.swift +++ b/Sources/CLI/Compose/Codable Structs/DockerCompose.swift @@ -24,13 +24,20 @@ /// Represents the top-level structure of a docker-compose.yml file. struct DockerCompose: Codable { - let version: String? // The Compose file format version (e.g., '3.8') - let name: String? // Optional project name - let services: [String: Service] // Dictionary of service definitions, keyed by service name - let volumes: [String: Volume]? // Optional top-level volume definitions - let networks: [String: Network]? // Optional top-level network definitions - let configs: [String: Config]? // Optional top-level config definitions (primarily for Swarm) - let secrets: [String: Secret]? // Optional top-level secret definitions (primarily for Swarm) + /// The Compose file format version (e.g., '3.8') + let version: String? + /// Optional project name + let name: String? + /// Dictionary of service definitions, keyed by service name + let services: [String: Service] + /// Optional top-level volume definitions + let volumes: [String: Volume]? + /// Optional top-level network definitions + let networks: [String: Network]? + /// Optional top-level config definitions (primarily for Swarm) + let configs: [String: Config]? + /// Optional top-level secret definitions (primarily for Swarm) + let secrets: [String: Secret]? init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) diff --git a/Sources/CLI/Compose/Codable Structs/ExternalConfig.swift b/Sources/CLI/Compose/Codable Structs/ExternalConfig.swift index ec6866e9c..d05ccd461 100644 --- a/Sources/CLI/Compose/Codable Structs/ExternalConfig.swift +++ b/Sources/CLI/Compose/Codable Structs/ExternalConfig.swift @@ -24,6 +24,8 @@ /// Represents an external config reference. struct ExternalConfig: Codable { - let isExternal: Bool // True if the config is external - let name: String? // Optional name of the external config if different from key -} \ No newline at end of file + /// True if the config is external + let isExternal: Bool + /// Optional name of the external config if different from key + let name: String? +} diff --git a/Sources/CLI/Compose/Codable Structs/ExternalNetwork.swift b/Sources/CLI/Compose/Codable Structs/ExternalNetwork.swift index d2df0da28..07d6c8ce9 100644 --- a/Sources/CLI/Compose/Codable Structs/ExternalNetwork.swift +++ b/Sources/CLI/Compose/Codable Structs/ExternalNetwork.swift @@ -24,6 +24,8 @@ /// Represents an external network reference. struct ExternalNetwork: Codable { - let isExternal: Bool // True if the network is external - let name: String? // Optional name of the external network if different from key -} \ No newline at end of file + /// True if the network is external + let isExternal: Bool + // Optional name of the external network if different from key + let name: String? +} diff --git a/Sources/CLI/Compose/Codable Structs/ExternalSecret.swift b/Sources/CLI/Compose/Codable Structs/ExternalSecret.swift index ec854546f..ce4411362 100644 --- a/Sources/CLI/Compose/Codable Structs/ExternalSecret.swift +++ b/Sources/CLI/Compose/Codable Structs/ExternalSecret.swift @@ -24,6 +24,8 @@ /// Represents an external secret reference. struct ExternalSecret: Codable { - let isExternal: Bool // True if the secret is external - let name: String? // Optional name of the external secret if different from key -} \ No newline at end of file + /// True if the secret is external + let isExternal: Bool + /// Optional name of the external secret if different from key + let name: String? +} diff --git a/Sources/CLI/Compose/Codable Structs/ExternalVolume.swift b/Sources/CLI/Compose/Codable Structs/ExternalVolume.swift index f42c4bff1..04cfe4f92 100644 --- a/Sources/CLI/Compose/Codable Structs/ExternalVolume.swift +++ b/Sources/CLI/Compose/Codable Structs/ExternalVolume.swift @@ -24,6 +24,8 @@ /// Represents an external volume reference. struct ExternalVolume: Codable { - let isExternal: Bool // True if the volume is external - let name: String? // Optional name of the external volume if different from key -} \ No newline at end of file + /// True if the volume is external + let isExternal: Bool + /// Optional name of the external volume if different from key + let name: String? +} diff --git a/Sources/CLI/Compose/Codable Structs/Healthcheck.swift b/Sources/CLI/Compose/Codable Structs/Healthcheck.swift index 9bb5131db..27f5aa912 100644 --- a/Sources/CLI/Compose/Codable Structs/Healthcheck.swift +++ b/Sources/CLI/Compose/Codable Structs/Healthcheck.swift @@ -24,9 +24,14 @@ /// Healthcheck configuration for a service. struct Healthcheck: Codable, Hashable { - let test: [String]? // Command to run to check health - let start_period: String? // Grace period for the container to start - let interval: String? // How often to run the check - let retries: Int? // Number of consecutive failures to consider unhealthy - let timeout: String? // Timeout for each check + /// Command to run to check health + let test: [String]? + /// Grace period for the container to start + let start_period: String? + /// How often to run the check + let interval: String? + /// Number of consecutive failures to consider unhealthy + let retries: Int? + /// Timeout for each check + let timeout: String? } diff --git a/Sources/CLI/Compose/Codable Structs/Network.swift b/Sources/CLI/Compose/Codable Structs/Network.swift index df14bcbbb..44752aecc 100644 --- a/Sources/CLI/Compose/Codable Structs/Network.swift +++ b/Sources/CLI/Compose/Codable Structs/Network.swift @@ -24,16 +24,24 @@ /// Represents a top-level network definition. struct Network: Codable { - let driver: String? // Network driver (e.g., 'bridge', 'overlay') - let driver_opts: [String: String]? // Driver-specific options - let attachable: Bool? // Allow standalone containers to attach to this network - let enable_ipv6: Bool? // Enable IPv6 networking - let isInternal: Bool? // RENAMED: from `internal` to `isInternal` to avoid keyword clash - let labels: [String: String]? // Labels for the network - let name: String? // Explicit name for the network - let external: ExternalNetwork? // Indicates if the network is external (pre-existing) + /// Network driver (e.g., 'bridge', 'overlay') + let driver: String? + /// Driver-specific options + let driver_opts: [String: String]? + /// Allow standalone containers to attach to this network + let attachable: Bool? + /// Enable IPv6 networking + let enable_ipv6: Bool? + /// RENAMED: from `internal` to `isInternal` to avoid keyword clash + let isInternal: Bool? + /// Labels for the network + let labels: [String: String]? + /// Explicit name for the network + let name: String? + /// Indicates if the network is external (pre-existing) + let external: ExternalNetwork? - // Updated CodingKeys to map 'internal' from YAML to 'isInternal' Swift property + /// Updated CodingKeys to map 'internal' from YAML to 'isInternal' Swift property enum CodingKeys: String, CodingKey { case driver, driver_opts, attachable, enable_ipv6, isInternal = "internal", labels, name, external } @@ -57,4 +65,4 @@ struct Network: Codable { external = nil } } -} \ No newline at end of file +} diff --git a/Sources/CLI/Compose/Codable Structs/ResourceLimits.swift b/Sources/CLI/Compose/Codable Structs/ResourceLimits.swift index d75dfa497..4643d961b 100644 --- a/Sources/CLI/Compose/Codable Structs/ResourceLimits.swift +++ b/Sources/CLI/Compose/Codable Structs/ResourceLimits.swift @@ -24,6 +24,8 @@ /// CPU and memory limits. struct ResourceLimits: Codable, Hashable { - let cpus: String? // CPU limit (e.g., "0.5") - let memory: String? // Memory limit (e.g., "512M") + /// CPU limit (e.g., "0.5") + let cpus: String? + /// Memory limit (e.g., "512M") + let memory: String? } diff --git a/Sources/CLI/Compose/Codable Structs/ResourceReservations.swift b/Sources/CLI/Compose/Codable Structs/ResourceReservations.swift index db495c1a1..26052e6b3 100644 --- a/Sources/CLI/Compose/Codable Structs/ResourceReservations.swift +++ b/Sources/CLI/Compose/Codable Structs/ResourceReservations.swift @@ -24,8 +24,11 @@ /// **FIXED**: Renamed from `ResourceReservables` to `ResourceReservations` and made `Codable`. /// CPU and memory reservations. -struct ResourceReservations: Codable, Hashable { // Changed from ResourceReservables to ResourceReservations - let cpus: String? // CPU reservation (e.g., "0.25") - let memory: String? // Memory reservation (e.g., "256M") - let devices: [DeviceReservation]? // Device reservations for GPUs or other devices +struct ResourceReservations: Codable, Hashable { + /// CPU reservation (e.g., "0.25") + let cpus: String? + /// Memory reservation (e.g., "256M") + let memory: String? + /// Device reservations for GPUs or other devices + let devices: [DeviceReservation]? } diff --git a/Sources/CLI/Compose/Codable Structs/Secret.swift b/Sources/CLI/Compose/Codable Structs/Secret.swift index dcb533d01..ff464c671 100644 --- a/Sources/CLI/Compose/Codable Structs/Secret.swift +++ b/Sources/CLI/Compose/Codable Structs/Secret.swift @@ -24,11 +24,16 @@ /// Represents a top-level secret definition (primarily for Swarm). struct Secret: Codable { - let file: String? // Path to the file containing the secret content - let environment: String? // Environment variable to populate with the secret content - let external: ExternalSecret? // Indicates if the secret is external (pre-existing) - let name: String? // Explicit name for the secret - let labels: [String: String]? // Labels for the secret + /// Path to the file containing the secret content + let file: String? + /// Environment variable to populate with the secret content + let environment: String? + /// Indicates if the secret is external (pre-existing) + let external: ExternalSecret? + /// Explicit name for the secret + let name: String? + /// Labels for the secret + let labels: [String: String]? enum CodingKeys: String, CodingKey { case file, environment, external, name, labels @@ -50,4 +55,4 @@ struct Secret: Codable { external = nil } } -} \ No newline at end of file +} diff --git a/Sources/CLI/Compose/Codable Structs/Service.swift b/Sources/CLI/Compose/Codable Structs/Service.swift index d493189fc..61cd483af 100644 --- a/Sources/CLI/Compose/Codable Structs/Service.swift +++ b/Sources/CLI/Compose/Codable Structs/Service.swift @@ -21,33 +21,82 @@ // Created by Morris Richman on 6/17/25. // +import Foundation + /// Represents a single service definition within the `services` section. struct Service: Codable, Hashable { - let image: String? // Docker image name - let build: Build? // Build configuration if the service is built from a Dockerfile - let deploy: Deploy? // Deployment configuration (primarily for Swarm) - let restart: String? // Restart policy (e.g., 'unless-stopped', 'always') - let healthcheck: Healthcheck? // Healthcheck configuration - let volumes: [String]? // List of volume mounts (e.g., "hostPath:containerPath", "namedVolume:/path") - let environment: [String: String]? // Environment variables to set in the container - let env_file: [String]? // List of .env files to load environment variables from - let ports: [String]? // Port mappings (e.g., "hostPort:containerPort") - let command: [String]? // Command to execute in the container, overriding the image's default - let depends_on: [String]? // Services this service depends on (for startup order) - let user: String? // User or UID to run the container as - - let container_name: String? // Explicit name for the container instance - let networks: [String]? // List of networks the service will connect to - let hostname: String? // Container hostname - let entrypoint: [String]? // Entrypoint to execute in the container, overriding the image's default - let privileged: Bool? // Run container in privileged mode - let read_only: Bool? // Mount container's root filesystem as read-only - let working_dir: String? // Working directory inside the container - let configs: [ServiceConfig]? // Service-specific config usage (primarily for Swarm) - let secrets: [ServiceSecret]? // Service-specific secret usage (primarily for Swarm) - let stdin_open: Bool? // Keep STDIN open (-i flag for `container run`) - let tty: Bool? // Allocate a pseudo-TTY (-t flag for `container run`) + /// Docker image name + let image: String? + + /// Build configuration if the service is built from a Dockerfile + let build: Build? + + /// Deployment configuration (primarily for Swarm) + let deploy: Deploy? + + /// Restart policy (e.g., 'unless-stopped', 'always') + let restart: String? + + /// Healthcheck configuration + let healthcheck: Healthcheck? + + /// List of volume mounts (e.g., "hostPath:containerPath", "namedVolume:/path") + let volumes: [String]? + + /// Environment variables to set in the container + let environment: [String: String]? + + /// List of .env files to load environment variables from + let env_file: [String]? + + /// Port mappings (e.g., "hostPort:containerPort") + let ports: [String]? + + /// Command to execute in the container, overriding the image's default + let command: [String]? + + /// Services this service depends on (for startup order) + let depends_on: [String]? + + /// User or UID to run the container as + let user: String? + + /// Explicit name for the container instance + let container_name: String? + + /// List of networks the service will connect to + let networks: [String]? + + /// Container hostname + let hostname: String? + + /// Entrypoint to execute in the container, overriding the image's default + let entrypoint: [String]? + + /// Run container in privileged mode + let privileged: Bool? + + /// Mount container's root filesystem as read-only + let read_only: Bool? + + /// Working directory inside the container + let working_dir: String? + + /// Service-specific config usage (primarily for Swarm) + let configs: [ServiceConfig]? + + /// Service-specific secret usage (primarily for Swarm) + let secrets: [ServiceSecret]? + + /// Keep STDIN open (-i flag for `container run`) + let stdin_open: Bool? + + /// Allocate a pseudo-TTY (-t flag for `container run`) + let tty: Bool? + + /// Other services that depend on this service + var dependedBy: [String] = [] // Defines custom coding keys to map YAML keys to Swift properties enum CodingKeys: String, CodingKey { @@ -107,4 +156,44 @@ struct Service: Codable, Hashable { stdin_open = try container.decodeIfPresent(Bool.self, forKey: .stdin_open) tty = try container.decodeIfPresent(Bool.self, forKey: .tty) } + + /// Returns the services in topological order based on `depends_on` relationships. + static func topoSortConfiguredServices( + _ services: [(serviceName: String, service: Service)] + ) throws -> [(serviceName: String, service: Service)] { + + var visited = Set() + var visiting = Set() + var sorted: [(String, Service)] = [] + + func visit(_ name: String, from service: String? = nil) throws { + guard var serviceTuple = services.first(where: { $0.serviceName == name }) else { return } + if let service { + serviceTuple.service.dependedBy.append(service) + } + + if visiting.contains(name) { + throw NSError(domain: "ComposeError", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" + ]) + } + guard !visited.contains(name) else { return } + + visiting.insert(name) + for depName in serviceTuple.service.depends_on ?? [] { + try visit(depName, from: name) + } + visiting.remove(name) + visited.insert(name) + sorted.append(serviceTuple) + } + + for (serviceName, _) in services { + if !visited.contains(serviceName) { + try visit(serviceName) + } + } + + return sorted + } } diff --git a/Sources/CLI/Compose/Codable Structs/ServiceConfig.swift b/Sources/CLI/Compose/Codable Structs/ServiceConfig.swift index fe4cd7c1a..712d42b7b 100644 --- a/Sources/CLI/Compose/Codable Structs/ServiceConfig.swift +++ b/Sources/CLI/Compose/Codable Structs/ServiceConfig.swift @@ -24,11 +24,20 @@ /// Represents a service's usage of a config. struct ServiceConfig: Codable, Hashable { - let source: String // Name of the config being used - let target: String? // Path in the container where the config will be mounted - let uid: String? // User ID for the mounted config file - let gid: String? // Group ID for the mounted config file - let mode: Int? // Permissions mode for the mounted config file + /// Name of the config being used + let source: String + + /// Path in the container where the config will be mounted + let target: String? + + /// User ID for the mounted config file + let uid: String? + + /// Group ID for the mounted config file + let gid: String? + + /// Permissions mode for the mounted config file + let mode: Int? /// Custom initializer to handle `config_name` (string) or `{ source: config_name, target: /path }` (object). init(from decoder: Decoder) throws { diff --git a/Sources/CLI/Compose/Codable Structs/ServiceSecret.swift b/Sources/CLI/Compose/Codable Structs/ServiceSecret.swift index 8ae352822..1849c495c 100644 --- a/Sources/CLI/Compose/Codable Structs/ServiceSecret.swift +++ b/Sources/CLI/Compose/Codable Structs/ServiceSecret.swift @@ -24,11 +24,20 @@ /// Represents a service's usage of a secret. struct ServiceSecret: Codable, Hashable { - let source: String // Name of the secret being used - let target: String? // Path in the container where the secret will be mounted - let uid: String? // User ID for the mounted secret file - let gid: String? // Group ID for the mounted secret file - let mode: Int? // Permissions mode for the mounted secret file + /// Name of the secret being used + let source: String + + /// Path in the container where the secret will be mounted + let target: String? + + /// User ID for the mounted secret file + let uid: String? + + /// Group ID for the mounted secret file + let gid: String? + + /// Permissions mode for the mounted secret file + let mode: Int? /// Custom initializer to handle `secret_name` (string) or `{ source: secret_name, target: /path }` (object). init(from decoder: Decoder) throws { diff --git a/Sources/CLI/Compose/Codable Structs/Volume.swift b/Sources/CLI/Compose/Codable Structs/Volume.swift index bb4b5c201..b43a1cca5 100644 --- a/Sources/CLI/Compose/Codable Structs/Volume.swift +++ b/Sources/CLI/Compose/Codable Structs/Volume.swift @@ -24,11 +24,20 @@ /// Represents a top-level volume definition. struct Volume: Codable { - let driver: String? // Volume driver (e.g., 'local') - let driver_opts: [String: String]? // Driver-specific options - let name: String? // Explicit name for the volume - let labels: [String: String]? // Labels for the volume - let external: ExternalVolume? // Indicates if the volume is external (pre-existing) + /// Volume driver (e.g., 'local') + let driver: String? + + /// Driver-specific options + let driver_opts: [String: String]? + + /// Explicit name for the volume + let name: String? + + /// Labels for the volume + let labels: [String: String]? + + /// Indicates if the volume is external (pre-existing) + let external: ExternalVolume? enum CodingKeys: String, CodingKey { case driver, driver_opts, name, labels, external From 572bd50ab036a1f9ba2940a98749ad9d1015adf2 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:27:52 -0700 Subject: [PATCH 09/80] added service specification to compose if desired --- .../CLI/Compose/Commands/ComposeDown.swift | 46 ++++---- Sources/CLI/Compose/Commands/ComposeUp.swift | 108 ++++++------------ 2 files changed, 53 insertions(+), 101 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeDown.swift b/Sources/CLI/Compose/Commands/ComposeDown.swift index 19bf20616..f7eaa0f82 100644 --- a/Sources/CLI/Compose/Commands/ComposeDown.swift +++ b/Sources/CLI/Compose/Commands/ComposeDown.swift @@ -31,6 +31,9 @@ struct ComposeDown: AsyncParsableCommand { commandName: "down", abstract: "Stop containers with compose" ) + + @Argument(help: "Specify the services to stop") + var services: [String] = [] @OptionGroup var process: Flags.Process @@ -47,45 +50,38 @@ struct ComposeDown: AsyncParsableCommand { guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { throw YamlError.dockerfileNotFound(dockerComposePath) } - + // Decode the YAML file into the DockerCompose struct let dockerComposeString = String(data: yamlData, encoding: .utf8)! let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - + // Determine project name for container naming if let name = dockerCompose.name { projectName = name print("Info: Docker Compose project name parsed as: \(name)") - print( - "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." - ) + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") } - - try await stopOldStuff(remove: false) - } - - /// Returns the names of all containers whose names start with a given prefix. - /// - Parameter prefix: The container name prefix (e.g. `"Assignment"`). - /// - Returns: An array of matching container names. - func getContainersWithPrefix(_ prefix: String) async throws -> [String] { - let result = try await runCommand("container", args: ["list", "-a"]) - let lines = result.stdout.split(separator: "\n") - - return lines.compactMap { line in - let trimmed = line.trimmingCharacters(in: .whitespaces) - let components = trimmed.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) - guard let name = components.first else { return nil } - return name.hasPrefix(prefix) ? String(name) : nil + + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) + services = try Service.topoSortConfiguredServices(services) + + // Filter for specified services + if !self.services.isEmpty { + services = services.filter({ serviceName, service in + self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) + }) } + + try await stopOldStuff(services.map({ $0.serviceName }), remove: false) } - func stopOldStuff(remove: Bool) async throws { + func stopOldStuff(_ services: [String], remove: Bool) async throws { guard let projectName else { return } - let containers = try await getContainersWithPrefix(projectName) - + let containers = services.map { "\(projectName)-\($0)" } + for container in containers { print("Stopping container: \(container)") do { diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index 87e89509a..1ae92b05c 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -32,7 +32,10 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { commandName: "up", abstract: "Start containers with compose" ) - + + @Argument(help: "Specify the services to start") + var services: [String] = [] + @Flag( name: [.customShort("d"), .customLong("detach")], help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") @@ -63,14 +66,14 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { throw YamlError.dockerfileNotFound(dockerComposePath) } - + // Decode the YAML file into the DockerCompose struct let dockerComposeString = String(data: yamlData, encoding: .utf8)! let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - + // Load environment variables from .env file environmentVariables = loadEnvFile(path: envFilePath) - + // Handle 'version' field if let version = dockerCompose.version { print("Info: Docker Compose file version parsed as: \(version)") @@ -81,16 +84,26 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { if let name = dockerCompose.name { projectName = name print("Info: Docker Compose project name parsed as: \(name)") - print( - "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." - ) + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") } - - try await stopOldStuff(remove: true) - + + // Get Services to use + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) + services = try Service.topoSortConfiguredServices(services) + + // Filter for specified services + if !self.services.isEmpty { + services = services.filter({ serviceName, service in + self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) + }) + } + + // Stop Services + try await stopOldStuff(services.map({ $0.serviceName }), remove: true) + // Process top-level networks // This creates named networks defined in the docker-compose.yml if let networks = dockerCompose.networks { @@ -100,7 +113,7 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } print("--- Networks Processed ---\n") } - + // Process top-level volumes // This creates named volumes defined in the docker-compose.yml if let volumes = dockerCompose.volumes { @@ -110,18 +123,15 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } print("--- Volumes Processed ---\n") } - + // Process each service defined in the docker-compose.yml print("\n--- Processing Services ---") - - var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) - services = try topoSortConfiguredServices(services) - + print(services.map(\.serviceName)) for (serviceName, service) in services { try await configService(service, serviceName: serviceName, from: dockerCompose) } - + if !detatch { await waitForever() } @@ -194,12 +204,12 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ]) } - func stopOldStuff(remove: Bool) async throws { + func stopOldStuff(_ services: [String], remove: Bool) async throws { guard let projectName else { return } - let containers = try await getContainersWithPrefix(projectName) - + let containers = services.map { "\(projectName)-\($0)" } + for container in containers { - print("Removing old container: \(container)") + print("Stopping container: \(container)") do { try await runCommand("container", args: ["stop", container]) if remove { @@ -210,21 +220,6 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - /// Returns the names of all containers whose names start with a given prefix. - /// - Parameter prefix: The container name prefix (e.g. `"Assignment"`). - /// - Returns: An array of matching container names. - func getContainersWithPrefix(_ prefix: String) async throws -> [String] { - let result = try await runCommand("container", args: ["list", "-a"]) - let lines = result.stdout.split(separator: "\n") - - return lines.compactMap { line in - let trimmed = line.trimmingCharacters(in: .whitespaces) - let components = trimmed.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) - guard let name = components.first else { return nil } - return name.hasPrefix(prefix) ? String(name) : nil - } - } - // MARK: Compose Top Level Functions mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { @@ -235,45 +230,6 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - /// Returns the services in topological order based on `depends_on` relationships. - func topoSortConfiguredServices( - _ services: [(serviceName: String, service: Service)] - ) throws -> [(serviceName: String, service: Service)] { - - var visited = Set() - var visiting = Set() - var sorted: [(String, Service)] = [] - - func visit(_ name: String) throws { - guard let serviceTuple = services.first(where: { $0.serviceName == name }) else { return } - - if visiting.contains(name) { - throw NSError( - domain: "ComposeError", code: 1, - userInfo: [ - NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" - ]) - } - guard !visited.contains(name) else { return } - - visiting.insert(name) - for depName in serviceTuple.service.depends_on ?? [] { - try visit(depName) - } - visiting.remove(name) - visited.insert(name) - sorted.append(serviceTuple) - } - - for (serviceName, _) in services { - if !visited.contains(serviceName) { - try visit(serviceName) - } - } - - return sorted - } - func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { guard let projectName else { return } let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name From 4ba586ebe2dbeabc298498a99e966aa4d6d4cc5d Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:28:49 -0700 Subject: [PATCH 10/80] Update ComposeUp.swift --- Sources/CLI/Compose/Commands/ComposeUp.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index 1ae92b05c..1e0aa9404 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -212,10 +212,13 @@ struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print("Stopping container: \(container)") do { try await runCommand("container", args: ["stop", container]) - if remove { + } catch { + } + if remove { + do { try await runCommand("container", args: ["rm", container]) + } catch { } - } catch { } } } From 77c751d062f031ef99295f0f92db9f7dd952b02d Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:29:12 -0700 Subject: [PATCH 11/80] Update ComposeDown.swift --- Sources/CLI/Compose/Commands/ComposeDown.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeDown.swift b/Sources/CLI/Compose/Commands/ComposeDown.swift index f7eaa0f82..a8683c1ea 100644 --- a/Sources/CLI/Compose/Commands/ComposeDown.swift +++ b/Sources/CLI/Compose/Commands/ComposeDown.swift @@ -86,10 +86,13 @@ struct ComposeDown: AsyncParsableCommand { print("Stopping container: \(container)") do { try await runCommand("container", args: ["stop", container]) - if remove { + } catch { + } + if remove { + do { try await runCommand("container", args: ["rm", container]) + } catch { } - } catch { } } } From 7e174d0e2316a450ce698fc59b87b2f27981079e Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 15:01:13 -0700 Subject: [PATCH 12/80] Moved compose to be within Application struct --- .../CLI/Compose/Commands/ComposeDown.swift | 228 ++-- Sources/CLI/Compose/Commands/ComposeUp.swift | 1171 +++++++++-------- Sources/CLI/Compose/ComposeCommand.swift | 18 +- 3 files changed, 712 insertions(+), 705 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeDown.swift b/Sources/CLI/Compose/Commands/ComposeDown.swift index a8683c1ea..f2df0d3fe 100644 --- a/Sources/CLI/Compose/Commands/ComposeDown.swift +++ b/Sources/CLI/Compose/Commands/ComposeDown.swift @@ -26,133 +26,135 @@ import ContainerClient import Foundation import Yams -struct ComposeDown: AsyncParsableCommand { - static let configuration: CommandConfiguration = .init( - commandName: "down", - abstract: "Stop containers with compose" - ) - - @Argument(help: "Specify the services to stop") - var services: [String] = [] - - @OptionGroup - var process: Flags.Process - - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - - var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml - - private var fileManager: FileManager { FileManager.default } - private var projectName: String? - - mutating func run() async throws { - // Read docker-compose.yml content - guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { - throw YamlError.dockerfileNotFound(dockerComposePath) - } +extension Application { + struct ComposeDown: AsyncParsableCommand { + static let configuration: CommandConfiguration = .init( + commandName: "down", + abstract: "Stop containers with compose" + ) - // Decode the YAML file into the DockerCompose struct - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + @Argument(help: "Specify the services to stop") + var services: [String] = [] - // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") - } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") - } + @OptionGroup + var process: Flags.Process - var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) - services = try Service.topoSortConfiguredServices(services) + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - // Filter for specified services - if !self.services.isEmpty { - services = services.filter({ serviceName, service in - self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) - }) - } + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml - try await stopOldStuff(services.map({ $0.serviceName }), remove: false) - } - - func stopOldStuff(_ services: [String], remove: Bool) async throws { - guard let projectName else { return } - let containers = services.map { "\(projectName)-\($0)" } + private var fileManager: FileManager { FileManager.default } + private var projectName: String? - for container in containers { - print("Stopping container: \(container)") - do { - try await runCommand("container", args: ["stop", container]) - } catch { + mutating func run() async throws { + // Read docker-compose.yml content + guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { + throw YamlError.dockerfileNotFound(dockerComposePath) + } + + // Decode the YAML file into the DockerCompose struct + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + + // Determine project name for container naming + if let name = dockerCompose.name { + projectName = name + print("Info: Docker Compose project name parsed as: \(name)") + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + } else { + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") + } + + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) + services = try Service.topoSortConfiguredServices(services) + + // Filter for specified services + if !self.services.isEmpty { + services = services.filter({ serviceName, service in + self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) + }) } - if remove { + + try await stopOldStuff(services.map({ $0.serviceName }), remove: false) + } + + func stopOldStuff(_ services: [String], remove: Bool) async throws { + guard let projectName else { return } + let containers = services.map { "\(projectName)-\($0)" } + + for container in containers { + print("Stopping container: \(container)") do { - try await runCommand("container", args: ["rm", container]) + try await runCommand("container", args: ["stop", container]) } catch { } + if remove { + do { + try await runCommand("container", args: ["rm", container]) + } catch { + } + } } } - } - - /// Runs a command-line tool asynchronously and captures its output and exit code. - /// - /// This function uses async/await and `Process` to launch a command-line tool, - /// returning a `CommandResult` containing the output, error, and exit code upon completion. - /// - /// - Parameters: - /// - command: The full path to the executable to run (e.g., `/bin/ls`). - /// - args: An array of arguments to pass to the command. Defaults to an empty array. - /// - Returns: A `CommandResult` containing `stdout`, `stderr`, and `exitCode`. - /// - Throws: An error if the process fails to launch. - /// - Example: - /// ```swift - /// let result = try await runCommand("/bin/echo", args: ["Hello"]) - /// print(result.stdout) // "Hello\n" - /// ``` - @discardableResult - func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult { - try await withCheckedThrowingContinuation { continuation in - let process = Process() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [command] + args - process.currentDirectoryURL = URL(fileURLWithPath: cwd) - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - // Manually set PATH so it can find `container` - process.environment = ProcessInfo.processInfo.environment.merging([ - "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" - ]) { _, new in new } - - do { - try process.run() - } catch { - continuation.resume(throwing: error) - return - } - - process.terminationHandler = { proc in - let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - - guard stderrData.isEmpty else { - continuation.resume(throwing: TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self))) + + /// Runs a command-line tool asynchronously and captures its output and exit code. + /// + /// This function uses async/await and `Process` to launch a command-line tool, + /// returning a `CommandResult` containing the output, error, and exit code upon completion. + /// + /// - Parameters: + /// - command: The full path to the executable to run (e.g., `/bin/ls`). + /// - args: An array of arguments to pass to the command. Defaults to an empty array. + /// - Returns: A `CommandResult` containing `stdout`, `stderr`, and `exitCode`. + /// - Throws: An error if the process fails to launch. + /// - Example: + /// ```swift + /// let result = try await runCommand("/bin/echo", args: ["Hello"]) + /// print(result.stdout) // "Hello\n" + /// ``` + @discardableResult + func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult { + try await withCheckedThrowingContinuation { continuation in + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [command] + args + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + // Manually set PATH so it can find `container` + process.environment = ProcessInfo.processInfo.environment.merging([ + "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + ]) { _, new in new } + + do { + try process.run() + } catch { + continuation.resume(throwing: error) return } - - let result = CommandResult( - stdout: String(decoding: stdoutData, as: UTF8.self), - stderr: String(decoding: stderrData, as: UTF8.self), - exitCode: proc.terminationStatus - ) - - continuation.resume(returning: result) + + process.terminationHandler = { proc in + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + + guard stderrData.isEmpty else { + continuation.resume(throwing: TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self))) + return + } + + let result = CommandResult( + stdout: String(decoding: stdoutData, as: UTF8.self), + stderr: String(decoding: stderrData, as: UTF8.self), + exitCode: proc.terminationStatus + ) + + continuation.resume(returning: result) + } } } } diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index 1e0aa9404..d43a85838 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -27,643 +27,646 @@ import Foundation @preconcurrency import Rainbow import Yams -struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { - static let configuration: CommandConfiguration = .init( - commandName: "up", - abstract: "Start containers with compose" - ) - - @Argument(help: "Specify the services to start") - var services: [String] = [] - - @Flag( - name: [.customShort("d"), .customLong("detach")], - help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") - var detatch: Bool = false - - @Flag(name: [.customShort("b"), .customLong("build")]) - var rebuild: Bool = false - - @OptionGroup - var process: Flags.Process - - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml - var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file - // - private var fileManager: FileManager { FileManager.default } - private var projectName: String? - private var environmentVariables: [String: String] = [:] - private var containerIps: [String: String] = [:] - private var containerConsoleColors: [String: NamedColor] = [:] - - private static let availableContainerConsoleColors: Set = [ - .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, - ] - - mutating func run() async throws { - // Read docker-compose.yml content - guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { - throw YamlError.dockerfileNotFound(dockerComposePath) - } - - // Decode the YAML file into the DockerCompose struct - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) +extension Application { + struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { + static let configuration: CommandConfiguration = .init( + commandName: "up", + abstract: "Start containers with compose" + ) - // Load environment variables from .env file - environmentVariables = loadEnvFile(path: envFilePath) + @Argument(help: "Specify the services to start") + var services: [String] = [] - // Handle 'version' field - if let version = dockerCompose.version { - print("Info: Docker Compose file version parsed as: \(version)") - print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") - } - - // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") - } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") - } + @Flag( + name: [.customShort("d"), .customLong("detach")], + help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") + var detatch: Bool = false - // Get Services to use - var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) - services = try Service.topoSortConfiguredServices(services) + @Flag(name: [.customShort("b"), .customLong("build")]) + var rebuild: Bool = false - // Filter for specified services - if !self.services.isEmpty { - services = services.filter({ serviceName, service in - self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) - }) - } + @OptionGroup + var process: Flags.Process - // Stop Services - try await stopOldStuff(services.map({ $0.serviceName }), remove: true) + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file + // + private var fileManager: FileManager { FileManager.default } + private var projectName: String? + private var environmentVariables: [String: String] = [:] + private var containerIps: [String: String] = [:] + private var containerConsoleColors: [String: NamedColor] = [:] - // Process top-level networks - // This creates named networks defined in the docker-compose.yml - if let networks = dockerCompose.networks { - print("\n--- Processing Networks ---") - for (networkName, networkConfig) in networks { - try await setupNetwork(name: networkName, config: networkConfig) - } - print("--- Networks Processed ---\n") - } + private static let availableContainerConsoleColors: Set = [ + .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, + ] - // Process top-level volumes - // This creates named volumes defined in the docker-compose.yml - if let volumes = dockerCompose.volumes { - print("\n--- Processing Volumes ---") - for (volumeName, volumeConfig) in volumes { - await createVolumeHardLink(name: volumeName, config: volumeConfig) - } - print("--- Volumes Processed ---\n") + mutating func run() async throws { + // Read docker-compose.yml content + guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { + throw YamlError.dockerfileNotFound(dockerComposePath) + } + + // Decode the YAML file into the DockerCompose struct + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + + // Load environment variables from .env file + environmentVariables = loadEnvFile(path: envFilePath) + + // Handle 'version' field + if let version = dockerCompose.version { + print("Info: Docker Compose file version parsed as: \(version)") + print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") + } + + // Determine project name for container naming + if let name = dockerCompose.name { + projectName = name + print("Info: Docker Compose project name parsed as: \(name)") + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + } else { + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") + } + + // Get Services to use + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) + services = try Service.topoSortConfiguredServices(services) + + // Filter for specified services + if !self.services.isEmpty { + services = services.filter({ serviceName, service in + self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) + }) + } + + // Stop Services + try await stopOldStuff(services.map({ $0.serviceName }), remove: true) + + // Process top-level networks + // This creates named networks defined in the docker-compose.yml + if let networks = dockerCompose.networks { + print("\n--- Processing Networks ---") + for (networkName, networkConfig) in networks { + try await setupNetwork(name: networkName, config: networkConfig) + } + print("--- Networks Processed ---\n") + } + + // Process top-level volumes + // This creates named volumes defined in the docker-compose.yml + if let volumes = dockerCompose.volumes { + print("\n--- Processing Volumes ---") + for (volumeName, volumeConfig) in volumes { + await createVolumeHardLink(name: volumeName, config: volumeConfig) + } + print("--- Volumes Processed ---\n") + } + + // Process each service defined in the docker-compose.yml + print("\n--- Processing Services ---") + + print(services.map(\.serviceName)) + for (serviceName, service) in services { + try await configService(service, serviceName: serviceName, from: dockerCompose) + } + + if !detatch { + await waitForever() + } } - // Process each service defined in the docker-compose.yml - print("\n--- Processing Services ---") - - print(services.map(\.serviceName)) - for (serviceName, service) in services { - try await configService(service, serviceName: serviceName, from: dockerCompose) + func waitForever() async -> Never { + for await _ in AsyncStream(unfolding: {}) { + // This will never run + } + fatalError("unreachable") } - if !detatch { - await waitForever() - } - } - - func waitForever() async -> Never { - for await _ in AsyncStream(unfolding: {}) { - // This will never run - } - fatalError("unreachable") - } - - func getIPForRunningService(_ serviceName: String) async throws -> String? { - guard let projectName else { return nil } - - let containerName = "\(projectName)-\(serviceName)" - - // Run the container list command - let containerCommandOutput = try await runCommand("container", args: ["list", "-a"]) - let allLines = containerCommandOutput.stdout.components(separatedBy: .newlines) - - // Find the line matching the full container name - guard let matchingLine = allLines.first(where: { $0.contains(containerName) }) else { + func getIPForRunningService(_ serviceName: String) async throws -> String? { + guard let projectName else { return nil } + + let containerName = "\(projectName)-\(serviceName)" + + // Run the container list command + let containers = ContainerList(all: true) + let containerCommandOutput = try await runCommand("container", args: ["list", "-a"]) + let allLines = containerCommandOutput.stdout.components(separatedBy: .newlines) + + // Find the line matching the full container name + guard let matchingLine = allLines.first(where: { $0.contains(containerName) }) else { + return nil + } + + // Extract IP using regex + let pattern = #"\b(?:\d{1,3}\.){3}\d{1,3}\b"# + let regex = try NSRegularExpression(pattern: pattern) + + let range = NSRange(matchingLine.startIndex.. String { - - var buildCommandArgs: [String] = ["build"] - - // Determine image tag for built image - let imageToRun = service.image ?? "\(serviceName):latest" - let searchName = imageToRun.split(separator: ":").first - - let imagesList = try await runCommand("container", args: ["images", "list"]).stdout - if !rebuild, let searchName, imagesList.contains(searchName) { + + /// Builds Docker Service + /// + /// - Parameters: + /// - buildConfig: The configuration for the build + /// - service: The service you would like to build + /// - serviceName: The fallback name for the image + /// + /// - Returns: Image Name (`String`) + func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { + + var buildCommandArgs: [String] = ["build"] + + // Determine image tag for built image + let imageToRun = service.image ?? "\(serviceName):latest" + let searchName = imageToRun.split(separator: ":").first + + let imagesList = try await runCommand("container", args: ["images", "list"]).stdout + if !rebuild, let searchName, imagesList.contains(searchName) { + return imageToRun + } + + do { + try await runCommand("container", args: ["images", "rm", imageToRun]) + } catch { + } + + buildCommandArgs.append("--tag") + buildCommandArgs.append(imageToRun) + + // Resolve build context path + let resolvedContext = resolveVariable(buildConfig.context, with: environmentVariables) + buildCommandArgs.append(resolvedContext) + + // Add Dockerfile path if specified + if let dockerfile = buildConfig.dockerfile { + let resolvedDockerfile = resolveVariable(dockerfile, with: environmentVariables) + buildCommandArgs.append("--file") + buildCommandArgs.append(resolvedDockerfile) + } + + // Add build arguments + if let args = buildConfig.args { + for (key, value) in args { + let resolvedValue = resolveVariable(value, with: environmentVariables) + buildCommandArgs.append("--build-arg") + buildCommandArgs.append("\(key)=\(resolvedValue)") + } + } + + print("\n----------------------------------------") + print("Building image for service: \(serviceName) (Tag: \(imageToRun))") + print("Executing container build: container \(buildCommandArgs.joined(separator: " "))") + try await streamCommand("container", args: buildCommandArgs, onStdout: { print($0.blue) }, onStderr: { print($0.blue) }) + print("Image build for \(serviceName) completed.") + print("----------------------------------------") + return imageToRun } - - do { - try await runCommand("container", args: ["images", "rm", imageToRun]) - } catch { - } - - buildCommandArgs.append("--tag") - buildCommandArgs.append(imageToRun) - - // Resolve build context path - let resolvedContext = resolveVariable(buildConfig.context, with: environmentVariables) - buildCommandArgs.append(resolvedContext) - - // Add Dockerfile path if specified - if let dockerfile = buildConfig.dockerfile { - let resolvedDockerfile = resolveVariable(dockerfile, with: environmentVariables) - buildCommandArgs.append("--file") - buildCommandArgs.append(resolvedDockerfile) - } - - // Add build arguments - if let args = buildConfig.args { - for (key, value) in args { - let resolvedValue = resolveVariable(value, with: environmentVariables) - buildCommandArgs.append("--build-arg") - buildCommandArgs.append("\(key)=\(resolvedValue)") + + func configVolume(_ volume: String) async throws -> [String] { + let resolvedVolume = resolveVariable(volume, with: environmentVariables) + + var runCommandArgs: [String] = [] + + // Parse the volume string: destination[:mode] + let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) + + guard components.count >= 2 else { + print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") + return [] } - } - - print("\n----------------------------------------") - print("Building image for service: \(serviceName) (Tag: \(imageToRun))") - print("Executing container build: container \(buildCommandArgs.joined(separator: " "))") - try await streamCommand("container", args: buildCommandArgs, onStdout: { print($0.blue) }, onStderr: { print($0.blue) }) - print("Image build for \(serviceName) completed.") - print("----------------------------------------") - - return imageToRun - } - - func configVolume(_ volume: String) async throws -> [String] { - let resolvedVolume = resolveVariable(volume, with: environmentVariables) - - var runCommandArgs: [String] = [] - - // Parse the volume string: destination[:mode] - let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) - - guard components.count >= 2 else { - print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") - return [] - } - - let source = components[0] - let destination = components[1] - - // Check if the source looks like a host path (contains '/' or starts with '.') - // This heuristic helps distinguish bind mounts from named volume references. - if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { - // This is likely a bind mount (local path to container path) - var isDirectory: ObjCBool = false - // Ensure the path is absolute or relative to the current directory for FileManager - let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) - - if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { - if isDirectory.boolValue { - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + + let source = components[0] + let destination = components[1] + + // Check if the source looks like a host path (contains '/' or starts with '.') + // This heuristic helps distinguish bind mounts from named volume references. + if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { + // This is likely a bind mount (local path to container path) + var isDirectory: ObjCBool = false + // Ensure the path is absolute or relative to the current directory for FileManager + let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) + + if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { + if isDirectory.boolValue { + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + } else { + // Host path exists but is a file + print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") + } } else { - // Host path exists but is a file - print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") + // Host path does not exist, assume it's meant to be a directory and try to create it. + do { + try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) + print("Info: Created missing host directory for volume: \(fullHostPath)") + runCommandArgs.append("-v") + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + } catch { + print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") + } } } else { - // Host path does not exist, assume it's meant to be a directory and try to create it. - do { - try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) - print("Info: Created missing host directory for volume: \(fullHostPath)") - runCommandArgs.append("-v") - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument - } catch { - print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") - } + guard let projectName else { return [] } + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") + let volumePath = volumeUrl.path(percentEncoded: false) + + let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() + let destinationPath = destinationUrl.path(percentEncoded: false) + + print( + "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + ) + try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument } - } else { - guard let projectName else { return [] } - let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") - let volumePath = volumeUrl.path(percentEncoded: false) - - let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() - let destinationPath = destinationUrl.path(percentEncoded: false) - - print( - "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." - ) - try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) - - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument + + return runCommandArgs } - - return runCommandArgs } } // MARK: CommandLine Functions -extension ComposeUp { +extension Application.ComposeUp { /// Runs a command-line tool asynchronously and captures its output and exit code. /// diff --git a/Sources/CLI/Compose/ComposeCommand.swift b/Sources/CLI/Compose/ComposeCommand.swift index 964b8f958..03e940332 100644 --- a/Sources/CLI/Compose/ComposeCommand.swift +++ b/Sources/CLI/Compose/ComposeCommand.swift @@ -26,14 +26,16 @@ import Foundation import Rainbow import Yams -struct ComposeCommand: AsyncParsableCommand { - static let configuration: CommandConfiguration = .init( - commandName: "compose", - abstract: "Manage containers with Docker Compose files", - subcommands: [ - ComposeUp.self, - ComposeDown.self, - ]) +extension Application { + struct ComposeCommand: AsyncParsableCommand { + static let configuration: CommandConfiguration = .init( + commandName: "compose", + abstract: "Manage containers with Docker Compose files", + subcommands: [ + ComposeUp.self, + ComposeDown.self, + ]) + } } /// A structure representing the result of a command-line process execution. From ab643d81fccc2b264230713ff9a87282f8083d69 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 15:08:51 -0700 Subject: [PATCH 13/80] Access control fixes --- Sources/CLI/Compose/Commands/ComposeUp.swift | 3 +- Sources/CLI/Compose/Errors.swift | 68 +++++------ Sources/CLI/Compose/Helper Functions.swift | 118 ++++++++++--------- 3 files changed, 97 insertions(+), 92 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index d43a85838..7f52503c7 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -151,7 +151,6 @@ extension Application { let containerName = "\(projectName)-\(serviceName)" // Run the container list command - let containers = ContainerList(all: true) let containerCommandOutput = try await runCommand("container", args: ["list", "-a"]) let allLines = containerCommandOutput.stdout.components(separatedBy: .newlines) @@ -713,7 +712,7 @@ extension Application.ComposeUp { let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() guard stderrData.isEmpty else { - continuation.resume(throwing: TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self))) + continuation.resume(throwing: Application.TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self))) return } diff --git a/Sources/CLI/Compose/Errors.swift b/Sources/CLI/Compose/Errors.swift index 030182d60..c5b375aa2 100644 --- a/Sources/CLI/Compose/Errors.swift +++ b/Sources/CLI/Compose/Errors.swift @@ -23,42 +23,44 @@ import Foundation -enum YamlError: Error, LocalizedError { - case dockerfileNotFound(String) - - var errorDescription: String? { - switch self { - case .dockerfileNotFound(let path): - return "docker-compose.yml not found at \(path)" +extension Application { + internal enum YamlError: Error, LocalizedError { + case dockerfileNotFound(String) + + var errorDescription: String? { + switch self { + case .dockerfileNotFound(let path): + return "docker-compose.yml not found at \(path)" + } } } -} - -enum ComposeError: Error, LocalizedError { - case imageNotFound(String) - case invalidProjectName - - var errorDescription: String? { - switch self { - case .imageNotFound(let name): - return "Service \(name) must define either 'image' or 'build'." - case .invalidProjectName: - return "Could not find project name." + + internal enum ComposeError: Error, LocalizedError { + case imageNotFound(String) + case invalidProjectName + + var errorDescription: String? { + switch self { + case .imageNotFound(let name): + return "Service \(name) must define either 'image' or 'build'." + case .invalidProjectName: + return "Could not find project name." + } } } -} - -enum TerminalError: Error, LocalizedError { - case commandFailed(String) - - var errorDescription: String? { - "Command failed: \(self)" + + internal enum TerminalError: Error, LocalizedError { + case commandFailed(String) + + var errorDescription: String? { + "Command failed: \(self)" + } + } + + /// An enum representing streaming output from either `stdout` or `stderr`. + internal enum CommandOutput { + case stdout(String) + case stderr(String) + case exitCode(Int32) } -} - -/// An enum representing streaming output from either `stdout` or `stderr`. -enum CommandOutput { - case stdout(String) - case stderr(String) - case exitCode(Int32) } diff --git a/Sources/CLI/Compose/Helper Functions.swift b/Sources/CLI/Compose/Helper Functions.swift index acfbb7b55..e1068ad94 100644 --- a/Sources/CLI/Compose/Helper Functions.swift +++ b/Sources/CLI/Compose/Helper Functions.swift @@ -24,69 +24,73 @@ import Foundation import Yams -/// Loads environment variables from a .env file. -/// - Parameter path: The full path to the .env file. -/// - Returns: A dictionary of key-value pairs representing environment variables. -func loadEnvFile(path: String) -> [String: String] { - var envVars: [String: String] = [:] - let fileURL = URL(fileURLWithPath: path) - do { - let content = try String(contentsOf: fileURL, encoding: .utf8) - let lines = content.split(separator: "\n") - for line in lines { - let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) - // Ignore empty lines and comments - if !trimmedLine.isEmpty && !trimmedLine.starts(with: "#") { - // Parse key=value pairs - if let eqIndex = trimmedLine.firstIndex(of: "=") { - let key = String(trimmedLine[.. [String: String] { + var envVars: [String: String] = [:] + let fileURL = URL(fileURLWithPath: path) + do { + let content = try String(contentsOf: fileURL, encoding: .utf8) + let lines = content.split(separator: "\n") + for line in lines { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + // Ignore empty lines and comments + if !trimmedLine.isEmpty && !trimmedLine.starts(with: "#") { + // Parse key=value pairs + if let eqIndex = trimmedLine.firstIndex(of: "=") { + let key = String(trimmedLine[.. String { - var resolvedValue = value - // Regex to find ${VAR}, ${VAR:-default}, ${VAR:?error} - let regex = try! NSRegularExpression(pattern: "\\$\\{([A-Z0-9_]+)(:?-(.*?))?(:\\?(.*?))?\\}", options: []) - // Combine process environment with loaded .env file variables, prioritizing process environment - let combinedEnv = ProcessInfo.processInfo.environment.merging(envVars) { (current, _) in current } - - // Loop to resolve all occurrences of variables in the string - while let match = regex.firstMatch(in: resolvedValue, options: [], range: NSRange(resolvedValue.startIndex.. String { + var resolvedValue = value + // Regex to find ${VAR}, ${VAR:-default}, ${VAR:?error} + let regex = try! NSRegularExpression(pattern: "\\$\\{([A-Z0-9_]+)(:?-(.*?))?(:\\?(.*?))?\\}", options: []) - if let envValue = combinedEnv[varName] { - // Variable found in environment, replace with its value - resolvedValue.replaceSubrange(Range(match.range(at: 0), in: resolvedValue)!, with: envValue) - } else if let defaultValueRange = Range(match.range(at: 3), in: resolvedValue) { - // Variable not found, but default value is provided, replace with default - let defaultValue = String(resolvedValue[defaultValueRange]) - resolvedValue.replaceSubrange(Range(match.range(at: 0), in: resolvedValue)!, with: defaultValue) - } else if match.range(at: 5).location != NSNotFound, let errorMessageRange = Range(match.range(at: 5), in: resolvedValue) { - // Variable not found, and error-on-missing syntax used, print error and exit - let errorMessage = String(resolvedValue[errorMessageRange]) - fputs("Error: Missing required environment variable '\(varName)': \(errorMessage)\n", stderr) - exit(1) - } else { - // Variable not found and no default/error specified, leave as is and break loop to avoid infinite loop - break + // Combine process environment with loaded .env file variables, prioritizing process environment + let combinedEnv = ProcessInfo.processInfo.environment.merging(envVars) { (current, _) in current } + + // Loop to resolve all occurrences of variables in the string + while let match = regex.firstMatch(in: resolvedValue, options: [], range: NSRange(resolvedValue.startIndex.. Date: Tue, 8 Jul 2025 15:59:53 -0700 Subject: [PATCH 14/80] update network setup function --- Sources/CLI/Compose/Commands/ComposeUp.swift | 40 +++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index 7f52503c7..d746a6ff8 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -256,28 +256,41 @@ extension Application { } else { var networkCreateArgs: [String] = ["network", "create"] + #warning("Docker Compose Network Options Not Supported") // Add driver and driver options if let driver = networkConfig.driver { - networkCreateArgs.append("--driver") - networkCreateArgs.append(driver) +// networkCreateArgs.append("--driver") +// networkCreateArgs.append(driver) + print("Network Driver Detected, But Not Supported") } if let driverOpts = networkConfig.driver_opts { - for (optKey, optValue) in driverOpts { - networkCreateArgs.append("--opt") - networkCreateArgs.append("\(optKey)=\(optValue)") - } +// for (optKey, optValue) in driverOpts { +// networkCreateArgs.append("--opt") +// networkCreateArgs.append("\(optKey)=\(optValue)") +// } + print("Network Options Detected, But Not Supported") } // Add various network flags - if networkConfig.attachable == true { networkCreateArgs.append("--attachable") } - if networkConfig.enable_ipv6 == true { networkCreateArgs.append("--ipv6") } - if networkConfig.isInternal == true { networkCreateArgs.append("--internal") } // CORRECTED: Use isInternal + if networkConfig.attachable == true { +// networkCreateArgs.append("--attachable") + print("Network Attachable Flag Detected, But Not Supported") + } + if networkConfig.enable_ipv6 == true { +// networkCreateArgs.append("--ipv6") + print("Network IPv6 Flag Detected, But Not Supported") + } + if networkConfig.isInternal == true { +// networkCreateArgs.append("--internal") + print("Network Internal Flag Detected, But Not Supported") + } // CORRECTED: Use isInternal // Add labels if let labels = networkConfig.labels { - for (labelKey, labelValue) in labels { - networkCreateArgs.append("--label") - networkCreateArgs.append("\(labelKey)=\(labelValue)") - } + print("Network Labels Detected, But Not Supported") +// for (labelKey, labelValue) in labels { +// networkCreateArgs.append("--label") +// networkCreateArgs.append("\(labelKey)=\(labelValue)") +// } } networkCreateArgs.append(actualNetworkName) // Add the network name @@ -285,7 +298,6 @@ extension Application { print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") let _ = try await runCommand("container", args: networkCreateArgs) -#warning("Network creation output not used") print("Network '\(networkName)' created or already exists.") } } From 1ee92a6dcdc9ed01636d142cf76ab7fdbfffe593 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 16:08:42 -0700 Subject: [PATCH 15/80] add networking support --- Sources/CLI/Compose/Commands/ComposeUp.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index d746a6ff8..6702c7547 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -297,7 +297,7 @@ extension Application { print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") - let _ = try await runCommand("container", args: networkCreateArgs) + let _ = try? await runCommand("container", args: networkCreateArgs) print("Network '\(networkName)' created or already exists.") } } From e09b012210caa4316d086d742930071aa5c77f93 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 16:09:37 -0700 Subject: [PATCH 16/80] warning fixes --- Sources/CLI/Compose/Commands/ComposeDown.swift | 2 +- Sources/CLI/Compose/Commands/ComposeUp.swift | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeDown.swift b/Sources/CLI/Compose/Commands/ComposeDown.swift index f2df0d3fe..0f02a9d87 100644 --- a/Sources/CLI/Compose/Commands/ComposeDown.swift +++ b/Sources/CLI/Compose/Commands/ComposeDown.swift @@ -63,7 +63,7 @@ extension Application { print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") } else { projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") } var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index 6702c7547..b98426c4f 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -88,7 +88,7 @@ extension Application { print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") } else { projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName)") + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") } // Get Services to use @@ -258,12 +258,12 @@ extension Application { #warning("Docker Compose Network Options Not Supported") // Add driver and driver options - if let driver = networkConfig.driver { + if let driver = networkConfig.driver, !driver.isEmpty { // networkCreateArgs.append("--driver") // networkCreateArgs.append(driver) print("Network Driver Detected, But Not Supported") } - if let driverOpts = networkConfig.driver_opts { + if let driverOpts = networkConfig.driver_opts, !driverOpts.isEmpty { // for (optKey, optValue) in driverOpts { // networkCreateArgs.append("--opt") // networkCreateArgs.append("\(optKey)=\(optValue)") @@ -285,7 +285,7 @@ extension Application { } // CORRECTED: Use isInternal // Add labels - if let labels = networkConfig.labels { + if let labels = networkConfig.labels, !labels.isEmpty { print("Network Labels Detected, But Not Supported") // for (labelKey, labelValue) in labels { // networkCreateArgs.append("--label") From acf7c8b3a41c96437ce6b3e625b04b7e1b7f8bcb Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 16:12:11 -0700 Subject: [PATCH 17/80] access control fixes --- .../CLI/Compose/Commands/ComposeDown.swift | 4 ++-- Sources/CLI/Compose/Commands/ComposeUp.swift | 20 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeDown.swift b/Sources/CLI/Compose/Commands/ComposeDown.swift index 0f02a9d87..ec1713137 100644 --- a/Sources/CLI/Compose/Commands/ComposeDown.swift +++ b/Sources/CLI/Compose/Commands/ComposeDown.swift @@ -79,7 +79,7 @@ extension Application { try await stopOldStuff(services.map({ $0.serviceName }), remove: false) } - func stopOldStuff(_ services: [String], remove: Bool) async throws { + private func stopOldStuff(_ services: [String], remove: Bool) async throws { guard let projectName else { return } let containers = services.map { "\(projectName)-\($0)" } @@ -114,7 +114,7 @@ extension Application { /// print(result.stdout) // "Hello\n" /// ``` @discardableResult - func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult { + private func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult { try await withCheckedThrowingContinuation { continuation in let process = Process() let stdoutPipe = Pipe() diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index b98426c4f..afed4dcdc 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -145,7 +145,7 @@ extension Application { fatalError("unreachable") } - func getIPForRunningService(_ serviceName: String) async throws -> String? { + private func getIPForRunningService(_ serviceName: String) async throws -> String? { guard let projectName else { return nil } let containerName = "\(projectName)-\(serviceName)" @@ -179,7 +179,7 @@ extension Application { /// - timeout: Max seconds to wait before failing. /// - interval: How often to poll (in seconds). /// - Returns: `true` if the container reached "running" state within the timeout. - func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { + private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { guard let projectName else { return } let containerName = "\(projectName)-\(serviceName)" @@ -205,7 +205,7 @@ extension Application { ]) } - func stopOldStuff(_ services: [String], remove: Bool) async throws { + private func stopOldStuff(_ services: [String], remove: Bool) async throws { guard let projectName else { return } let containers = services.map { "\(projectName)-\($0)" } @@ -226,7 +226,7 @@ extension Application { // MARK: Compose Top Level Functions - mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { + private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { let ip = try await getIPForRunningService(serviceName) self.containerIps[serviceName] = ip for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { @@ -234,7 +234,7 @@ extension Application { } } - func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { + private func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { guard let projectName else { return } let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name @@ -247,7 +247,7 @@ extension Application { try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) } - func setupNetwork(name networkName: String, config networkConfig: Network) async throws { + private func setupNetwork(name networkName: String, config networkConfig: Network) async throws { let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name if let externalNetwork = networkConfig.external, externalNetwork.isExternal { @@ -303,7 +303,7 @@ extension Application { } // MARK: Compose Service Level Functions - mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { + private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { guard let projectName else { throw ComposeError.invalidProjectName } var imageToRun: String @@ -534,7 +534,7 @@ extension Application { } } - func pullImage(_ image: String) async throws { + private func pullImage(_ image: String) async throws { let imageList = try await runCommand("container", args: ["images", "ls"]).stdout.replacingOccurrences(of: " ", with: "") guard !imageList.contains(image.replacingOccurrences(of: ":", with: "")) else { return @@ -556,7 +556,7 @@ extension Application { /// - serviceName: The fallback name for the image /// /// - Returns: Image Name (`String`) - func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { + private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { var buildCommandArgs: [String] = ["build"] @@ -607,7 +607,7 @@ extension Application { return imageToRun } - func configVolume(_ volume: String) async throws -> [String] { + private func configVolume(_ volume: String) async throws -> [String] { let resolvedVolume = resolveVariable(volume, with: environmentVariables) var runCommandArgs: [String] = [] From 11cacaf7e5cf0bac1faae70bcc614b70c1352725 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 18:03:37 -0700 Subject: [PATCH 18/80] Begin update of functions using internal controls instead of runCommand --- Sources/CLI/Compose/Commands/ComposeUp.swift | 41 +++++++------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index afed4dcdc..8838565a9 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -26,6 +26,7 @@ import ContainerClient import Foundation @preconcurrency import Rainbow import Yams +import ContainerizationExtras extension Application { struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { @@ -48,6 +49,9 @@ extension Application { @OptionGroup var process: Flags.Process + @OptionGroup + var global: Flags.Global + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file @@ -150,27 +154,10 @@ extension Application { let containerName = "\(projectName)-\(serviceName)" - // Run the container list command - let containerCommandOutput = try await runCommand("container", args: ["list", "-a"]) - let allLines = containerCommandOutput.stdout.components(separatedBy: .newlines) - - // Find the line matching the full container name - guard let matchingLine = allLines.first(where: { $0.contains(containerName) }) else { - return nil - } - - // Extract IP using regex - let pattern = #"\b(?:\d{1,3}\.){3}\d{1,3}\b"# - let regex = try NSRegularExpression(pattern: pattern) + let container = try await ClientContainer.get(id: containerName) + let ip = container.networks.compactMap { try? CIDRAddress($0.address).address.description }.first - let range = NSRange(matchingLine.startIndex.. Date: Tue, 8 Jul 2025 21:00:19 -0700 Subject: [PATCH 19/80] updated network and image pull to use internal tools --- Sources/CLI/Compose/Commands/ComposeUp.swift | 43 ++++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index 8838565a9..bf87221f7 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -278,12 +278,18 @@ extension Application { // } } - networkCreateArgs.append(actualNetworkName) // Add the network name - print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") - let _ = try? await runCommand("container", args: networkCreateArgs) - print("Network '\(networkName)' created or already exists.") + guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else { + print("Network '\(networkName)' already exists") + return + } + var networkCreate = NetworkCreate() + networkCreate.global = global + networkCreate.name = actualNetworkName + + try await networkCreate.run() + print("Network '\(networkName)' created") } } @@ -519,18 +525,29 @@ extension Application { } } - private func pullImage(_ image: String) async throws { - let imageList = try await runCommand("container", args: ["images", "ls"]).stdout.replacingOccurrences(of: " ", with: "") - guard !imageList.contains(image.replacingOccurrences(of: ":", with: "")) else { + private func pullImage(_ imageName: String) async throws { + let imageList = try await ClientImage.list() + guard !imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageName }) else { return } - print("Pulling Image \(image)...") - try await streamCommand("container", args: ["image", "pull", image]) { str in - print(str.blue) - } onStderr: { str in - print(str.red) - } + print("Pulling Image \(imageName)...") + + let processedReference = try ClientImage.normalizeReference(imageName) + + var registry = Flags.Registry() + registry.scheme = "auto" // Set or SwiftArgumentParser gets mad + + var progress = Flags.Progress() + progress.disableProgressUpdates = false + + var imagePull = ImagePull() + imagePull.progressFlags = progress + imagePull.registry = registry + imagePull.global = global + imagePull.reference = imageName + imagePull.platform = nil + try await imagePull.run() } /// Builds Docker Service From 10e1ed5018d8fd6abbe194ca86b8896cd34057ef Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 21:07:12 -0700 Subject: [PATCH 20/80] add platform support for image pulling for services --- Sources/CLI/Compose/Codable Structs/Service.swift | 3 +++ Sources/CLI/Compose/Commands/ComposeUp.swift | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/CLI/Compose/Codable Structs/Service.swift b/Sources/CLI/Compose/Codable Structs/Service.swift index 61cd483af..3ad74bcb6 100644 --- a/Sources/CLI/Compose/Codable Structs/Service.swift +++ b/Sources/CLI/Compose/Codable Structs/Service.swift @@ -83,6 +83,9 @@ struct Service: Codable, Hashable { /// Working directory inside the container let working_dir: String? + /// Platform architecture for the service + let platform: String? + /// Service-specific config usage (primarily for Swarm) let configs: [ServiceConfig]? diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index bf87221f7..06d4c1d72 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -305,7 +305,7 @@ extension Application { } else if let img = service.image { // Use specified image if no build config // Pull image if necessary - try await pullImage(img) + try await pullImage(img, platform: service.container_name) imageToRun = img } else { // Should not happen due to Service init validation, but as a fallback @@ -525,7 +525,7 @@ extension Application { } } - private func pullImage(_ imageName: String) async throws { + private func pullImage(_ imageName: String, platform: String?) async throws { let imageList = try await ClientImage.list() guard !imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageName }) else { return @@ -546,7 +546,7 @@ extension Application { imagePull.registry = registry imagePull.global = global imagePull.reference = imageName - imagePull.platform = nil + imagePull.platform = platform try await imagePull.run() } @@ -565,7 +565,7 @@ extension Application { // Determine image tag for built image let imageToRun = service.image ?? "\(serviceName):latest" let searchName = imageToRun.split(separator: ":").first - + BuildCommand(targetImageName: <#T##String#>) let imagesList = try await runCommand("container", args: ["images", "list"]).stdout if !rebuild, let searchName, imagesList.contains(searchName) { return imageToRun From 0b0d68d08e7c0f81725c2935e8f128c933da2c27 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 21:52:57 -0700 Subject: [PATCH 21/80] preliminary update build to use internal tooling --- Sources/CLI/BuildCommand.swift | 17 +++--- .../CLI/Compose/Codable Structs/Service.swift | 3 +- Sources/CLI/Compose/Commands/ComposeUp.swift | 60 ++++++++++--------- 3 files changed, 42 insertions(+), 38 deletions(-) diff --git a/Sources/CLI/BuildCommand.swift b/Sources/CLI/BuildCommand.swift index 05426e1b7..5e95fc4b0 100644 --- a/Sources/CLI/BuildCommand.swift +++ b/Sources/CLI/BuildCommand.swift @@ -128,6 +128,7 @@ extension Application { let container = try await ClientContainer.get(id: "buildkit") let fh = try await container.dial(self.vsockPort) + progress.set(description: "FH: \(fh.debugDescription)") let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) let b = try Builder(socket: fh, group: threadGroup) @@ -137,14 +138,14 @@ extension Application { } catch { // If we get here, "Dialing builder" is shown for such a short period // of time that it's invisible to the user. - progress.set(tasks: 0) - progress.set(totalTasks: 3) - - try await BuilderStart.start( - cpus: self.cpus, - memory: self.memory, - progressUpdate: progress.handler - ) +// progress.set(tasks: 0) +// progress.set(totalTasks: 3) +// +// try await BuilderStart.start( +// cpus: self.cpus, +// memory: self.memory, +// progressUpdate: progress.handler +// ) // wait (seconds) for builder to start listening on vsock try await Task.sleep(for: .seconds(5)) diff --git a/Sources/CLI/Compose/Codable Structs/Service.swift b/Sources/CLI/Compose/Codable Structs/Service.swift index 3ad74bcb6..1c5aeb528 100644 --- a/Sources/CLI/Compose/Codable Structs/Service.swift +++ b/Sources/CLI/Compose/Codable Structs/Service.swift @@ -104,7 +104,7 @@ struct Service: Codable, Hashable { // Defines custom coding keys to map YAML keys to Swift properties enum CodingKeys: String, CodingKey { case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, - container_name, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty + container_name, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform } /// Custom initializer to handle decoding and basic validation. @@ -158,6 +158,7 @@ struct Service: Codable, Hashable { secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets) stdin_open = try container.decodeIfPresent(Bool.self, forKey: .stdin_open) tty = try container.decodeIfPresent(Bool.self, forKey: .tty) + platform = try container.decodeIfPresent(String.self, forKey: .platform) } /// Returns the services in topological order based on `depends_on` relationships. diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index 06d4c1d72..256d8f68f 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -46,6 +46,9 @@ extension Application { @Flag(name: [.customShort("b"), .customLong("build")]) var rebuild: Bool = false + @Flag(name: .long, help: "Do not use cache") + var noCache: Bool = false + @OptionGroup var process: Flags.Process @@ -559,50 +562,49 @@ extension Application { /// /// - Returns: Image Name (`String`) private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { - - var buildCommandArgs: [String] = ["build"] - // Determine image tag for built image let imageToRun = service.image ?? "\(serviceName):latest" let searchName = imageToRun.split(separator: ":").first - BuildCommand(targetImageName: <#T##String#>) let imagesList = try await runCommand("container", args: ["images", "list"]).stdout if !rebuild, let searchName, imagesList.contains(searchName) { return imageToRun } - do { - try await runCommand("container", args: ["images", "rm", imageToRun]) - } catch { - } + var buildCommand = BuildCommand() - buildCommandArgs.append("--tag") - buildCommandArgs.append(imageToRun) + // Set Build Commands + buildCommand.buildArg = (buildConfig.args ?? [:]).map({ "\($0.key)=\(resolveVariable($0.value, with: environmentVariables))" }) - // Resolve build context path - let resolvedContext = resolveVariable(buildConfig.context, with: environmentVariables) - buildCommandArgs.append(resolvedContext) + // Locate Dockerfile and context + buildCommand.contextDir = buildConfig.context + buildCommand.file = buildConfig.dockerfile ?? "Dockerfile" - // Add Dockerfile path if specified - if let dockerfile = buildConfig.dockerfile { - let resolvedDockerfile = resolveVariable(dockerfile, with: environmentVariables) - buildCommandArgs.append("--file") - buildCommandArgs.append(resolvedDockerfile) - } + // Handle Caching + buildCommand.noCache = noCache + buildCommand.cacheIn = [] + buildCommand.cacheOut = [] - // Add build arguments - if let args = buildConfig.args { - for (key, value) in args { - let resolvedValue = resolveVariable(value, with: environmentVariables) - buildCommandArgs.append("--build-arg") - buildCommandArgs.append("\(key)=\(resolvedValue)") - } - } + // Handle OS/Arch + let split = service.platform?.split(separator: "/") + buildCommand.os = [String(split?.first ?? "linux")] + buildCommand.arch = [String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64")] + + // Set Image Name + buildCommand.targetImageName = imageToRun + + // Set CPU & Memory + buildCommand.cpus = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 + buildCommand.memory = service.deploy?.resources?.limits?.memory ?? "2048MB" + // Set Miscelaneous + buildCommand.label = [] // No Label Equivalent? + buildCommand.progress = "auto" + buildCommand.vsockPort = 8080 + buildCommand.quiet = false + buildCommand.target = "" print("\n----------------------------------------") print("Building image for service: \(serviceName) (Tag: \(imageToRun))") - print("Executing container build: container \(buildCommandArgs.joined(separator: " "))") - try await streamCommand("container", args: buildCommandArgs, onStdout: { print($0.blue) }, onStderr: { print($0.blue) }) + try await buildCommand.run() print("Image build for \(serviceName) completed.") print("----------------------------------------") From 630e288e3e912d1c88a8e2a8ee8f65a2a278f7cb Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 22:41:08 -0700 Subject: [PATCH 22/80] image build fixes --- Sources/CLI/BuildCommand.swift | 19 +++++++++---------- Sources/CLI/Compose/Commands/ComposeUp.swift | 8 +++++--- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/Sources/CLI/BuildCommand.swift b/Sources/CLI/BuildCommand.swift index 5e95fc4b0..a1e84c258 100644 --- a/Sources/CLI/BuildCommand.swift +++ b/Sources/CLI/BuildCommand.swift @@ -127,8 +127,7 @@ extension Application { do { let container = try await ClientContainer.get(id: "buildkit") let fh = try await container.dial(self.vsockPort) - - progress.set(description: "FH: \(fh.debugDescription)") + let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) let b = try Builder(socket: fh, group: threadGroup) @@ -138,14 +137,14 @@ extension Application { } catch { // If we get here, "Dialing builder" is shown for such a short period // of time that it's invisible to the user. -// progress.set(tasks: 0) -// progress.set(totalTasks: 3) -// -// try await BuilderStart.start( -// cpus: self.cpus, -// memory: self.memory, -// progressUpdate: progress.handler -// ) + progress.set(tasks: 0) + progress.set(totalTasks: 3) + + try await BuilderStart.start( + cpus: self.cpus, + memory: self.memory, + progressUpdate: progress.handler + ) // wait (seconds) for builder to start listening on vsock try await Task.sleep(for: .seconds(5)) diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index 256d8f68f..b5a5d7c1c 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -576,8 +576,8 @@ extension Application { buildCommand.buildArg = (buildConfig.args ?? [:]).map({ "\($0.key)=\(resolveVariable($0.value, with: environmentVariables))" }) // Locate Dockerfile and context - buildCommand.contextDir = buildConfig.context - buildCommand.file = buildConfig.dockerfile ?? "Dockerfile" + buildCommand.contextDir = "\(self.cwd)/\(buildConfig.context)" + buildCommand.file = "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")" // Handle Caching buildCommand.noCache = noCache @@ -599,11 +599,13 @@ extension Application { // Set Miscelaneous buildCommand.label = [] // No Label Equivalent? buildCommand.progress = "auto" - buildCommand.vsockPort = 8080 + buildCommand.vsockPort = 8088 buildCommand.quiet = false buildCommand.target = "" + buildCommand.output = ["type=oci"] print("\n----------------------------------------") print("Building image for service: \(serviceName) (Tag: \(imageToRun))") + try buildCommand.validate() try await buildCommand.run() print("Image build for \(serviceName) completed.") print("----------------------------------------") From 7306ba4b34b63ae6d275c844fe285a6790ec8410 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 22:43:42 -0700 Subject: [PATCH 23/80] Update ComposeUp.swift --- Sources/CLI/Compose/Commands/ComposeUp.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index b5a5d7c1c..e1729f921 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -564,9 +564,8 @@ extension Application { private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { // Determine image tag for built image let imageToRun = service.image ?? "\(serviceName):latest" - let searchName = imageToRun.split(separator: ":").first - let imagesList = try await runCommand("container", args: ["images", "list"]).stdout - if !rebuild, let searchName, imagesList.contains(searchName) { + let imageList = try await ClientImage.list() + if !rebuild, imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageToRun }) { return imageToRun } From 8acd11f06b1a2ad575d4ae323950a25537d40855 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 22:48:39 -0700 Subject: [PATCH 24/80] code cleanup --- .../CLI/Compose/Commands/ComposeDown.swift | 66 +------------ Sources/CLI/Compose/Commands/ComposeUp.swift | 95 ------------------- 2 files changed, 4 insertions(+), 157 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeDown.swift b/Sources/CLI/Compose/Commands/ComposeDown.swift index ec1713137..c4682cbb0 100644 --- a/Sources/CLI/Compose/Commands/ComposeDown.swift +++ b/Sources/CLI/Compose/Commands/ComposeDown.swift @@ -85,77 +85,19 @@ extension Application { for container in containers { print("Stopping container: \(container)") + guard let container = try? await ClientContainer.get(id: container) else { continue } + do { - try await runCommand("container", args: ["stop", container]) + try await container.stop() } catch { } if remove { do { - try await runCommand("container", args: ["rm", container]) + try await container.delete() } catch { } } } } - - /// Runs a command-line tool asynchronously and captures its output and exit code. - /// - /// This function uses async/await and `Process` to launch a command-line tool, - /// returning a `CommandResult` containing the output, error, and exit code upon completion. - /// - /// - Parameters: - /// - command: The full path to the executable to run (e.g., `/bin/ls`). - /// - args: An array of arguments to pass to the command. Defaults to an empty array. - /// - Returns: A `CommandResult` containing `stdout`, `stderr`, and `exitCode`. - /// - Throws: An error if the process fails to launch. - /// - Example: - /// ```swift - /// let result = try await runCommand("/bin/echo", args: ["Hello"]) - /// print(result.stdout) // "Hello\n" - /// ``` - @discardableResult - private func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult { - try await withCheckedThrowingContinuation { continuation in - let process = Process() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [command] + args - process.currentDirectoryURL = URL(fileURLWithPath: cwd) - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - // Manually set PATH so it can find `container` - process.environment = ProcessInfo.processInfo.environment.merging([ - "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" - ]) { _, new in new } - - do { - try process.run() - } catch { - continuation.resume(throwing: error) - return - } - - process.terminationHandler = { proc in - let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - - guard stderrData.isEmpty else { - continuation.resume(throwing: TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self))) - return - } - - let result = CommandResult( - stdout: String(decoding: stdoutData, as: UTF8.self), - stderr: String(decoding: stderrData, as: UTF8.self), - exitCode: proc.terminationStatus - ) - - continuation.resume(returning: result) - } - } - } } } diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index e1729f921..693d16ec7 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -684,66 +684,6 @@ extension Application { // MARK: CommandLine Functions extension Application.ComposeUp { - /// Runs a command-line tool asynchronously and captures its output and exit code. - /// - /// This function uses async/await and `Process` to launch a command-line tool, - /// returning a `CommandResult` containing the output, error, and exit code upon completion. - /// - /// - Parameters: - /// - command: The full path to the executable to run (e.g., `/bin/ls`). - /// - args: An array of arguments to pass to the command. Defaults to an empty array. - /// - Returns: A `CommandResult` containing `stdout`, `stderr`, and `exitCode`. - /// - Throws: An error if the process fails to launch. - /// - Example: - /// ```swift - /// let result = try await runCommand("/bin/echo", args: ["Hello"]) - /// print(result.stdout) // "Hello\n" - /// ``` - @discardableResult - func runCommand(_ command: String, args: [String] = []) async throws -> CommandResult { - try await withCheckedThrowingContinuation { continuation in - let process = Process() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [command] + args - process.currentDirectoryURL = URL(fileURLWithPath: cwd) - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - // Manually set PATH so it can find `container` - process.environment = ProcessInfo.processInfo.environment.merging([ - "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" - ]) { _, new in new } - - do { - try process.run() - } catch { - continuation.resume(throwing: error) - return - } - - process.terminationHandler = { proc in - let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - - guard stderrData.isEmpty else { - continuation.resume(throwing: Application.TerminalError.commandFailed(String(decoding: stderrData, as: UTF8.self))) - return - } - - let result = CommandResult( - stdout: String(decoding: stdoutData, as: UTF8.self), - stderr: String(decoding: stderrData, as: UTF8.self), - exitCode: proc.terminationStatus - ) - - continuation.resume(returning: result) - } - } - } - /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. /// /// - Parameters: @@ -807,39 +747,4 @@ extension Application.ComposeUp { } } } - - /// Launches a detached command-line process without waiting for its output or termination. - /// - /// This function is useful when you want to spawn a process that runs in the background - /// independently of the current ComposeUp. Output streams are redirected to null devices. - /// - /// - Parameters: - /// - command: The full path to the executable to launch (e.g., `/usr/bin/open`). - /// - args: An array of arguments to pass to the command. Defaults to an empty array. - /// - Returns: The `Process` instance that was launched, in case you want to retain or manage it. - /// - Throws: An error if the process fails to launch. - /// - Example: - /// ```swift - /// try launchDetachedCommand("/usr/bin/open", args: ["/ComposeUps/Calculator.app"]) - /// ``` - @discardableResult - func launchDetachedCommand(_ command: String, args: [String] = []) throws -> Process { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [command] + args - process.currentDirectoryURL = URL(fileURLWithPath: cwd) - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - process.standardInput = FileHandle.nullDevice - // Manually set PATH so it can find `container` - process.environment = ProcessInfo.processInfo.environment.merging([ - "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" - ]) { _, new in new } - - // Set this to true to run independently of the launching app - process.qualityOfService = .background - - try process.run() - return process - } } From c7936d9cfe2111d60f5e5ca5e9377f043cf0c687 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:03:37 -0700 Subject: [PATCH 25/80] Update ComposeUp.swift --- Sources/CLI/Compose/Commands/ComposeUp.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index 693d16ec7..a6fdc34d2 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -535,9 +535,6 @@ extension Application { } print("Pulling Image \(imageName)...") - - let processedReference = try ClientImage.normalizeReference(imageName) - var registry = Flags.Registry() registry.scheme = "auto" // Set or SwiftArgumentParser gets mad From 0ef6fbda33c21d8195e65f37d333eaa6627aded1 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:12:01 -0700 Subject: [PATCH 26/80] access control updates --- Sources/CLI/Application.swift | 8 +++++--- Sources/CLI/Compose/Commands/ComposeDown.swift | 8 +++++--- Sources/CLI/Compose/Commands/ComposeUp.swift | 8 +++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/Sources/CLI/Application.swift b/Sources/CLI/Application.swift index 9a180ac70..ba002a74c 100644 --- a/Sources/CLI/Application.swift +++ b/Sources/CLI/Application.swift @@ -41,11 +41,13 @@ nonisolated(unsafe) var log = { }() @main -struct Application: AsyncParsableCommand { +public struct Application: AsyncParsableCommand { + public init() {} + @OptionGroup var global: Flags.Global - static let configuration = CommandConfiguration( + public static let configuration = CommandConfiguration( commandName: "container", abstract: "A container platform for macOS", version: releaseVersion(), @@ -224,7 +226,7 @@ struct Application: AsyncParsableCommand { } } - func validate() throws { + public func validate() throws { // Not really a "validation", but a cheat to run this before // any of the commands do their business. let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"] diff --git a/Sources/CLI/Compose/Commands/ComposeDown.swift b/Sources/CLI/Compose/Commands/ComposeDown.swift index c4682cbb0..8993f8ddb 100644 --- a/Sources/CLI/Compose/Commands/ComposeDown.swift +++ b/Sources/CLI/Compose/Commands/ComposeDown.swift @@ -27,8 +27,10 @@ import Foundation import Yams extension Application { - struct ComposeDown: AsyncParsableCommand { - static let configuration: CommandConfiguration = .init( + public struct ComposeDown: AsyncParsableCommand { + public init() {} + + public static let configuration: CommandConfiguration = .init( commandName: "down", abstract: "Stop containers with compose" ) @@ -46,7 +48,7 @@ extension Application { private var fileManager: FileManager { FileManager.default } private var projectName: String? - mutating func run() async throws { + public mutating func run() async throws { // Read docker-compose.yml content guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { throw YamlError.dockerfileNotFound(dockerComposePath) diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Sources/CLI/Compose/Commands/ComposeUp.swift index a6fdc34d2..6b1053670 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Sources/CLI/Compose/Commands/ComposeUp.swift @@ -29,8 +29,10 @@ import Yams import ContainerizationExtras extension Application { - struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { - static let configuration: CommandConfiguration = .init( + public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { + public init() {} + + public static let configuration: CommandConfiguration = .init( commandName: "up", abstract: "Start containers with compose" ) @@ -69,7 +71,7 @@ extension Application { .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, ] - mutating func run() async throws { + public mutating func run() async throws { // Read docker-compose.yml content guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { throw YamlError.dockerfileNotFound(dockerComposePath) From 4c517c53871262d0fd06e8261c51964bca2e1712 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:12:42 -0700 Subject: [PATCH 27/80] Update Package.swift --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index 633e62c8b..88c0107f6 100644 --- a/Package.swift +++ b/Package.swift @@ -42,6 +42,7 @@ let package = Package( .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService"]), .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerClient", targets: ["ContainerClient"]), + .library(name: "ContainerCLI", targets: ["ContainerCLI"]), .library(name: "ContainerBuild", targets: ["ContainerBuild"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), From 7b7a0ab9258e38b87975cbec78885a5003aa6c4b Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:16:19 -0700 Subject: [PATCH 28/80] exposed CLI as product --- Package.swift | 20 ++++++++++++++++++++ Sources/ContainerCLI | Bin 0 -> 916 bytes 2 files changed, 20 insertions(+) create mode 100644 Sources/ContainerCLI diff --git a/Package.swift b/Package.swift index 88c0107f6..9ff27cb33 100644 --- a/Package.swift +++ b/Package.swift @@ -84,6 +84,26 @@ let package = Package( ], path: "Sources/CLI" ), + .target( + name: "ContainerCLI", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "SwiftProtobuf", package: "swift-protobuf"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + "CVersion", + "TerminalProgress", + "ContainerBuild", + "ContainerClient", + "ContainerPlugin", + "ContainerLog", + "Yams", + "Rainbow", + ], + path: "Sources/ContainerCLI" + ), .executableTarget( name: "container-apiserver", dependencies: [ diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI new file mode 100644 index 0000000000000000000000000000000000000000..ce9b7e01c3c1838dc5198f48c2a927a02ac3d673 GIT binary patch literal 916 zcmZvayG{a85Qfis#YR^#YUjq-5|G=16&H;fO)L~bVS~#OiNKOYV`GfI0TyN?bbnot|+K=eGCL%gLVKhi6YPCaIXpJU4ma;2bxp~)HIHT!3#7@5p{B1!!f z3@u+Mn(JP#*YOcAWsUs<%^#NUE5uXa0=50~ynH?1!C$5Qs80lfn+cz;dxCm2=n2O4 zkSCh-M?Hz8KN1eDg*L(oU7r1h_CRm$;gw?4%Zru1gMxR9ZIv)~5v z9Jt2p2G^N2@GG+qEc+`>+(C}df{}7;u8Em&Txk#LRj~ZNi|@U=OB+_eE*{P=_%T-2 literal 0 HcmV?d00001 From 1d9704fb709988053aa2250af229fedb6b12c875 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:20:59 -0700 Subject: [PATCH 29/80] copy cli to container cli --- Sources/ContainerCLI | Bin 916 -> 0 bytes Sources/ContainerCLI/Application.swift | 335 ++++++++ Sources/ContainerCLI/BuildCommand.swift | 313 ++++++++ Sources/ContainerCLI/Builder/Builder.swift | 31 + .../ContainerCLI/Builder/BuilderDelete.swift | 57 ++ .../ContainerCLI/Builder/BuilderStart.swift | 262 ++++++ .../ContainerCLI/Builder/BuilderStatus.swift | 71 ++ .../ContainerCLI/Builder/BuilderStop.swift | 49 ++ Sources/ContainerCLI/Codable+JSON.swift | 25 + .../Compose/Codable Structs/Build.swift | 52 ++ .../Compose/Codable Structs/Config.swift | 55 ++ .../Compose/Codable Structs/Deploy.swift | 35 + .../Codable Structs/DeployResources.swift | 31 + .../Codable Structs/DeployRestartPolicy.swift | 35 + .../Codable Structs/DeviceReservation.swift | 35 + .../Codable Structs/DockerCompose.swift | 60 ++ .../Codable Structs/ExternalConfig.swift | 31 + .../Codable Structs/ExternalNetwork.swift | 31 + .../Codable Structs/ExternalSecret.swift | 31 + .../Codable Structs/ExternalVolume.swift | 31 + .../Compose/Codable Structs/Healthcheck.swift | 37 + .../Compose/Codable Structs/Network.swift | 68 ++ .../Codable Structs/ResourceLimits.swift | 31 + .../ResourceReservations.swift | 34 + .../Compose/Codable Structs/Secret.swift | 58 ++ .../Compose/Codable Structs/Service.swift | 203 +++++ .../Codable Structs/ServiceConfig.swift | 64 ++ .../Codable Structs/ServiceSecret.swift | 64 ++ .../Compose/Codable Structs/Volume.swift | 70 ++ .../Compose/Commands/ComposeDown.swift | 105 +++ .../Compose/Commands/ComposeUp.swift | 749 ++++++++++++++++++ .../ContainerCLI/Compose/ComposeCommand.swift | 55 ++ Sources/ContainerCLI/Compose/Errors.swift | 66 ++ .../Compose/Helper Functions.swift | 96 +++ .../Container/ContainerCreate.swift | 100 +++ .../Container/ContainerDelete.swift | 127 +++ .../Container/ContainerExec.swift | 96 +++ .../Container/ContainerInspect.swift | 43 + .../Container/ContainerKill.swift | 79 ++ .../Container/ContainerList.swift | 110 +++ .../Container/ContainerLogs.swift | 144 ++++ .../Container/ContainerStart.swift | 87 ++ .../Container/ContainerStop.swift | 102 +++ .../Container/ContainersCommand.swift | 38 + .../ContainerCLI/Container/ProcessUtils.swift | 31 + Sources/ContainerCLI/DefaultCommand.swift | 54 ++ Sources/ContainerCLI/Image/ImageInspect.swift | 53 ++ Sources/ContainerCLI/Image/ImageList.swift | 175 ++++ Sources/ContainerCLI/Image/ImageLoad.swift | 76 ++ Sources/ContainerCLI/Image/ImagePrune.swift | 38 + Sources/ContainerCLI/Image/ImagePull.swift | 98 +++ Sources/ContainerCLI/Image/ImagePush.swift | 73 ++ Sources/ContainerCLI/Image/ImageRemove.swift | 99 +++ Sources/ContainerCLI/Image/ImageSave.swift | 67 ++ Sources/ContainerCLI/Image/ImageTag.swift | 42 + .../ContainerCLI/Image/ImagesCommand.swift | 38 + .../ContainerCLI/Network/NetworkCommand.swift | 33 + .../ContainerCLI/Network/NetworkCreate.swift | 42 + .../ContainerCLI/Network/NetworkDelete.swift | 116 +++ .../ContainerCLI/Network/NetworkInspect.swift | 44 + .../ContainerCLI/Network/NetworkList.swift | 107 +++ Sources/ContainerCLI/Registry/Login.swift | 92 +++ Sources/ContainerCLI/Registry/Logout.swift | 39 + .../Registry/RegistryCommand.swift | 32 + .../Registry/RegistryDefault.swift | 98 +++ Sources/ContainerCLI/RunCommand.swift | 317 ++++++++ .../ContainerCLI/System/DNS/DNSCreate.swift | 51 ++ .../ContainerCLI/System/DNS/DNSDefault.swift | 72 ++ .../ContainerCLI/System/DNS/DNSDelete.swift | 49 ++ Sources/ContainerCLI/System/DNS/DNSList.swift | 36 + .../System/Kernel/KernelSet.swift | 114 +++ .../ContainerCLI/System/SystemCommand.swift | 35 + Sources/ContainerCLI/System/SystemDNS.swift | 34 + .../ContainerCLI/System/SystemKernel.swift | 29 + Sources/ContainerCLI/System/SystemLogs.swift | 82 ++ Sources/ContainerCLI/System/SystemStart.swift | 170 ++++ .../ContainerCLI/System/SystemStatus.swift | 52 ++ Sources/ContainerCLI/System/SystemStop.swift | 91 +++ 78 files changed, 6775 insertions(+) delete mode 100644 Sources/ContainerCLI create mode 100644 Sources/ContainerCLI/Application.swift create mode 100644 Sources/ContainerCLI/BuildCommand.swift create mode 100644 Sources/ContainerCLI/Builder/Builder.swift create mode 100644 Sources/ContainerCLI/Builder/BuilderDelete.swift create mode 100644 Sources/ContainerCLI/Builder/BuilderStart.swift create mode 100644 Sources/ContainerCLI/Builder/BuilderStatus.swift create mode 100644 Sources/ContainerCLI/Builder/BuilderStop.swift create mode 100644 Sources/ContainerCLI/Codable+JSON.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Build.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Config.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Network.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Secret.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Service.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Volume.swift create mode 100644 Sources/ContainerCLI/Compose/Commands/ComposeDown.swift create mode 100644 Sources/ContainerCLI/Compose/Commands/ComposeUp.swift create mode 100644 Sources/ContainerCLI/Compose/ComposeCommand.swift create mode 100644 Sources/ContainerCLI/Compose/Errors.swift create mode 100644 Sources/ContainerCLI/Compose/Helper Functions.swift create mode 100644 Sources/ContainerCLI/Container/ContainerCreate.swift create mode 100644 Sources/ContainerCLI/Container/ContainerDelete.swift create mode 100644 Sources/ContainerCLI/Container/ContainerExec.swift create mode 100644 Sources/ContainerCLI/Container/ContainerInspect.swift create mode 100644 Sources/ContainerCLI/Container/ContainerKill.swift create mode 100644 Sources/ContainerCLI/Container/ContainerList.swift create mode 100644 Sources/ContainerCLI/Container/ContainerLogs.swift create mode 100644 Sources/ContainerCLI/Container/ContainerStart.swift create mode 100644 Sources/ContainerCLI/Container/ContainerStop.swift create mode 100644 Sources/ContainerCLI/Container/ContainersCommand.swift create mode 100644 Sources/ContainerCLI/Container/ProcessUtils.swift create mode 100644 Sources/ContainerCLI/DefaultCommand.swift create mode 100644 Sources/ContainerCLI/Image/ImageInspect.swift create mode 100644 Sources/ContainerCLI/Image/ImageList.swift create mode 100644 Sources/ContainerCLI/Image/ImageLoad.swift create mode 100644 Sources/ContainerCLI/Image/ImagePrune.swift create mode 100644 Sources/ContainerCLI/Image/ImagePull.swift create mode 100644 Sources/ContainerCLI/Image/ImagePush.swift create mode 100644 Sources/ContainerCLI/Image/ImageRemove.swift create mode 100644 Sources/ContainerCLI/Image/ImageSave.swift create mode 100644 Sources/ContainerCLI/Image/ImageTag.swift create mode 100644 Sources/ContainerCLI/Image/ImagesCommand.swift create mode 100644 Sources/ContainerCLI/Network/NetworkCommand.swift create mode 100644 Sources/ContainerCLI/Network/NetworkCreate.swift create mode 100644 Sources/ContainerCLI/Network/NetworkDelete.swift create mode 100644 Sources/ContainerCLI/Network/NetworkInspect.swift create mode 100644 Sources/ContainerCLI/Network/NetworkList.swift create mode 100644 Sources/ContainerCLI/Registry/Login.swift create mode 100644 Sources/ContainerCLI/Registry/Logout.swift create mode 100644 Sources/ContainerCLI/Registry/RegistryCommand.swift create mode 100644 Sources/ContainerCLI/Registry/RegistryDefault.swift create mode 100644 Sources/ContainerCLI/RunCommand.swift create mode 100644 Sources/ContainerCLI/System/DNS/DNSCreate.swift create mode 100644 Sources/ContainerCLI/System/DNS/DNSDefault.swift create mode 100644 Sources/ContainerCLI/System/DNS/DNSDelete.swift create mode 100644 Sources/ContainerCLI/System/DNS/DNSList.swift create mode 100644 Sources/ContainerCLI/System/Kernel/KernelSet.swift create mode 100644 Sources/ContainerCLI/System/SystemCommand.swift create mode 100644 Sources/ContainerCLI/System/SystemDNS.swift create mode 100644 Sources/ContainerCLI/System/SystemKernel.swift create mode 100644 Sources/ContainerCLI/System/SystemLogs.swift create mode 100644 Sources/ContainerCLI/System/SystemStart.swift create mode 100644 Sources/ContainerCLI/System/SystemStatus.swift create mode 100644 Sources/ContainerCLI/System/SystemStop.swift diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI deleted file mode 100644 index ce9b7e01c3c1838dc5198f48c2a927a02ac3d673..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 916 zcmZvayG{a85Qfis#YR^#YUjq-5|G=16&H;fO)L~bVS~#OiNKOYV`GfI0TyN?bbnot|+K=eGCL%gLVKhi6YPCaIXpJU4ma;2bxp~)HIHT!3#7@5p{B1!!f z3@u+Mn(JP#*YOcAWsUs<%^#NUE5uXa0=50~ynH?1!C$5Qs80lfn+cz;dxCm2=n2O4 zkSCh-M?Hz8KN1eDg*L(oU7r1h_CRm$;gw?4%Zru1gMxR9ZIv)~5v z9Jt2p2G^N2@GG+qEc+`>+(C}df{}7;u8Em&Txk#LRj~ZNi|@U=OB+_eE*{P=_%T-2 diff --git a/Sources/ContainerCLI/Application.swift b/Sources/ContainerCLI/Application.swift new file mode 100644 index 000000000..ba002a74c --- /dev/null +++ b/Sources/ContainerCLI/Application.swift @@ -0,0 +1,335 @@ +//===----------------------------------------------------------------------===// +// 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 CVersion +import ContainerClient +import ContainerLog +import ContainerPlugin +import ContainerizationError +import ContainerizationOS +import Foundation +import Logging +import TerminalProgress + +// `log` is updated only once in the `validate()` method. +nonisolated(unsafe) var log = { + LoggingSystem.bootstrap { label in + OSLogHandler( + label: label, + category: "CLI" + ) + } + var log = Logger(label: "com.apple.container") + log.logLevel = .debug + return log +}() + +@main +public struct Application: AsyncParsableCommand { + public init() {} + + @OptionGroup + var global: Flags.Global + + public static let configuration = CommandConfiguration( + commandName: "container", + abstract: "A container platform for macOS", + version: releaseVersion(), + subcommands: [ + DefaultCommand.self + ], + groupedSubcommands: [ + CommandGroup( + name: "Container", + subcommands: [ + ComposeCommand.self, + ContainerCreate.self, + ContainerDelete.self, + ContainerExec.self, + ContainerInspect.self, + ContainerKill.self, + ContainerList.self, + ContainerLogs.self, + ContainerRunCommand.self, + ContainerStart.self, + ContainerStop.self, + ] + ), + CommandGroup( + name: "Image", + subcommands: [ + BuildCommand.self, + ImagesCommand.self, + RegistryCommand.self, + ] + ), + CommandGroup( + name: "Other", + subcommands: Self.otherCommands() + ), + ], + // Hidden command to handle plugins on unrecognized input. + defaultSubcommand: DefaultCommand.self + ) + + static let appRoot: URL = { + FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + .appendingPathComponent("com.apple.container") + }() + + static let pluginLoader: PluginLoader = { + // create user-installed plugins directory if it doesn't exist + let pluginsURL = PluginLoader.userPluginsDir(root: Self.appRoot) + try! FileManager.default.createDirectory(at: pluginsURL, withIntermediateDirectories: true) + let pluginDirectories = [ + pluginsURL + ] + let pluginFactories = [ + DefaultPluginFactory() + ] + + let statePath = PluginLoader.defaultPluginResourcePath(root: Self.appRoot) + try! FileManager.default.createDirectory(at: statePath, withIntermediateDirectories: true) + return PluginLoader(pluginDirectories: pluginDirectories, pluginFactories: pluginFactories, defaultResourcePath: statePath, log: log) + }() + + public static func main() async throws { + restoreCursorAtExit() + + #if DEBUG + let warning = "Running debug build. Performance may be degraded." + let formattedWarning = "\u{001B}[33mWarning!\u{001B}[0m \(warning)\n" + let warningData = Data(formattedWarning.utf8) + FileHandle.standardError.write(warningData) + #endif + + let fullArgs = CommandLine.arguments + let args = Array(fullArgs.dropFirst()) + + do { + // container -> defaultHelpCommand + var command = try Application.parseAsRoot(args) + if var asyncCommand = command as? AsyncParsableCommand { + try await asyncCommand.run() + } else { + try command.run() + } + } catch { + // Regular ol `command` with no args will get caught by DefaultCommand. --help + // on the root command will land here. + let containsHelp = fullArgs.contains("-h") || fullArgs.contains("--help") + if fullArgs.count <= 2 && containsHelp { + Self.printModifiedHelpText() + return + } + let errorAsString: String = String(describing: error) + if errorAsString.contains("XPC connection error") { + let modifiedError = ContainerizationError(.interrupted, message: "\(error)\nEnsure container system service has been started with `container system start`.") + Application.exit(withError: modifiedError) + } else { + Application.exit(withError: error) + } + } + } + + static func handleProcess(io: ProcessIO, process: ClientProcess) async throws -> Int32 { + let signals = AsyncSignalHandler.create(notify: Application.signalSet) + return try await withThrowingTaskGroup(of: Int32?.self, returning: Int32.self) { group in + let waitAdded = group.addTaskUnlessCancelled { + let code = try await process.wait() + try await io.wait() + return code + } + + guard waitAdded else { + group.cancelAll() + return -1 + } + + try await process.start(io.stdio) + defer { + try? io.close() + } + try io.closeAfterStart() + + if let current = io.console { + let size = try current.size + // It's supremely possible the process could've exited already. We shouldn't treat + // this as fatal. + try? await process.resize(size) + _ = group.addTaskUnlessCancelled { + let winchHandler = AsyncSignalHandler.create(notify: [SIGWINCH]) + for await _ in winchHandler.signals { + do { + try await process.resize(try current.size) + } catch { + log.error( + "failed to send terminal resize event", + metadata: [ + "error": "\(error)" + ] + ) + } + } + return nil + } + } else { + _ = group.addTaskUnlessCancelled { + for await sig in signals.signals { + do { + try await process.kill(sig) + } catch { + log.error( + "failed to send signal", + metadata: [ + "signal": "\(sig)", + "error": "\(error)", + ] + ) + } + } + return nil + } + } + + while true { + let result = try await group.next() + if result == nil { + return -1 + } + let status = result! + if let status { + group.cancelAll() + return status + } + } + return -1 + } + } + + public func validate() throws { + // Not really a "validation", but a cheat to run this before + // any of the commands do their business. + let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"] + if self.global.debug || debugEnvVar != nil { + log.logLevel = .debug + } + // Ensure we're not running under Rosetta. + if try isTranslated() { + throw ValidationError( + """ + `container` is currently running under Rosetta Translation, which could be + caused by your terminal application. Please ensure this is turned off. + """ + ) + } + } + + private static func otherCommands() -> [any ParsableCommand.Type] { + guard #available(macOS 26, *) else { + return [ + BuilderCommand.self, + SystemCommand.self, + ] + } + + return [ + BuilderCommand.self, + NetworkCommand.self, + SystemCommand.self, + ] + } + + private static func restoreCursorAtExit() { + let signalHandler: @convention(c) (Int32) -> Void = { signal in + let exitCode = ExitCode(signal + 128) + Application.exit(withError: exitCode) + } + // Termination by Ctrl+C. + signal(SIGINT, signalHandler) + // Termination using `kill`. + signal(SIGTERM, signalHandler) + // Normal and explicit exit. + atexit { + if let progressConfig = try? ProgressConfig() { + let progressBar = ProgressBar(config: progressConfig) + progressBar.resetCursor() + } + } + } +} + +extension Application { + // Because we support plugins, we need to modify the help text to display + // any if we found some. + static func printModifiedHelpText() { + let altered = Self.pluginLoader.alterCLIHelpText( + original: Application.helpMessage(for: Application.self) + ) + print(altered) + } + + enum ListFormat: String, CaseIterable, ExpressibleByArgument { + case json + case table + } + + static let signalSet: [Int32] = [ + SIGTERM, + SIGINT, + SIGUSR1, + SIGUSR2, + SIGWINCH, + ] + + func isTranslated() throws -> Bool { + do { + return try Sysctl.byName("sysctl.proc_translated") == 1 + } catch let posixErr as POSIXError { + if posixErr.code == .ENOENT { + return false + } + throw posixErr + } + } + + private static func releaseVersion() -> String { + var versionDetails: [String: String] = ["build": "release"] + #if DEBUG + versionDetails["build"] = "debug" + #endif + let gitCommit = { + let sha = get_git_commit().map { String(cString: $0) } + guard let sha else { + return "unspecified" + } + return String(sha.prefix(7)) + }() + versionDetails["commit"] = gitCommit + let extras: String = versionDetails.map { "\($0): \($1)" }.sorted().joined(separator: ", ") + + let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) + let releaseVersion = bundleVersion ?? get_release_version().map { String(cString: $0) } ?? "0.0.0" + + return "container CLI version \(releaseVersion) (\(extras))" + } +} diff --git a/Sources/ContainerCLI/BuildCommand.swift b/Sources/ContainerCLI/BuildCommand.swift new file mode 100644 index 000000000..a1e84c258 --- /dev/null +++ b/Sources/ContainerCLI/BuildCommand.swift @@ -0,0 +1,313 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerBuild +import ContainerClient +import ContainerImagesServiceClient +import Containerization +import ContainerizationError +import ContainerizationOCI +import ContainerizationOS +import Foundation +import NIO +import TerminalProgress + +extension Application { + struct BuildCommand: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "build" + config.abstract = "Build an image from a Dockerfile" + config._superCommandName = "container" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") + public var cpus: Int64 = 2 + + @Option( + name: [.customLong("memory"), .customShort("m")], + help: + "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" + ) + var memory: String = "2048MB" + + @Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val")) + var buildArg: [String] = [] + + @Argument(help: "Build directory") + var contextDir: String = "." + + @Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path")) + var file: String = "Dockerfile" + + @Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val")) + var label: [String] = [] + + @Flag(name: .long, help: "Do not use cache") + var noCache: Bool = false + + @Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build", valueName: "value")) + var output: [String] = { + ["type=oci"] + }() + + @Option(name: .long, help: ArgumentHelp("Cache imports for the build", valueName: "value", visibility: .hidden)) + var cacheIn: [String] = { + [] + }() + + @Option(name: .long, help: ArgumentHelp("Cache exports for the build", valueName: "value", visibility: .hidden)) + var cacheOut: [String] = { + [] + }() + + @Option(name: .long, help: ArgumentHelp("set the build architecture", valueName: "value")) + var arch: [String] = { + ["arm64"] + }() + + @Option(name: .long, help: ArgumentHelp("set the build os", valueName: "value")) + var os: [String] = { + ["linux"] + }() + + @Option(name: .long, help: ArgumentHelp("Progress type - one of [auto|plain|tty]", valueName: "type")) + var progress: String = "auto" + + @Option(name: .long, help: ArgumentHelp("Builder-shim vsock port", valueName: "port")) + var vsockPort: UInt32 = 8088 + + @Option(name: [.customShort("t"), .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) + var targetImageName: String = UUID().uuidString.lowercased() + + @Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage")) + var target: String = "" + + @Flag(name: .shortAndLong, help: "Suppress build output") + var quiet: Bool = false + + func run() async throws { + do { + let timeout: Duration = .seconds(300) + let progressConfig = try ProgressConfig( + showTasks: true, + showItems: true + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + progress.set(description: "Dialing builder") + + let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { group in + defer { + group.cancelAll() + } + + group.addTask { + while true { + do { + let container = try await ClientContainer.get(id: "buildkit") + let fh = try await container.dial(self.vsockPort) + + let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + let b = try Builder(socket: fh, group: threadGroup) + + // If this call succeeds, then BuildKit is running. + let _ = try await b.info() + return b + } catch { + // If we get here, "Dialing builder" is shown for such a short period + // of time that it's invisible to the user. + progress.set(tasks: 0) + progress.set(totalTasks: 3) + + try await BuilderStart.start( + cpus: self.cpus, + memory: self.memory, + progressUpdate: progress.handler + ) + + // wait (seconds) for builder to start listening on vsock + try await Task.sleep(for: .seconds(5)) + continue + } + } + } + + group.addTask { + try await Task.sleep(for: timeout) + throw ValidationError( + """ + Timeout waiting for connection to builder + """ + ) + } + + return try await group.next() + } + + guard let builder else { + throw ValidationError("builder is not running") + } + + let dockerfile = try Data(contentsOf: URL(filePath: file)) + let exportPath = Application.appRoot.appendingPathComponent(".build") + + let buildID = UUID().uuidString + let tempURL = exportPath.appendingPathComponent(buildID) + try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil) + defer { + try? FileManager.default.removeItem(at: tempURL) + } + + let imageName: String = try { + let parsedReference = try Reference.parse(targetImageName) + parsedReference.normalize() + return parsedReference.description + }() + + var terminal: Terminal? + switch self.progress { + case "tty": + terminal = try Terminal(descriptor: STDERR_FILENO) + case "auto": + terminal = try? Terminal(descriptor: STDERR_FILENO) + case "plain": + terminal = nil + default: + throw ContainerizationError(.invalidArgument, message: "invalid progress mode \(self.progress)") + } + + defer { terminal?.tryReset() } + + let exports: [Builder.BuildExport] = try output.map { output in + var exp = try Builder.BuildExport(from: output) + if exp.destination == nil { + exp.destination = tempURL.appendingPathComponent("out.tar") + } + return exp + } + + try await withThrowingTaskGroup(of: Void.self) { [terminal] group in + defer { + group.cancelAll() + } + group.addTask { + let handler = AsyncSignalHandler.create(notify: [SIGTERM, SIGINT, SIGUSR1, SIGUSR2]) + for await sig in handler.signals { + throw ContainerizationError(.interrupted, message: "exiting on signal \(sig)") + } + } + let platforms: [Platform] = try { + var results: [Platform] = [] + for o in self.os { + for a in self.arch { + guard let platform = try? Platform(from: "\(o)/\(a)") else { + throw ValidationError("invalid os/architecture combination \(o)/\(a)") + } + results.append(platform) + } + } + return results + }() + group.addTask { [terminal] in + let config = ContainerBuild.Builder.BuildConfig( + buildID: buildID, + contentStore: RemoteContentStoreClient(), + buildArgs: buildArg, + contextDir: contextDir, + dockerfile: dockerfile, + labels: label, + noCache: noCache, + platforms: platforms, + terminal: terminal, + tag: imageName, + target: target, + quiet: quiet, + exports: exports, + cacheIn: cacheIn, + cacheOut: cacheOut + ) + progress.finish() + + try await builder.build(config) + } + + try await group.next() + } + + let unpackProgressConfig = try ProgressConfig( + description: "Unpacking built image", + itemsName: "entries", + showTasks: exports.count > 1, + totalTasks: exports.count + ) + let unpackProgress = ProgressBar(config: unpackProgressConfig) + defer { + unpackProgress.finish() + } + unpackProgress.start() + + let taskManager = ProgressTaskCoordinator() + // Currently, only a single export can be specified. + for exp in exports { + unpackProgress.add(tasks: 1) + let unpackTask = await taskManager.startTask() + switch exp.type { + case "oci": + try Task.checkCancellation() + guard let dest = exp.destination else { + throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)") + } + let loaded = try await ClientImage.load(from: dest.absolutePath()) + + for image in loaded { + try Task.checkCancellation() + try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler)) + } + case "tar": + break + default: + throw ContainerizationError(.invalidArgument, message: "invalid exporter \(exp.rawValue)") + } + } + await taskManager.finish() + unpackProgress.finish() + print("Successfully built \(imageName)") + } catch { + throw NSError(domain: "Build", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(error)"]) + } + } + + func validate() throws { + guard FileManager.default.fileExists(atPath: file) else { + throw ValidationError("Dockerfile does not exist at path: \(file)") + } + guard FileManager.default.fileExists(atPath: contextDir) else { + throw ValidationError("context dir does not exist \(contextDir)") + } + guard let _ = try? Reference.parse(targetImageName) else { + throw ValidationError("invalid reference \(targetImageName)") + } + } + } +} diff --git a/Sources/ContainerCLI/Builder/Builder.swift b/Sources/ContainerCLI/Builder/Builder.swift new file mode 100644 index 000000000..ad9eb6c97 --- /dev/null +++ b/Sources/ContainerCLI/Builder/Builder.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct BuilderCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "builder", + abstract: "Manage an image builder instance", + subcommands: [ + BuilderStart.self, + BuilderStatus.self, + BuilderStop.self, + BuilderDelete.self, + ]) + } +} diff --git a/Sources/ContainerCLI/Builder/BuilderDelete.swift b/Sources/ContainerCLI/Builder/BuilderDelete.swift new file mode 100644 index 000000000..e848da95e --- /dev/null +++ b/Sources/ContainerCLI/Builder/BuilderDelete.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct BuilderDelete: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "delete" + config._superCommandName = "builder" + config.abstract = "Delete builder" + config.usage = "\n\t builder delete [command options]" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Flag(name: .shortAndLong, help: "Force delete builder even if it is running") + var force = false + + func run() async throws { + do { + let container = try await ClientContainer.get(id: "buildkit") + if container.status != .stopped { + guard force else { + throw ContainerizationError(.invalidState, message: "BuildKit container is not stopped, use --force to override") + } + try await container.stop() + } + try await container.delete() + } catch { + if error is ContainerizationError { + if (error as? ContainerizationError)?.code == .notFound { + return + } + } + throw error + } + } + } +} diff --git a/Sources/ContainerCLI/Builder/BuilderStart.swift b/Sources/ContainerCLI/Builder/BuilderStart.swift new file mode 100644 index 000000000..5800b712e --- /dev/null +++ b/Sources/ContainerCLI/Builder/BuilderStart.swift @@ -0,0 +1,262 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerBuild +import ContainerClient +import ContainerNetworkService +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Foundation +import TerminalProgress + +extension Application { + struct BuilderStart: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "start" + config._superCommandName = "builder" + config.abstract = "Start builder" + config.usage = "\nbuilder start [command options]" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") + public var cpus: Int64 = 2 + + @Option( + name: [.customLong("memory"), .customShort("m")], + help: + "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" + ) + public var memory: String = "2048MB" + + func run() async throws { + let progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + totalTasks: 4 + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + try await Self.start(cpus: self.cpus, memory: self.memory, progressUpdate: progress.handler) + progress.finish() + } + + static func start(cpus: Int64?, memory: String?, progressUpdate: @escaping ProgressUpdateHandler) async throws { + await progressUpdate([ + .setDescription("Fetching BuildKit image"), + .setItemsName("blobs"), + ]) + let taskManager = ProgressTaskCoordinator() + let fetchTask = await taskManager.startTask() + + let builderImage: String = ClientDefaults.get(key: .defaultBuilderImage) + let exportsMount: String = Application.appRoot.appendingPathComponent(".build").absolutePath() + + if !FileManager.default.fileExists(atPath: exportsMount) { + try FileManager.default.createDirectory( + atPath: exportsMount, + withIntermediateDirectories: true, + attributes: nil + ) + } + + let builderPlatform = ContainerizationOCI.Platform(arch: "arm64", os: "linux", variant: "v8") + + let existingContainer = try? await ClientContainer.get(id: "buildkit") + if let existingContainer { + let existingImage = existingContainer.configuration.image.reference + let existingResources = existingContainer.configuration.resources + + // Check if we need to recreate the builder due to different image + let imageChanged = existingImage != builderImage + let cpuChanged = { + if let cpus { + if existingResources.cpus != cpus { + return true + } + } + return false + }() + let memChanged = try { + if let memory { + let memoryInBytes = try Parser.resources(cpus: nil, memory: memory).memoryInBytes + if existingResources.memoryInBytes != memoryInBytes { + return true + } + } + return false + }() + + switch existingContainer.status { + case .running: + guard imageChanged || cpuChanged || memChanged else { + // If image, mem and cpu are the same, continue using the existing builder + return + } + // If they changed, stop and delete the existing builder + try await existingContainer.stop() + try await existingContainer.delete() + case .stopped: + // If the builder is stopped and matches our requirements, start it + // Otherwise, delete it and create a new one + guard imageChanged || cpuChanged || memChanged else { + try await existingContainer.startBuildKit(progressUpdate, nil) + return + } + try await existingContainer.delete() + case .stopping: + throw ContainerizationError( + .invalidState, + message: "builder is stopping, please wait until it is fully stopped before proceeding" + ) + case .unknown: + break + } + } + + let shimArguments: [String] = [ + "--debug", + "--vsock", + ] + + let id = "buildkit" + try ContainerClient.Utility.validEntityName(id) + + let processConfig = ProcessConfiguration( + executable: "/usr/local/bin/container-builder-shim", + arguments: shimArguments, + environment: [], + workingDirectory: "/", + terminal: false, + user: .id(uid: 0, gid: 0) + ) + + let resources = try Parser.resources( + cpus: cpus, + memory: memory + ) + + let image = try await ClientImage.fetch( + reference: builderImage, + platform: builderPlatform, + progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate) + ) + // Unpack fetched image before use + await progressUpdate([ + .setDescription("Unpacking BuildKit image"), + .setItemsName("entries"), + ]) + + let unpackTask = await taskManager.startTask() + _ = try await image.getCreateSnapshot( + platform: builderPlatform, + progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate) + ) + let imageConfig = ImageDescription( + reference: builderImage, + descriptor: image.descriptor + ) + + var config = ContainerConfiguration(id: id, image: imageConfig, process: processConfig) + config.resources = resources + config.mounts = [ + .init( + type: .tmpfs, + source: "", + destination: "/run", + options: [] + ), + .init( + type: .virtiofs, + source: exportsMount, + destination: "/var/lib/container-builder-shim/exports", + options: [] + ), + ] + // Enable Rosetta only if the user didn't ask to disable it + config.rosetta = ClientDefaults.getBool(key: .buildRosetta) ?? true + + let network = try await ClientNetwork.get(id: ClientNetwork.defaultNetworkName) + guard case .running(_, let networkStatus) = network else { + throw ContainerizationError(.invalidState, message: "default network is not running") + } + config.networks = [network.id] + let subnet = try CIDRAddress(networkStatus.address) + let nameserver = IPv4Address(fromValue: subnet.lower.value + 1).description + let nameservers = [nameserver] + config.dns = ContainerConfiguration.DNSConfiguration(nameservers: nameservers) + + let kernel = try await { + await progressUpdate([ + .setDescription("Fetching kernel"), + .setItemsName("binary"), + ]) + + let kernel = try await ClientKernel.getDefaultKernel(for: .current) + return kernel + }() + + await progressUpdate([ + .setDescription("Starting BuildKit container") + ]) + + let container = try await ClientContainer.create( + configuration: config, + options: .default, + kernel: kernel + ) + + try await container.startBuildKit(progressUpdate, taskManager) + } + } +} + +// MARK: - ClientContainer Extension for BuildKit + +extension ClientContainer { + /// Starts the BuildKit process within the container + /// This method handles bootstrapping the container and starting the BuildKit process + fileprivate func startBuildKit(_ progress: @escaping ProgressUpdateHandler, _ taskManager: ProgressTaskCoordinator? = nil) async throws { + do { + let io = try ProcessIO.create( + tty: false, + interactive: false, + detach: true + ) + defer { try? io.close() } + let process = try await bootstrap() + _ = try await process.start(io.stdio) + await taskManager?.finish() + try io.closeAfterStart() + log.debug("starting BuildKit and BuildKit-shim") + } catch { + try? await stop() + try? await delete() + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to start BuildKit: \(error)") + } + } +} diff --git a/Sources/ContainerCLI/Builder/BuilderStatus.swift b/Sources/ContainerCLI/Builder/BuilderStatus.swift new file mode 100644 index 000000000..b1210a3dd --- /dev/null +++ b/Sources/ContainerCLI/Builder/BuilderStatus.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct BuilderStatus: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "status" + config._superCommandName = "builder" + config.abstract = "Print builder status" + config.usage = "\n\t builder status [command options]" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Flag(name: .long, help: ArgumentHelp("Display detailed status in json format")) + var json: Bool = false + + func run() async throws { + do { + let container = try await ClientContainer.get(id: "buildkit") + if json { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let jsonData = try encoder.encode(container) + + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw ContainerizationError(.internalError, message: "failed to encode BuildKit container as json") + } + print(jsonString) + return + } + + let image = container.configuration.image.reference + let resources = container.configuration.resources + let cpus = resources.cpus + let memory = resources.memoryInBytes / (1024 * 1024) // bytes to MB + let addr = "" + + print("ID IMAGE STATE ADDR CPUS MEMORY") + print("\(container.id) \(image) \(container.status.rawValue.uppercased()) \(addr) \(cpus) \(memory) MB") + } catch { + if error is ContainerizationError { + if (error as? ContainerizationError)?.code == .notFound { + print("builder is not running") + return + } + } + throw error + } + } + } +} diff --git a/Sources/ContainerCLI/Builder/BuilderStop.swift b/Sources/ContainerCLI/Builder/BuilderStop.swift new file mode 100644 index 000000000..e7484c9c1 --- /dev/null +++ b/Sources/ContainerCLI/Builder/BuilderStop.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct BuilderStop: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "stop" + config._superCommandName = "builder" + config.abstract = "Stop builder" + config.usage = "\n\t builder stop" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + func run() async throws { + do { + let container = try await ClientContainer.get(id: "buildkit") + try await container.stop() + } catch { + if error is ContainerizationError { + if (error as? ContainerizationError)?.code == .notFound { + print("builder is not running") + return + } + } + throw error + } + } + } +} diff --git a/Sources/ContainerCLI/Codable+JSON.swift b/Sources/ContainerCLI/Codable+JSON.swift new file mode 100644 index 000000000..60cbd04d7 --- /dev/null +++ b/Sources/ContainerCLI/Codable+JSON.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension [any Codable] { + func jsonArray() throws -> String { + "[\(try self.map { String(data: try JSONEncoder().encode($0), encoding: .utf8)! }.joined(separator: ","))]" + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Build.swift b/Sources/ContainerCLI/Compose/Codable Structs/Build.swift new file mode 100644 index 000000000..5dc9a7ffa --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Build.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Build.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents the `build` configuration for a service. +struct Build: Codable, Hashable { + /// Path to the build context + let context: String + /// Optional path to the Dockerfile within the context + let dockerfile: String? + /// Build arguments + let args: [String: String]? + + /// Custom initializer to handle `build: .` (string) or `build: { context: . }` (object) + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let contextString = try? container.decode(String.self) { + self.context = contextString + self.dockerfile = nil + self.args = nil + } else { + let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) + self.context = try keyedContainer.decode(String.self, forKey: .context) + self.dockerfile = try keyedContainer.decodeIfPresent(String.self, forKey: .dockerfile) + self.args = try keyedContainer.decodeIfPresent([String: String].self, forKey: .args) + } + } + + enum CodingKeys: String, CodingKey { + case context, dockerfile, args + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Config.swift b/Sources/ContainerCLI/Compose/Codable Structs/Config.swift new file mode 100644 index 000000000..6b982bfdb --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Config.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Config.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level config definition (primarily for Swarm). +struct Config: Codable { + /// Path to the file containing the config content + let file: String? + /// Indicates if the config is external (pre-existing) + let external: ExternalConfig? + /// Explicit name for the config + let name: String? + /// Labels for the config + let labels: [String: String]? + + enum CodingKeys: String, CodingKey { + case file, external, name, labels + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_cfg" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + file = try container.decodeIfPresent(String.self, forKey: .file) + name = try container.decodeIfPresent(String.self, forKey: .name) + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalConfig(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalConfig(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift b/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift new file mode 100644 index 000000000..d30f9ffa8 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift @@ -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. +//===----------------------------------------------------------------------===// + +// +// Deploy.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents the `deploy` configuration for a service (primarily for Swarm orchestration). +struct Deploy: Codable, Hashable { + /// Deployment mode (e.g., 'replicated', 'global') + let mode: String? + /// Number of replicated service tasks + let replicas: Int? + /// Resource constraints (limits, reservations) + let resources: DeployResources? + /// Restart policy for tasks + let restart_policy: DeployRestartPolicy? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift new file mode 100644 index 000000000..370e61a46 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// DeployResources.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Resource constraints for deployment. +struct DeployResources: Codable, Hashable { + /// Hard limits on resources + let limits: ResourceLimits? + /// Guarantees for resources + let reservations: ResourceReservations? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift new file mode 100644 index 000000000..56daa6573 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift @@ -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. +//===----------------------------------------------------------------------===// + +// +// DeployRestartPolicy.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Restart policy for deployed tasks. +struct DeployRestartPolicy: Codable, Hashable { + /// Condition to restart on (e.g., 'on-failure', 'any') + let condition: String? + /// Delay before attempting restart + let delay: String? + /// Maximum number of restart attempts + let max_attempts: Int? + /// Window to evaluate restart policy + let window: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift new file mode 100644 index 000000000..47a58acad --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift @@ -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. +//===----------------------------------------------------------------------===// + +// +// DeviceReservation.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Device reservations for GPUs or other devices. +struct DeviceReservation: Codable, Hashable { + /// Device capabilities + let capabilities: [String]? + /// Device driver + let driver: String? + /// Number of devices + let count: String? + /// Specific device IDs + let device_ids: [String]? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift b/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift new file mode 100644 index 000000000..503d98664 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// DockerCompose.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents the top-level structure of a docker-compose.yml file. +struct DockerCompose: Codable { + /// The Compose file format version (e.g., '3.8') + let version: String? + /// Optional project name + let name: String? + /// Dictionary of service definitions, keyed by service name + let services: [String: Service] + /// Optional top-level volume definitions + let volumes: [String: Volume]? + /// Optional top-level network definitions + let networks: [String: Network]? + /// Optional top-level config definitions (primarily for Swarm) + let configs: [String: Config]? + /// Optional top-level secret definitions (primarily for Swarm) + let secrets: [String: Secret]? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decodeIfPresent(String.self, forKey: .version) + name = try container.decodeIfPresent(String.self, forKey: .name) + services = try container.decode([String: Service].self, forKey: .services) + + if let volumes = try container.decodeIfPresent([String: Optional].self, forKey: .volumes) { + let safeVolumes: [String : Volume] = volumes.mapValues { value in + value ?? Volume() + } + self.volumes = safeVolumes + } else { + self.volumes = nil + } + networks = try container.decodeIfPresent([String: Network].self, forKey: .networks) + configs = try container.decodeIfPresent([String: Config].self, forKey: .configs) + secrets = try container.decodeIfPresent([String: Secret].self, forKey: .secrets) + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift new file mode 100644 index 000000000..d05ccd461 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ExternalConfig.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external config reference. +struct ExternalConfig: Codable { + /// True if the config is external + let isExternal: Bool + /// Optional name of the external config if different from key + let name: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift new file mode 100644 index 000000000..07d6c8ce9 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ExternalNetwork.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external network reference. +struct ExternalNetwork: Codable { + /// True if the network is external + let isExternal: Bool + // Optional name of the external network if different from key + let name: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift new file mode 100644 index 000000000..ce4411362 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ExternalSecret.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external secret reference. +struct ExternalSecret: Codable { + /// True if the secret is external + let isExternal: Bool + /// Optional name of the external secret if different from key + let name: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift new file mode 100644 index 000000000..04cfe4f92 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ExternalVolume.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external volume reference. +struct ExternalVolume: Codable { + /// True if the volume is external + let isExternal: Bool + /// Optional name of the external volume if different from key + let name: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift b/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift new file mode 100644 index 000000000..27f5aa912 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Healthcheck.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Healthcheck configuration for a service. +struct Healthcheck: Codable, Hashable { + /// Command to run to check health + let test: [String]? + /// Grace period for the container to start + let start_period: String? + /// How often to run the check + let interval: String? + /// Number of consecutive failures to consider unhealthy + let retries: Int? + /// Timeout for each check + let timeout: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Network.swift b/Sources/ContainerCLI/Compose/Codable Structs/Network.swift new file mode 100644 index 000000000..44752aecc --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Network.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Network.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level network definition. +struct Network: Codable { + /// Network driver (e.g., 'bridge', 'overlay') + let driver: String? + /// Driver-specific options + let driver_opts: [String: String]? + /// Allow standalone containers to attach to this network + let attachable: Bool? + /// Enable IPv6 networking + let enable_ipv6: Bool? + /// RENAMED: from `internal` to `isInternal` to avoid keyword clash + let isInternal: Bool? + /// Labels for the network + let labels: [String: String]? + /// Explicit name for the network + let name: String? + /// Indicates if the network is external (pre-existing) + let external: ExternalNetwork? + + /// Updated CodingKeys to map 'internal' from YAML to 'isInternal' Swift property + enum CodingKeys: String, CodingKey { + case driver, driver_opts, attachable, enable_ipv6, isInternal = "internal", labels, name, external + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_net" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + driver = try container.decodeIfPresent(String.self, forKey: .driver) + driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts) + attachable = try container.decodeIfPresent(Bool.self, forKey: .attachable) + enable_ipv6 = try container.decodeIfPresent(Bool.self, forKey: .enable_ipv6) + isInternal = try container.decodeIfPresent(Bool.self, forKey: .isInternal) // Use isInternal here + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + name = try container.decodeIfPresent(String.self, forKey: .name) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalNetwork(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalNetwork(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift b/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift new file mode 100644 index 000000000..4643d961b --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ResourceLimits.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// CPU and memory limits. +struct ResourceLimits: Codable, Hashable { + /// CPU limit (e.g., "0.5") + let cpus: String? + /// Memory limit (e.g., "512M") + let memory: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift b/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift new file mode 100644 index 000000000..26052e6b3 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ResourceReservations.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// **FIXED**: Renamed from `ResourceReservables` to `ResourceReservations` and made `Codable`. +/// CPU and memory reservations. +struct ResourceReservations: Codable, Hashable { + /// CPU reservation (e.g., "0.25") + let cpus: String? + /// Memory reservation (e.g., "256M") + let memory: String? + /// Device reservations for GPUs or other devices + let devices: [DeviceReservation]? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift b/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift new file mode 100644 index 000000000..ff464c671 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Secret.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level secret definition (primarily for Swarm). +struct Secret: Codable { + /// Path to the file containing the secret content + let file: String? + /// Environment variable to populate with the secret content + let environment: String? + /// Indicates if the secret is external (pre-existing) + let external: ExternalSecret? + /// Explicit name for the secret + let name: String? + /// Labels for the secret + let labels: [String: String]? + + enum CodingKeys: String, CodingKey { + case file, environment, external, name, labels + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_sec" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + file = try container.decodeIfPresent(String.self, forKey: .file) + environment = try container.decodeIfPresent(String.self, forKey: .environment) + name = try container.decodeIfPresent(String.self, forKey: .name) + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalSecret(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalSecret(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Service.swift b/Sources/ContainerCLI/Compose/Codable Structs/Service.swift new file mode 100644 index 000000000..1c5aeb528 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Service.swift @@ -0,0 +1,203 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Service.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + +import Foundation + + +/// Represents a single service definition within the `services` section. +struct Service: Codable, Hashable { + /// Docker image name + let image: String? + + /// Build configuration if the service is built from a Dockerfile + let build: Build? + + /// Deployment configuration (primarily for Swarm) + let deploy: Deploy? + + /// Restart policy (e.g., 'unless-stopped', 'always') + let restart: String? + + /// Healthcheck configuration + let healthcheck: Healthcheck? + + /// List of volume mounts (e.g., "hostPath:containerPath", "namedVolume:/path") + let volumes: [String]? + + /// Environment variables to set in the container + let environment: [String: String]? + + /// List of .env files to load environment variables from + let env_file: [String]? + + /// Port mappings (e.g., "hostPort:containerPort") + let ports: [String]? + + /// Command to execute in the container, overriding the image's default + let command: [String]? + + /// Services this service depends on (for startup order) + let depends_on: [String]? + + /// User or UID to run the container as + let user: String? + + /// Explicit name for the container instance + let container_name: String? + + /// List of networks the service will connect to + let networks: [String]? + + /// Container hostname + let hostname: String? + + /// Entrypoint to execute in the container, overriding the image's default + let entrypoint: [String]? + + /// Run container in privileged mode + let privileged: Bool? + + /// Mount container's root filesystem as read-only + let read_only: Bool? + + /// Working directory inside the container + let working_dir: String? + + /// Platform architecture for the service + let platform: String? + + /// Service-specific config usage (primarily for Swarm) + let configs: [ServiceConfig]? + + /// Service-specific secret usage (primarily for Swarm) + let secrets: [ServiceSecret]? + + /// Keep STDIN open (-i flag for `container run`) + let stdin_open: Bool? + + /// Allocate a pseudo-TTY (-t flag for `container run`) + let tty: Bool? + + /// Other services that depend on this service + var dependedBy: [String] = [] + + // Defines custom coding keys to map YAML keys to Swift properties + enum CodingKeys: String, CodingKey { + case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, + container_name, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform + } + + /// Custom initializer to handle decoding and basic validation. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + image = try container.decodeIfPresent(String.self, forKey: .image) + build = try container.decodeIfPresent(Build.self, forKey: .build) + deploy = try container.decodeIfPresent(Deploy.self, forKey: .deploy) + + // Ensure that a service has either an image or a build context. + guard image != nil || build != nil else { + throw DecodingError.dataCorruptedError(forKey: .image, in: container, debugDescription: "Service must have either 'image' or 'build' specified.") + } + + restart = try container.decodeIfPresent(String.self, forKey: .restart) + healthcheck = try container.decodeIfPresent(Healthcheck.self, forKey: .healthcheck) + volumes = try container.decodeIfPresent([String].self, forKey: .volumes) + environment = try container.decodeIfPresent([String: String].self, forKey: .environment) + env_file = try container.decodeIfPresent([String].self, forKey: .env_file) + ports = try container.decodeIfPresent([String].self, forKey: .ports) + + // Decode 'command' which can be either a single string or an array of strings. + if let cmdArray = try? container.decodeIfPresent([String].self, forKey: .command) { + command = cmdArray + } else if let cmdString = try? container.decodeIfPresent(String.self, forKey: .command) { + command = [cmdString] + } else { + command = nil + } + + depends_on = try container.decodeIfPresent([String].self, forKey: .depends_on) + user = try container.decodeIfPresent(String.self, forKey: .user) + + container_name = try container.decodeIfPresent(String.self, forKey: .container_name) + networks = try container.decodeIfPresent([String].self, forKey: .networks) + hostname = try container.decodeIfPresent(String.self, forKey: .hostname) + + // Decode 'entrypoint' which can be either a single string or an array of strings. + if let entrypointArray = try? container.decodeIfPresent([String].self, forKey: .entrypoint) { + entrypoint = entrypointArray + } else if let entrypointString = try? container.decodeIfPresent(String.self, forKey: .entrypoint) { + entrypoint = [entrypointString] + } else { + entrypoint = nil + } + + privileged = try container.decodeIfPresent(Bool.self, forKey: .privileged) + read_only = try container.decodeIfPresent(Bool.self, forKey: .read_only) + working_dir = try container.decodeIfPresent(String.self, forKey: .working_dir) + configs = try container.decodeIfPresent([ServiceConfig].self, forKey: .configs) + secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets) + stdin_open = try container.decodeIfPresent(Bool.self, forKey: .stdin_open) + tty = try container.decodeIfPresent(Bool.self, forKey: .tty) + platform = try container.decodeIfPresent(String.self, forKey: .platform) + } + + /// Returns the services in topological order based on `depends_on` relationships. + static func topoSortConfiguredServices( + _ services: [(serviceName: String, service: Service)] + ) throws -> [(serviceName: String, service: Service)] { + + var visited = Set() + var visiting = Set() + var sorted: [(String, Service)] = [] + + func visit(_ name: String, from service: String? = nil) throws { + guard var serviceTuple = services.first(where: { $0.serviceName == name }) else { return } + if let service { + serviceTuple.service.dependedBy.append(service) + } + + if visiting.contains(name) { + throw NSError(domain: "ComposeError", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" + ]) + } + guard !visited.contains(name) else { return } + + visiting.insert(name) + for depName in serviceTuple.service.depends_on ?? [] { + try visit(depName, from: name) + } + visiting.remove(name) + visited.insert(name) + sorted.append(serviceTuple) + } + + for (serviceName, _) in services { + if !visited.contains(serviceName) { + try visit(serviceName) + } + } + + return sorted + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift b/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift new file mode 100644 index 000000000..712d42b7b --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ServiceConfig.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a service's usage of a config. +struct ServiceConfig: Codable, Hashable { + /// Name of the config being used + let source: String + + /// Path in the container where the config will be mounted + let target: String? + + /// User ID for the mounted config file + let uid: String? + + /// Group ID for the mounted config file + let gid: String? + + /// Permissions mode for the mounted config file + let mode: Int? + + /// Custom initializer to handle `config_name` (string) or `{ source: config_name, target: /path }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let sourceName = try? container.decode(String.self) { + self.source = sourceName + self.target = nil + self.uid = nil + self.gid = nil + self.mode = nil + } else { + let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) + self.source = try keyedContainer.decode(String.self, forKey: .source) + self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) + self.uid = try keyedContainer.decodeIfPresent(String.self, forKey: .uid) + self.gid = try keyedContainer.decodeIfPresent(String.self, forKey: .gid) + self.mode = try keyedContainer.decodeIfPresent(Int.self, forKey: .mode) + } + } + + enum CodingKeys: String, CodingKey { + case source, target, uid, gid, mode + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift b/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift new file mode 100644 index 000000000..1849c495c --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ServiceSecret.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a service's usage of a secret. +struct ServiceSecret: Codable, Hashable { + /// Name of the secret being used + let source: String + + /// Path in the container where the secret will be mounted + let target: String? + + /// User ID for the mounted secret file + let uid: String? + + /// Group ID for the mounted secret file + let gid: String? + + /// Permissions mode for the mounted secret file + let mode: Int? + + /// Custom initializer to handle `secret_name` (string) or `{ source: secret_name, target: /path }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let sourceName = try? container.decode(String.self) { + self.source = sourceName + self.target = nil + self.uid = nil + self.gid = nil + self.mode = nil + } else { + let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) + self.source = try keyedContainer.decode(String.self, forKey: .source) + self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) + self.uid = try keyedContainer.decodeIfPresent(String.self, forKey: .uid) + self.gid = try keyedContainer.decodeIfPresent(String.self, forKey: .gid) + self.mode = try keyedContainer.decodeIfPresent(Int.self, forKey: .mode) + } + } + + enum CodingKeys: String, CodingKey { + case source, target, uid, gid, mode + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift b/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift new file mode 100644 index 000000000..b43a1cca5 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Volume.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level volume definition. +struct Volume: Codable { + /// Volume driver (e.g., 'local') + let driver: String? + + /// Driver-specific options + let driver_opts: [String: String]? + + /// Explicit name for the volume + let name: String? + + /// Labels for the volume + let labels: [String: String]? + + /// Indicates if the volume is external (pre-existing) + let external: ExternalVolume? + + enum CodingKeys: String, CodingKey { + case driver, driver_opts, name, labels, external + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_vol" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + driver = try container.decodeIfPresent(String.self, forKey: .driver) + driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts) + name = try container.decodeIfPresent(String.self, forKey: .name) + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalVolume(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalVolume(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } + + init(driver: String? = nil, driver_opts: [String : String]? = nil, name: String? = nil, labels: [String : String]? = nil, external: ExternalVolume? = nil) { + self.driver = driver + self.driver_opts = driver_opts + self.name = name + self.labels = labels + self.external = external + } +} diff --git a/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift b/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift new file mode 100644 index 000000000..8993f8ddb --- /dev/null +++ b/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift @@ -0,0 +1,105 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ComposeDown.swift +// Container-Compose +// +// Created by Morris Richman on 6/19/25. +// + +import ArgumentParser +import ContainerClient +import Foundation +import Yams + +extension Application { + public struct ComposeDown: AsyncParsableCommand { + public init() {} + + public static let configuration: CommandConfiguration = .init( + commandName: "down", + abstract: "Stop containers with compose" + ) + + @Argument(help: "Specify the services to stop") + var services: [String] = [] + + @OptionGroup + var process: Flags.Process + + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + + private var fileManager: FileManager { FileManager.default } + private var projectName: String? + + public mutating func run() async throws { + // Read docker-compose.yml content + guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { + throw YamlError.dockerfileNotFound(dockerComposePath) + } + + // Decode the YAML file into the DockerCompose struct + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + + // Determine project name for container naming + if let name = dockerCompose.name { + projectName = name + print("Info: Docker Compose project name parsed as: \(name)") + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + } else { + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") + } + + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) + services = try Service.topoSortConfiguredServices(services) + + // Filter for specified services + if !self.services.isEmpty { + services = services.filter({ serviceName, service in + self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) + }) + } + + try await stopOldStuff(services.map({ $0.serviceName }), remove: false) + } + + private func stopOldStuff(_ services: [String], remove: Bool) async throws { + guard let projectName else { return } + let containers = services.map { "\(projectName)-\($0)" } + + for container in containers { + print("Stopping container: \(container)") + guard let container = try? await ClientContainer.get(id: container) else { continue } + + do { + try await container.stop() + } catch { + } + if remove { + do { + try await container.delete() + } catch { + } + } + } + } + } +} diff --git a/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift b/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift new file mode 100644 index 000000000..6b1053670 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift @@ -0,0 +1,749 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ComposeUp.swift +// Container-Compose +// +// Created by Morris Richman on 6/19/25. +// + +import ArgumentParser +import ContainerClient +import Foundation +@preconcurrency import Rainbow +import Yams +import ContainerizationExtras + +extension Application { + public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { + public init() {} + + public static let configuration: CommandConfiguration = .init( + commandName: "up", + abstract: "Start containers with compose" + ) + + @Argument(help: "Specify the services to start") + var services: [String] = [] + + @Flag( + name: [.customShort("d"), .customLong("detach")], + help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") + var detatch: Bool = false + + @Flag(name: [.customShort("b"), .customLong("build")]) + var rebuild: Bool = false + + @Flag(name: .long, help: "Do not use cache") + var noCache: Bool = false + + @OptionGroup + var process: Flags.Process + + @OptionGroup + var global: Flags.Global + + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file + // + private var fileManager: FileManager { FileManager.default } + private var projectName: String? + private var environmentVariables: [String: String] = [:] + private var containerIps: [String: String] = [:] + private var containerConsoleColors: [String: NamedColor] = [:] + + private static let availableContainerConsoleColors: Set = [ + .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, + ] + + public mutating func run() async throws { + // Read docker-compose.yml content + guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { + throw YamlError.dockerfileNotFound(dockerComposePath) + } + + // Decode the YAML file into the DockerCompose struct + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + + // Load environment variables from .env file + environmentVariables = loadEnvFile(path: envFilePath) + + // Handle 'version' field + if let version = dockerCompose.version { + print("Info: Docker Compose file version parsed as: \(version)") + print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") + } + + // Determine project name for container naming + if let name = dockerCompose.name { + projectName = name + print("Info: Docker Compose project name parsed as: \(name)") + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + } else { + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") + } + + // Get Services to use + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) + services = try Service.topoSortConfiguredServices(services) + + // Filter for specified services + if !self.services.isEmpty { + services = services.filter({ serviceName, service in + self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) + }) + } + + // Stop Services + try await stopOldStuff(services.map({ $0.serviceName }), remove: true) + + // Process top-level networks + // This creates named networks defined in the docker-compose.yml + if let networks = dockerCompose.networks { + print("\n--- Processing Networks ---") + for (networkName, networkConfig) in networks { + try await setupNetwork(name: networkName, config: networkConfig) + } + print("--- Networks Processed ---\n") + } + + // Process top-level volumes + // This creates named volumes defined in the docker-compose.yml + if let volumes = dockerCompose.volumes { + print("\n--- Processing Volumes ---") + for (volumeName, volumeConfig) in volumes { + await createVolumeHardLink(name: volumeName, config: volumeConfig) + } + print("--- Volumes Processed ---\n") + } + + // Process each service defined in the docker-compose.yml + print("\n--- Processing Services ---") + + print(services.map(\.serviceName)) + for (serviceName, service) in services { + try await configService(service, serviceName: serviceName, from: dockerCompose) + } + + if !detatch { + await waitForever() + } + } + + func waitForever() async -> Never { + for await _ in AsyncStream(unfolding: {}) { + // This will never run + } + fatalError("unreachable") + } + + private func getIPForRunningService(_ serviceName: String) async throws -> String? { + guard let projectName else { return nil } + + let containerName = "\(projectName)-\(serviceName)" + + let container = try await ClientContainer.get(id: containerName) + let ip = container.networks.compactMap { try? CIDRAddress($0.address).address.description }.first + + return ip + } + + /// Repeatedly checks `container list -a` until the given container is listed as `running`. + /// - Parameters: + /// - containerName: The exact name of the container (e.g. "Assignment-Manager-API-db"). + /// - timeout: Max seconds to wait before failing. + /// - interval: How often to poll (in seconds). + /// - Returns: `true` if the container reached "running" state within the timeout. + private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { + guard let projectName else { return } + let containerName = "\(projectName)-\(serviceName)" + + let deadline = Date().addingTimeInterval(timeout) + + while Date() < deadline { + let container = try? await ClientContainer.get(id: containerName) + if container?.status == .running { + return + } + + try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + + throw NSError( + domain: "ContainerWait", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." + ]) + } + + private func stopOldStuff(_ services: [String], remove: Bool) async throws { + guard let projectName else { return } + let containers = services.map { "\(projectName)-\($0)" } + + for container in containers { + print("Stopping container: \(container)") + guard let container = try? await ClientContainer.get(id: container) else { continue } + + do { + try await container.stop() + } catch { + } + if remove { + do { + try await container.delete() + } catch { + } + } + } + } + + // MARK: Compose Top Level Functions + + private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { + let ip = try await getIPForRunningService(serviceName) + self.containerIps[serviceName] = ip + for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { + self.environmentVariables[key] = ip ?? value + } + } + + private func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { + guard let projectName else { return } + let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name + + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") + let volumePath = volumeUrl.path(percentEncoded: false) + + print( + "Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + ) + try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + } + + private func setupNetwork(name networkName: String, config networkConfig: Network) async throws { + let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name + + if let externalNetwork = networkConfig.external, externalNetwork.isExternal { + print("Info: Network '\(networkName)' is declared as external.") + print("This tool assumes external network '\(externalNetwork.name ?? actualNetworkName)' already exists and will not attempt to create it.") + } else { + var networkCreateArgs: [String] = ["network", "create"] + + #warning("Docker Compose Network Options Not Supported") + // Add driver and driver options + if let driver = networkConfig.driver, !driver.isEmpty { +// networkCreateArgs.append("--driver") +// networkCreateArgs.append(driver) + print("Network Driver Detected, But Not Supported") + } + if let driverOpts = networkConfig.driver_opts, !driverOpts.isEmpty { +// for (optKey, optValue) in driverOpts { +// networkCreateArgs.append("--opt") +// networkCreateArgs.append("\(optKey)=\(optValue)") +// } + print("Network Options Detected, But Not Supported") + } + // Add various network flags + if networkConfig.attachable == true { +// networkCreateArgs.append("--attachable") + print("Network Attachable Flag Detected, But Not Supported") + } + if networkConfig.enable_ipv6 == true { +// networkCreateArgs.append("--ipv6") + print("Network IPv6 Flag Detected, But Not Supported") + } + if networkConfig.isInternal == true { +// networkCreateArgs.append("--internal") + print("Network Internal Flag Detected, But Not Supported") + } // CORRECTED: Use isInternal + + // Add labels + if let labels = networkConfig.labels, !labels.isEmpty { + print("Network Labels Detected, But Not Supported") +// for (labelKey, labelValue) in labels { +// networkCreateArgs.append("--label") +// networkCreateArgs.append("\(labelKey)=\(labelValue)") +// } + } + + print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") + print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") + guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else { + print("Network '\(networkName)' already exists") + return + } + var networkCreate = NetworkCreate() + networkCreate.global = global + networkCreate.name = actualNetworkName + + try await networkCreate.run() + print("Network '\(networkName)' created") + } + } + + // MARK: Compose Service Level Functions + private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { + guard let projectName else { throw ComposeError.invalidProjectName } + + var imageToRun: String + + // Handle 'build' configuration + if let buildConfig = service.build { + imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) + } else if let img = service.image { + // Use specified image if no build config + // Pull image if necessary + try await pullImage(img, platform: service.container_name) + imageToRun = img + } else { + // Should not happen due to Service init validation, but as a fallback + throw ComposeError.imageNotFound(serviceName) + } + + // Handle 'deploy' configuration (note that this tool doesn't fully support it) + if service.deploy != nil { + print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") + print( + "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." + ) + print("The service will be run as a single container based on other configurations.") + } + + var runCommandArgs: [String] = [] + + // Add detach flag if specified on the CLI + if detatch { + runCommandArgs.append("-d") + } + + // Determine container name + let containerName: String + if let explicitContainerName = service.container_name { + containerName = explicitContainerName + print("Info: Using explicit container_name: \(containerName)") + } else { + // Default container name based on project and service name + containerName = "\(projectName)-\(serviceName)" + } + runCommandArgs.append("--name") + runCommandArgs.append(containerName) + + // REMOVED: Restart policy is not supported by `container run` + // if let restart = service.restart { + // runCommandArgs.append("--restart") + // runCommandArgs.append(restart) + // } + + // Add user + if let user = service.user { + runCommandArgs.append("--user") + runCommandArgs.append(user) + } + + // Add volume mounts + if let volumes = service.volumes { + for volume in volumes { + let args = try await configVolume(volume) + runCommandArgs.append(contentsOf: args) + } + } + + // Combine environment variables from .env files and service environment + var combinedEnv: [String: String] = environmentVariables + + if let envFiles = service.env_file { + for envFile in envFiles { + let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") + combinedEnv.merge(additionalEnvVars) { (current, _) in current } + } + } + + if let serviceEnv = service.environment { + combinedEnv.merge(serviceEnv) { (old, new) in + guard !new.contains("${") else { + return old + } + return new + } // Service env overrides .env files + } + + // Fill in variables + combinedEnv = combinedEnv.mapValues({ value in + guard value.contains("${") else { return value } + + let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) + return combinedEnv[variableName] ?? value + }) + + // Fill in IPs + combinedEnv = combinedEnv.mapValues({ value in + containerIps[value] ?? value + }) + + // MARK: Spinning Spot + // Add environment variables to run command + for (key, value) in combinedEnv { + runCommandArgs.append("-e") + runCommandArgs.append("\(key)=\(value)") + } + + // REMOVED: Port mappings (-p) are not supported by `container run` + // if let ports = service.ports { + // for port in ports { + // let resolvedPort = resolveVariable(port, with: envVarsFromFile) + // runCommandArgs.append("-p") + // runCommandArgs.append(resolvedPort) + // } + // } + + // Connect to specified networks + if let serviceNetworks = service.networks { + for network in serviceNetworks { + let resolvedNetwork = resolveVariable(network, with: environmentVariables) + // Use the explicit network name from top-level definition if available, otherwise resolved name + let networkToConnect = dockerCompose.networks?[network]?.name ?? resolvedNetwork + runCommandArgs.append("--network") + runCommandArgs.append(networkToConnect) + } + print( + "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml." + ) + print( + "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." + ) + } else { + print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.") + } + + // Add hostname + if let hostname = service.hostname { + let resolvedHostname = resolveVariable(hostname, with: environmentVariables) + runCommandArgs.append("--hostname") + runCommandArgs.append(resolvedHostname) + } + + // Add working directory + if let workingDir = service.working_dir { + let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) + runCommandArgs.append("--workdir") + runCommandArgs.append(resolvedWorkingDir) + } + + // Add privileged flag + if service.privileged == true { + runCommandArgs.append("--privileged") + } + + // Add read-only flag + if service.read_only == true { + runCommandArgs.append("--read-only") + } + + // Handle service-level configs (note: still only parsing/logging, not attaching) + if let serviceConfigs = service.configs { + print( + "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) + print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.") + for serviceConfig in serviceConfigs { + print( + " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" + ) + } + } + // + // Handle service-level secrets (note: still only parsing/logging, not attaching) + if let serviceSecrets = service.secrets { + print( + "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) + print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.") + for serviceSecret in serviceSecrets { + print( + " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" + ) + } + } + + // Add interactive and TTY flags + if service.stdin_open == true { + runCommandArgs.append("-i") // --interactive + } + if service.tty == true { + runCommandArgs.append("-t") // --tty + } + + runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint + + // Add entrypoint or command + if let entrypointParts = service.entrypoint { + runCommandArgs.append("--entrypoint") + runCommandArgs.append(contentsOf: entrypointParts) + } else if let commandParts = service.command { + runCommandArgs.append(contentsOf: commandParts) + } + + var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! + + if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { + while containerConsoleColors.values.contains(serviceColor) { + serviceColor = Self.availableContainerConsoleColors.randomElement()! + } + } + + self.containerConsoleColors[serviceName] = serviceColor + + Task { [self, serviceColor] in + @Sendable + func handleOutput(_ output: String) { + print("\(serviceName): \(output)".applyingColor(serviceColor)) + } + + print("\nStarting service: \(serviceName)") + print("Starting \(serviceName)") + print("----------------------------------------\n") + let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) + } + + do { + try await waitUntilServiceIsRunning(serviceName) + try await updateEnvironmentWithServiceIP(serviceName) + } catch { + print(error) + } + } + + private func pullImage(_ imageName: String, platform: String?) async throws { + let imageList = try await ClientImage.list() + guard !imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageName }) else { + return + } + + print("Pulling Image \(imageName)...") + var registry = Flags.Registry() + registry.scheme = "auto" // Set or SwiftArgumentParser gets mad + + var progress = Flags.Progress() + progress.disableProgressUpdates = false + + var imagePull = ImagePull() + imagePull.progressFlags = progress + imagePull.registry = registry + imagePull.global = global + imagePull.reference = imageName + imagePull.platform = platform + try await imagePull.run() + } + + /// Builds Docker Service + /// + /// - Parameters: + /// - buildConfig: The configuration for the build + /// - service: The service you would like to build + /// - serviceName: The fallback name for the image + /// + /// - Returns: Image Name (`String`) + private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { + // Determine image tag for built image + let imageToRun = service.image ?? "\(serviceName):latest" + let imageList = try await ClientImage.list() + if !rebuild, imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageToRun }) { + return imageToRun + } + + var buildCommand = BuildCommand() + + // Set Build Commands + buildCommand.buildArg = (buildConfig.args ?? [:]).map({ "\($0.key)=\(resolveVariable($0.value, with: environmentVariables))" }) + + // Locate Dockerfile and context + buildCommand.contextDir = "\(self.cwd)/\(buildConfig.context)" + buildCommand.file = "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")" + + // Handle Caching + buildCommand.noCache = noCache + buildCommand.cacheIn = [] + buildCommand.cacheOut = [] + + // Handle OS/Arch + let split = service.platform?.split(separator: "/") + buildCommand.os = [String(split?.first ?? "linux")] + buildCommand.arch = [String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64")] + + // Set Image Name + buildCommand.targetImageName = imageToRun + + // Set CPU & Memory + buildCommand.cpus = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 + buildCommand.memory = service.deploy?.resources?.limits?.memory ?? "2048MB" + + // Set Miscelaneous + buildCommand.label = [] // No Label Equivalent? + buildCommand.progress = "auto" + buildCommand.vsockPort = 8088 + buildCommand.quiet = false + buildCommand.target = "" + buildCommand.output = ["type=oci"] + print("\n----------------------------------------") + print("Building image for service: \(serviceName) (Tag: \(imageToRun))") + try buildCommand.validate() + try await buildCommand.run() + print("Image build for \(serviceName) completed.") + print("----------------------------------------") + + return imageToRun + } + + private func configVolume(_ volume: String) async throws -> [String] { + let resolvedVolume = resolveVariable(volume, with: environmentVariables) + + var runCommandArgs: [String] = [] + + // Parse the volume string: destination[:mode] + let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) + + guard components.count >= 2 else { + print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") + return [] + } + + let source = components[0] + let destination = components[1] + + // Check if the source looks like a host path (contains '/' or starts with '.') + // This heuristic helps distinguish bind mounts from named volume references. + if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { + // This is likely a bind mount (local path to container path) + var isDirectory: ObjCBool = false + // Ensure the path is absolute or relative to the current directory for FileManager + let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) + + if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { + if isDirectory.boolValue { + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + } else { + // Host path exists but is a file + print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") + } + } else { + // Host path does not exist, assume it's meant to be a directory and try to create it. + do { + try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) + print("Info: Created missing host directory for volume: \(fullHostPath)") + runCommandArgs.append("-v") + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + } catch { + print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") + } + } + } else { + guard let projectName else { return [] } + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") + let volumePath = volumeUrl.path(percentEncoded: false) + + let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() + let destinationPath = destinationUrl.path(percentEncoded: false) + + print( + "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + ) + try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument + } + + return runCommandArgs + } + } +} + +// MARK: CommandLine Functions +extension Application.ComposeUp { + + /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. + /// + /// - Parameters: + /// - command: The name of the command to run (e.g., `"container"`). + /// - args: Command-line arguments to pass to the command. + /// - onStdout: Closure called with streamed stdout data. + /// - onStderr: Closure called with streamed stderr data. + /// - Returns: The process's exit code. + /// - Throws: If the process fails to launch. + @discardableResult + func streamCommand( + _ command: String, + args: [String] = [], + onStdout: @escaping (@Sendable (String) -> Void), + onStderr: @escaping (@Sendable (String) -> Void) + ) async throws -> Int32 { + try await withCheckedThrowingContinuation { continuation in + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [command] + args + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + process.environment = ProcessInfo.processInfo.environment.merging([ + "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + ]) { _, new in new } + + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + + stdoutHandle.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let string = String(data: data, encoding: .utf8) { + onStdout(string) + } + } + + stderrHandle.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let string = String(data: data, encoding: .utf8) { + onStderr(string) + } + } + + process.terminationHandler = { proc in + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil + continuation.resume(returning: proc.terminationStatus) + } + + do { + try process.run() + } catch { + continuation.resume(throwing: error) + } + } + } +} diff --git a/Sources/ContainerCLI/Compose/ComposeCommand.swift b/Sources/ContainerCLI/Compose/ComposeCommand.swift new file mode 100644 index 000000000..03e940332 --- /dev/null +++ b/Sources/ContainerCLI/Compose/ComposeCommand.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// File.swift +// Container-Compose +// +// Created by Morris Richman on 6/18/25. +// + +import ArgumentParser +import Foundation +import Rainbow +import Yams + +extension Application { + struct ComposeCommand: AsyncParsableCommand { + static let configuration: CommandConfiguration = .init( + commandName: "compose", + abstract: "Manage containers with Docker Compose files", + subcommands: [ + ComposeUp.self, + ComposeDown.self, + ]) + } +} + +/// A structure representing the result of a command-line process execution. +struct CommandResult { + /// The standard output captured from the process. + let stdout: String + + /// The standard error output captured from the process. + let stderr: String + + /// The exit code returned by the process upon termination. + let exitCode: Int32 +} + +extension NamedColor: Codable { + +} diff --git a/Sources/ContainerCLI/Compose/Errors.swift b/Sources/ContainerCLI/Compose/Errors.swift new file mode 100644 index 000000000..c5b375aa2 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Errors.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Errors.swift +// Container-Compose +// +// Created by Morris Richman on 6/18/25. +// + +import Foundation + +extension Application { + internal enum YamlError: Error, LocalizedError { + case dockerfileNotFound(String) + + var errorDescription: String? { + switch self { + case .dockerfileNotFound(let path): + return "docker-compose.yml not found at \(path)" + } + } + } + + internal enum ComposeError: Error, LocalizedError { + case imageNotFound(String) + case invalidProjectName + + var errorDescription: String? { + switch self { + case .imageNotFound(let name): + return "Service \(name) must define either 'image' or 'build'." + case .invalidProjectName: + return "Could not find project name." + } + } + } + + internal enum TerminalError: Error, LocalizedError { + case commandFailed(String) + + var errorDescription: String? { + "Command failed: \(self)" + } + } + + /// An enum representing streaming output from either `stdout` or `stderr`. + internal enum CommandOutput { + case stdout(String) + case stderr(String) + case exitCode(Int32) + } +} diff --git a/Sources/ContainerCLI/Compose/Helper Functions.swift b/Sources/ContainerCLI/Compose/Helper Functions.swift new file mode 100644 index 000000000..e1068ad94 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Helper Functions.swift @@ -0,0 +1,96 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Helper Functions.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + +import Foundation +import Yams + +extension Application { + /// Loads environment variables from a .env file. + /// - Parameter path: The full path to the .env file. + /// - Returns: A dictionary of key-value pairs representing environment variables. + internal static func loadEnvFile(path: String) -> [String: String] { + var envVars: [String: String] = [:] + let fileURL = URL(fileURLWithPath: path) + do { + let content = try String(contentsOf: fileURL, encoding: .utf8) + let lines = content.split(separator: "\n") + for line in lines { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + // Ignore empty lines and comments + if !trimmedLine.isEmpty && !trimmedLine.starts(with: "#") { + // Parse key=value pairs + if let eqIndex = trimmedLine.firstIndex(of: "=") { + let key = String(trimmedLine[.. String { + var resolvedValue = value + // Regex to find ${VAR}, ${VAR:-default}, ${VAR:?error} + let regex = try! NSRegularExpression(pattern: "\\$\\{([A-Z0-9_]+)(:?-(.*?))?(:\\?(.*?))?\\}", options: []) + + // Combine process environment with loaded .env file variables, prioritizing process environment + let combinedEnv = ProcessInfo.processInfo.environment.merging(envVars) { (current, _) in current } + + // Loop to resolve all occurrences of variables in the string + while let match = regex.firstMatch(in: resolvedValue, options: [], range: NSRange(resolvedValue.startIndex.. 0 && all { + throw ContainerizationError( + .invalidArgument, + message: "explicitly supplied container ID(s) conflict with the --all flag" + ) + } + } + + mutating func run() async throws { + let set = Set(containerIDs) + var containers = [ClientContainer]() + + if all { + containers = try await ClientContainer.list() + } else { + let ctrs = try await ClientContainer.list() + containers = ctrs.filter { c in + set.contains(c.id) + } + // If one of the containers requested isn't present, let's throw. We don't need to do + // this for --all as --all should be perfectly usable with no containers to remove; otherwise, + // it'd be quite clunky. + if containers.count != set.count { + let missing = set.filter { id in + !containers.contains { c in + c.id == id + } + } + throw ContainerizationError( + .notFound, + message: "failed to delete one or more containers: \(missing)" + ) + } + } + + var failed = [String]() + let force = self.force + let all = self.all + try await withThrowingTaskGroup(of: ClientContainer?.self) { group in + for container in containers { + group.addTask { + do { + // First we need to find if the container supports auto-remove + // and if so we need to skip deletion. + if container.status == .running { + if !force { + // We don't want to error if the user just wants all containers deleted. + // It's implied we'll skip containers we can't actually delete. + if all { + return nil + } + throw ContainerizationError(.invalidState, message: "container is running") + } + let stopOpts = ContainerStopOptions( + timeoutInSeconds: 5, + signal: SIGKILL + ) + try await container.stop(opts: stopOpts) + } + try await container.delete() + print(container.id) + return nil + } catch { + log.error("failed to delete container \(container.id): \(error)") + return container + } + } + } + + for try await ctr in group { + guard let ctr else { + continue + } + failed.append(ctr.id) + } + } + + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "delete failed for one or more containers: \(failed)") + } + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerExec.swift b/Sources/ContainerCLI/Container/ContainerExec.swift new file mode 100644 index 000000000..de3969585 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerExec.swift @@ -0,0 +1,96 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import Foundation + +extension Application { + struct ContainerExec: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "exec", + abstract: "Run a new command in a running container") + + @OptionGroup + var processFlags: Flags.Process + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Running containers ID") + var containerID: String + + @Argument(parsing: .captureForPassthrough, help: "New process arguments") + var arguments: [String] + + func run() async throws { + var exitCode: Int32 = 127 + let container = try await ClientContainer.get(id: containerID) + try ensureRunning(container: container) + + let stdin = self.processFlags.interactive + let tty = self.processFlags.tty + + var config = container.configuration.initProcess + config.executable = arguments.first! + config.arguments = [String](self.arguments.dropFirst()) + config.terminal = tty + config.environment.append( + contentsOf: try Parser.allEnv( + imageEnvs: [], + envFiles: self.processFlags.envFile, + envs: self.processFlags.env + )) + + if let cwd = self.processFlags.cwd { + config.workingDirectory = cwd + } + + let defaultUser = config.user + let (user, additionalGroups) = Parser.user( + user: processFlags.user, uid: processFlags.uid, + gid: processFlags.gid, defaultUser: defaultUser) + config.user = user + config.supplementalGroups.append(contentsOf: additionalGroups) + + do { + let io = try ProcessIO.create(tty: tty, interactive: stdin, detach: false) + + if !self.processFlags.tty { + var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) + handler.start { + print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") + Darwin.exit(1) + } + } + + let process = try await container.createProcess( + id: UUID().uuidString.lowercased(), + configuration: config) + + exitCode = try await Application.handleProcess(io: io, process: process) + } catch { + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to exec process \(error)") + } + throw ArgumentParser.ExitCode(exitCode) + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerInspect.swift b/Sources/ContainerCLI/Container/ContainerInspect.swift new file mode 100644 index 000000000..43bda51a1 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerInspect.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import SwiftProtobuf + +extension Application { + struct ContainerInspect: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display information about one or more containers") + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Containers to inspect") + var containers: [String] + + func run() async throws { + let objects: [any Codable] = try await ClientContainer.list().filter { + containers.contains($0.id) + }.map { + PrintableContainer($0) + } + print(try objects.jsonArray()) + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerKill.swift b/Sources/ContainerCLI/Container/ContainerKill.swift new file mode 100644 index 000000000..9b9ef4ed4 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerKill.swift @@ -0,0 +1,79 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import Darwin + +extension Application { + struct ContainerKill: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "kill", + abstract: "Kill one or more running containers") + + @Option(name: .shortAndLong, help: "Signal to send the container(s)") + var signal: String = "KILL" + + @Flag(name: .shortAndLong, help: "Kill all running containers") + var all = false + + @Argument(help: "Container IDs") + var containerIDs: [String] = [] + + @OptionGroup + var global: Flags.Global + + func validate() throws { + if containerIDs.count == 0 && !all { + throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") + } + if containerIDs.count > 0 && all { + throw ContainerizationError(.invalidArgument, message: "explicitly supplied container IDs conflicts with the --all flag") + } + } + + mutating func run() async throws { + let set = Set(containerIDs) + + var containers = try await ClientContainer.list().filter { c in + c.status == .running + } + if !self.all { + containers = containers.filter { c in + set.contains(c.id) + } + } + + let signalNumber = try Signals.parseSignal(signal) + + var failed: [String] = [] + for container in containers { + do { + try await container.kill(signalNumber) + print(container.id) + } catch { + log.error("failed to kill container \(container.id): \(error)") + failed.append(container.id) + } + } + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "kill failed for one or more containers") + } + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerList.swift b/Sources/ContainerCLI/Container/ContainerList.swift new file mode 100644 index 000000000..43e5a4cec --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerList.swift @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkService +import ContainerizationExtras +import Foundation +import SwiftProtobuf + +extension Application { + struct ContainerList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List containers", + aliases: ["ls"]) + + @Flag(name: .shortAndLong, help: "Show stopped containers as well") + var all = false + + @Flag(name: .shortAndLong, help: "Only output the container ID") + 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 containers = try await ClientContainer.list() + try printContainers(containers: containers, format: format) + } + + private func createHeader() -> [[String]] { + [["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR"]] + } + + private func printContainers(containers: [ClientContainer], format: ListFormat) throws { + if format == .json { + let printables = containers.map { + PrintableContainer($0) + } + let data = try JSONEncoder().encode(printables) + print(String(data: data, encoding: .utf8)!) + + return + } + + if self.quiet { + containers.forEach { + if !self.all && $0.status != .running { + return + } + print($0.id) + } + return + } + + var rows = createHeader() + for container in containers { + if !self.all && container.status != .running { + continue + } + rows.append(container.asRow) + } + + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + } +} + +extension ClientContainer { + var asRow: [String] { + [ + self.id, + self.configuration.image.reference, + self.configuration.platform.os, + self.configuration.platform.architecture, + self.status.rawValue, + self.networks.compactMap { try? CIDRAddress($0.address).address.description }.joined(separator: ","), + ] + } +} + +struct PrintableContainer: Codable { + let status: RuntimeStatus + let configuration: ContainerConfiguration + let networks: [Attachment] + + init(_ container: ClientContainer) { + self.status = container.status + self.configuration = container.configuration + self.networks = container.networks + } +} diff --git a/Sources/ContainerCLI/Container/ContainerLogs.swift b/Sources/ContainerCLI/Container/ContainerLogs.swift new file mode 100644 index 000000000..d70e80323 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerLogs.swift @@ -0,0 +1,144 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Dispatch +import Foundation + +extension Application { + struct ContainerLogs: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "logs", + abstract: "Fetch container stdio or boot logs" + ) + + @OptionGroup + var global: Flags.Global + + @Flag(name: .shortAndLong, help: "Follow log output") + var follow: Bool = false + + @Flag(name: .long, help: "Display the boot log for the container instead of stdio") + var boot: Bool = false + + @Option(name: [.customShort("n")], help: "Number of lines to show from the end of the logs. If not provided this will print all of the logs") + var numLines: Int? + + @Argument(help: "Container to fetch logs for") + var container: String + + func run() async throws { + do { + let container = try await ClientContainer.get(id: container) + let fhs = try await container.logs() + let fileHandle = boot ? fhs[1] : fhs[0] + + try await Self.tail( + fh: fileHandle, + n: numLines, + follow: follow + ) + } catch { + throw ContainerizationError( + .invalidArgument, + message: "failed to fetch container logs for \(container): \(error)" + ) + } + } + + private static func tail( + fh: FileHandle, + n: Int?, + follow: Bool + ) async throws { + if let n { + var buffer = Data() + let size = try fh.seekToEnd() + var offset = size + var lines: [String] = [] + + while offset > 0, lines.count < n { + let readSize = min(1024, offset) + offset -= readSize + try fh.seek(toOffset: offset) + + let data = fh.readData(ofLength: Int(readSize)) + buffer.insert(contentsOf: data, at: 0) + + if let chunk = String(data: buffer, encoding: .utf8) { + lines = chunk.components(separatedBy: .newlines) + lines = lines.filter { !$0.isEmpty } + } + } + + lines = Array(lines.suffix(n)) + for line in lines { + print(line) + } + } else { + // Fast path if all they want is the full file. + guard let data = try fh.readToEnd() else { + // Seems you get nil if it's a zero byte read, or you + // try and read from dev/null. + return + } + guard let str = String(data: data, encoding: .utf8) else { + throw ContainerizationError( + .internalError, + message: "failed to convert container logs to utf8" + ) + } + print(str.trimmingCharacters(in: .newlines)) + } + + if follow { + try await Self.followFile(fh: fh) + } + } + + private static func followFile(fh: FileHandle) async throws { + _ = try fh.seekToEnd() + let stream = AsyncStream { cont in + fh.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + // Triggers on container restart - can exit here as well + do { + _ = try fh.seekToEnd() // To continue streaming existing truncated log files + } catch { + fh.readabilityHandler = nil + cont.finish() + return + } + } + if let str = String(data: data, encoding: .utf8), !str.isEmpty { + var lines = str.components(separatedBy: .newlines) + lines = lines.filter { !$0.isEmpty } + for line in lines { + cont.yield(line) + } + } + } + } + + for await line in stream { + print(line) + } + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerStart.swift b/Sources/ContainerCLI/Container/ContainerStart.swift new file mode 100644 index 000000000..a804b9c20 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerStart.swift @@ -0,0 +1,87 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import TerminalProgress + +extension Application { + struct ContainerStart: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Start a container") + + @Flag(name: .shortAndLong, help: "Attach STDOUT/STDERR") + var attach = false + + @Flag(name: .shortAndLong, help: "Attach container's STDIN") + var interactive = false + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Container's ID") + var containerID: String + + func run() async throws { + var exitCode: Int32 = 127 + + let progressConfig = try ProgressConfig( + description: "Starting container" + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + let container = try await ClientContainer.get(id: containerID) + let process = try await container.bootstrap() + + progress.set(description: "Starting init process") + let detach = !self.attach && !self.interactive + do { + let io = try ProcessIO.create( + tty: container.configuration.initProcess.terminal, + interactive: self.interactive, + detach: detach + ) + progress.finish() + if detach { + try await process.start(io.stdio) + defer { + try? io.close() + } + try io.closeAfterStart() + print(self.containerID) + return + } + + exitCode = try await Application.handleProcess(io: io, process: process) + } catch { + try? await container.stop() + + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to start container: \(error)") + } + throw ArgumentParser.ExitCode(exitCode) + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerStop.swift b/Sources/ContainerCLI/Container/ContainerStop.swift new file mode 100644 index 000000000..78f69090e --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerStop.swift @@ -0,0 +1,102 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import Foundation + +extension Application { + struct ContainerStop: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "stop", + abstract: "Stop one or more running containers") + + @Flag(name: .shortAndLong, help: "Stop all running containers") + var all = false + + @Option(name: .shortAndLong, help: "Signal to send the container(s)") + var signal: String = "SIGTERM" + + @Option(name: .shortAndLong, help: "Seconds to wait before killing the container(s)") + var time: Int32 = 5 + + @Argument + var containerIDs: [String] = [] + + @OptionGroup + var global: Flags.Global + + func validate() throws { + if containerIDs.count == 0 && !all { + throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") + } + if containerIDs.count > 0 && all { + throw ContainerizationError( + .invalidArgument, message: "explicitly supplied container IDs conflicts with the --all flag") + } + } + + mutating func run() async throws { + let set = Set(containerIDs) + var containers = [ClientContainer]() + if self.all { + containers = try await ClientContainer.list() + } else { + containers = try await ClientContainer.list().filter { c in + set.contains(c.id) + } + } + + let opts = ContainerStopOptions( + timeoutInSeconds: self.time, + signal: try Signals.parseSignal(self.signal) + ) + let failed = try await Self.stopContainers(containers: containers, stopOptions: opts) + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "stop failed for one or more containers \(failed.joined(separator: ","))") + } + } + + static func stopContainers(containers: [ClientContainer], stopOptions: ContainerStopOptions) async throws -> [String] { + var failed: [String] = [] + try await withThrowingTaskGroup(of: ClientContainer?.self) { group in + for container in containers { + group.addTask { + do { + try await container.stop(opts: stopOptions) + print(container.id) + return nil + } catch { + log.error("failed to stop container \(container.id): \(error)") + return container + } + } + } + + for try await ctr in group { + guard let ctr else { + continue + } + failed.append(ctr.id) + } + } + + return failed + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainersCommand.swift b/Sources/ContainerCLI/Container/ContainersCommand.swift new file mode 100644 index 000000000..ef6aff93e --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainersCommand.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct ContainersCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "containers", + abstract: "Manage containers", + subcommands: [ + ContainerCreate.self, + ContainerDelete.self, + ContainerExec.self, + ContainerInspect.self, + ContainerKill.self, + ContainerList.self, + ContainerLogs.self, + ContainerStart.self, + ContainerStop.self, + ], + aliases: ["container", "c"] + ) + } +} diff --git a/Sources/ContainerCLI/Container/ProcessUtils.swift b/Sources/ContainerCLI/Container/ProcessUtils.swift new file mode 100644 index 000000000..d4dda6a27 --- /dev/null +++ b/Sources/ContainerCLI/Container/ProcessUtils.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerClient +import Containerization +import ContainerizationError +import ContainerizationOS +import Foundation + +extension Application { + static func ensureRunning(container: ClientContainer) throws { + if container.status != .running { + throw ContainerizationError(.invalidState, message: "container \(container.id) is not running") + } + } +} diff --git a/Sources/ContainerCLI/DefaultCommand.swift b/Sources/ContainerCLI/DefaultCommand.swift new file mode 100644 index 000000000..ef88aaaa3 --- /dev/null +++ b/Sources/ContainerCLI/DefaultCommand.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin + +struct DefaultCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: nil, + shouldDisplay: false + ) + + @OptionGroup(visibility: .hidden) + var global: Flags.Global + + @Argument(parsing: .captureForPassthrough) + var remaining: [String] = [] + + func run() async throws { + // See if we have a possible plugin command. + guard let command = remaining.first else { + Application.printModifiedHelpText() + return + } + + // Check for edge cases and unknown options to match the behavior in the absence of plugins. + if command.isEmpty { + throw ValidationError("Unknown argument '\(command)'") + } else if command.starts(with: "-") { + throw ValidationError("Unknown option '\(command)'") + } + + let pluginLoader = Application.pluginLoader + guard let plugin = pluginLoader.findPlugin(name: command), plugin.config.isCLI else { + throw ValidationError("failed to find plugin named container-\(command)") + } + // Exec performs execvp (with no fork). + try plugin.exec(args: remaining) + } +} diff --git a/Sources/ContainerCLI/Image/ImageInspect.swift b/Sources/ContainerCLI/Image/ImageInspect.swift new file mode 100644 index 000000000..cea356867 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageInspect.swift @@ -0,0 +1,53 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation +import SwiftProtobuf + +extension Application { + struct ImageInspect: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display information about one or more images") + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Images to inspect") + var images: [String] + + func run() async throws { + var printable = [any Codable]() + let result = try await ClientImage.get(names: images) + let notFound = result.error + for image in result.images { + guard !Utility.isInfraImage(name: image.reference) else { + continue + } + printable.append(try await image.details()) + } + if printable.count > 0 { + print(try printable.jsonArray()) + } + if notFound.count > 0 { + throw ContainerizationError(.notFound, message: "Images: \(notFound.joined(separator: "\n"))") + } + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageList.swift b/Sources/ContainerCLI/Image/ImageList.swift new file mode 100644 index 000000000..e666feca7 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageList.swift @@ -0,0 +1,175 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationOCI +import Foundation +import SwiftProtobuf + +extension Application { + struct ListImageOptions: ParsableArguments { + @Flag(name: .shortAndLong, help: "Only output the image name") + var quiet = false + + @Flag(name: .shortAndLong, help: "Verbose output") + var verbose = false + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @OptionGroup + var global: Flags.Global + } + + struct ListImageImplementation { + static private func createHeader() -> [[String]] { + [["NAME", "TAG", "DIGEST"]] + } + + static private func createVerboseHeader() -> [[String]] { + [["NAME", "TAG", "INDEX DIGEST", "OS", "ARCH", "VARIANT", "SIZE", "CREATED", "MANIFEST DIGEST"]] + } + + static private func printImagesVerbose(images: [ClientImage]) async throws { + + var rows = createVerboseHeader() + for image in images { + let formatter = ByteCountFormatter() + for descriptor in try await image.index().manifests { + // Don't list attestation manifests + if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], + referenceType == "attestation-manifest" + { + continue + } + + guard let platform = descriptor.platform else { + continue + } + + let os = platform.os + let arch = platform.architecture + let variant = platform.variant ?? "" + + var config: ContainerizationOCI.Image + var manifest: ContainerizationOCI.Manifest + do { + config = try await image.config(for: platform) + manifest = try await image.manifest(for: platform) + } catch { + continue + } + + let created = config.created ?? "" + let size = descriptor.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size }) + let formattedSize = formatter.string(fromByteCount: size) + + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) + let row = [ + reference.name, + reference.tag ?? "", + Utility.trimDigest(digest: image.descriptor.digest), + os, + arch, + variant, + formattedSize, + created, + Utility.trimDigest(digest: descriptor.digest), + ] + rows.append(row) + } + } + + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + + static private func printImages(images: [ClientImage], format: ListFormat, options: ListImageOptions) async throws { + var images = images + images.sort { + $0.reference < $1.reference + } + + if format == .json { + let data = try JSONEncoder().encode(images.map { $0.description }) + print(String(data: data, encoding: .utf8)!) + return + } + + if options.quiet { + try images.forEach { image in + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + print(processedReferenceString) + } + return + } + + if options.verbose { + try await Self.printImagesVerbose(images: images) + return + } + + var rows = createHeader() + for image in images { + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) + rows.append([ + reference.name, + reference.tag ?? "", + Utility.trimDigest(digest: image.descriptor.digest), + ]) + } + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + + static func validate(options: ListImageOptions) throws { + if options.quiet && options.verbose { + throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite and --verbose together") + } + let modifier = options.quiet || options.verbose + if modifier && options.format == .json { + throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite or --verbose along with --format json") + } + } + + static func listImages(options: ListImageOptions) async throws { + let images = try await ClientImage.list().filter { img in + !Utility.isInfraImage(name: img.reference) + } + try await printImages(images: images, format: options.format, options: options) + } + } + + struct ImageList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List images", + aliases: ["ls"]) + + @OptionGroup + var options: ListImageOptions + + mutating func run() async throws { + try ListImageImplementation.validate(options: options) + try await ListImageImplementation.listImages(options: options) + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageLoad.swift b/Sources/ContainerCLI/Image/ImageLoad.swift new file mode 100644 index 000000000..719fd19ec --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageLoad.swift @@ -0,0 +1,76 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import Foundation +import TerminalProgress + +extension Application { + struct ImageLoad: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "load", + abstract: "Load images from an OCI compatible tar archive" + ) + + @OptionGroup + var global: Flags.Global + + @Option( + name: .shortAndLong, help: "Path to the tar archive to load images from", completion: .file(), + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) + }) + var input: String + + func run() async throws { + guard FileManager.default.fileExists(atPath: input) else { + print("File does not exist \(input)") + Application.exit(withError: ArgumentParser.ExitCode(1)) + } + + let progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + totalTasks: 2 + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + progress.set(description: "Loading tar archive") + let loaded = try await ClientImage.load(from: input) + + let taskManager = ProgressTaskCoordinator() + let unpackTask = await taskManager.startTask() + progress.set(description: "Unpacking image") + progress.set(itemsName: "entries") + for image in loaded { + try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) + } + await taskManager.finish() + progress.finish() + print("Loaded images:") + for image in loaded { + print(image.reference) + } + } + } +} diff --git a/Sources/ContainerCLI/Image/ImagePrune.swift b/Sources/ContainerCLI/Image/ImagePrune.swift new file mode 100644 index 000000000..d233247f1 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImagePrune.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension Application { + struct ImagePrune: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "prune", + abstract: "Remove unreferenced and dangling images") + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let (_, size) = try await ClientImage.pruneImages() + let formatter = ByteCountFormatter() + let freed = formatter.string(fromByteCount: Int64(size)) + print("Cleaned unreferenced images and snapshots") + print("Reclaimed \(freed) in disk space") + } + } +} diff --git a/Sources/ContainerCLI/Image/ImagePull.swift b/Sources/ContainerCLI/Image/ImagePull.swift new file mode 100644 index 000000000..58f6dc2c6 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImagePull.swift @@ -0,0 +1,98 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationOCI +import TerminalProgress + +extension Application { + struct ImagePull: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "pull", + abstract: "Pull an image" + ) + + @OptionGroup + var global: Flags.Global + + @OptionGroup + var registry: Flags.Registry + + @OptionGroup + var progressFlags: Flags.Progress + + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? + + @Argument var reference: String + + init() {} + + init(platform: String? = nil, scheme: String = "auto", reference: String, disableProgress: Bool = false) { + self.global = Flags.Global() + self.registry = Flags.Registry(scheme: scheme) + self.progressFlags = Flags.Progress(disableProgressUpdates: disableProgress) + self.platform = platform + self.reference = reference + } + + func run() async throws { + var p: Platform? + if let platform { + p = try Platform(from: platform) + } + + let scheme = try RequestScheme(registry.scheme) + + let processedReference = try ClientImage.normalizeReference(reference) + + var progressConfig: ProgressConfig + if self.progressFlags.disableProgressUpdates { + progressConfig = try ProgressConfig(disableProgressUpdates: self.progressFlags.disableProgressUpdates) + } else { + progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + ignoreSmallSize: true, + totalTasks: 2 + ) + } + + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + progress.set(description: "Fetching image") + progress.set(itemsName: "blobs") + let taskManager = ProgressTaskCoordinator() + let fetchTask = await taskManager.startTask() + let image = try await ClientImage.pull( + reference: processedReference, platform: p, scheme: scheme, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler) + ) + + progress.set(description: "Unpacking image") + progress.set(itemsName: "entries") + let unpackTask = await taskManager.startTask() + try await image.unpack(platform: p, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) + await taskManager.finish() + progress.finish() + } + } +} diff --git a/Sources/ContainerCLI/Image/ImagePush.swift b/Sources/ContainerCLI/Image/ImagePush.swift new file mode 100644 index 000000000..e61d162de --- /dev/null +++ b/Sources/ContainerCLI/Image/ImagePush.swift @@ -0,0 +1,73 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationOCI +import TerminalProgress + +extension Application { + struct ImagePush: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "push", + abstract: "Push an image" + ) + + @OptionGroup + var global: Flags.Global + + @OptionGroup + var registry: Flags.Registry + + @OptionGroup + var progressFlags: Flags.Progress + + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? + + @Argument var reference: String + + func run() async throws { + var p: Platform? + if let platform { + p = try Platform(from: platform) + } + + let scheme = try RequestScheme(registry.scheme) + let image = try await ClientImage.get(reference: reference) + + var progressConfig: ProgressConfig + if progressFlags.disableProgressUpdates { + progressConfig = try ProgressConfig(disableProgressUpdates: progressFlags.disableProgressUpdates) + } else { + progressConfig = try ProgressConfig( + description: "Pushing image \(image.reference)", + itemsName: "blobs", + showItems: true, + showSpeed: false, + ignoreSmallSize: true + ) + } + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + _ = try await image.push(platform: p, scheme: scheme, progressUpdate: progress.handler) + progress.finish() + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageRemove.swift b/Sources/ContainerCLI/Image/ImageRemove.swift new file mode 100644 index 000000000..2f0c86c22 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageRemove.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import Foundation + +extension Application { + struct RemoveImageOptions: ParsableArguments { + @Flag(name: .shortAndLong, help: "Remove all images") + var all: Bool = false + + @Argument + var images: [String] = [] + + @OptionGroup + var global: Flags.Global + } + + struct RemoveImageImplementation { + static func validate(options: RemoveImageOptions) throws { + if options.images.count == 0 && !options.all { + throw ContainerizationError(.invalidArgument, message: "no image specified and --all not supplied") + } + if options.images.count > 0 && options.all { + throw ContainerizationError(.invalidArgument, message: "explicitly supplied images conflict with the --all flag") + } + } + + static func removeImage(options: RemoveImageOptions) async throws { + let (found, notFound) = try await { + if options.all { + let found = try await ClientImage.list() + let notFound: [String] = [] + return (found, notFound) + } + return try await ClientImage.get(names: options.images) + }() + var failures: [String] = notFound + var didDeleteAnyImage = false + for image in found { + guard !Utility.isInfraImage(name: image.reference) else { + continue + } + do { + try await ClientImage.delete(reference: image.reference, garbageCollect: false) + print(image.reference) + didDeleteAnyImage = true + } catch { + log.error("failed to remove \(image.reference): \(error)") + failures.append(image.reference) + } + } + let (_, size) = try await ClientImage.pruneImages() + let formatter = ByteCountFormatter() + let freed = formatter.string(fromByteCount: Int64(size)) + + if didDeleteAnyImage { + print("Reclaimed \(freed) in disk space") + } + if failures.count > 0 { + throw ContainerizationError(.internalError, message: "failed to delete one or more images: \(failures)") + } + } + } + + struct ImageRemove: AsyncParsableCommand { + @OptionGroup + var options: RemoveImageOptions + + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Remove one or more images", + aliases: ["rm"]) + + func validate() throws { + try RemoveImageImplementation.validate(options: options) + } + + mutating func run() async throws { + try await RemoveImageImplementation.removeImage(options: options) + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageSave.swift b/Sources/ContainerCLI/Image/ImageSave.swift new file mode 100644 index 000000000..8c0b6eac4 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageSave.swift @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationOCI +import Foundation +import TerminalProgress + +extension Application { + struct ImageSave: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "save", + abstract: "Save an image as an OCI compatible tar archive" + ) + + @OptionGroup + var global: Flags.Global + + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? + + @Option( + name: .shortAndLong, help: "Path to save the image tar archive", completion: .file(), + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) + }) + var output: String + + @Argument var reference: String + + func run() async throws { + var p: Platform? + if let platform { + p = try Platform(from: platform) + } + + let progressConfig = try ProgressConfig( + description: "Saving image" + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + let image = try await ClientImage.get(reference: reference) + try await image.save(out: output, platform: p) + + progress.finish() + print("Image saved") + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageTag.swift b/Sources/ContainerCLI/Image/ImageTag.swift new file mode 100644 index 000000000..01a76190f --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageTag.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct ImageTag: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "tag", + abstract: "Tag an image") + + @Argument(help: "SOURCE_IMAGE[:TAG]") + var source: String + + @Argument(help: "TARGET_IMAGE[:TAG]") + var target: String + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let existing = try await ClientImage.get(reference: source) + let targetReference = try ClientImage.normalizeReference(target) + try await existing.tag(new: targetReference) + print("Image \(source) tagged as \(target)") + } + } +} diff --git a/Sources/ContainerCLI/Image/ImagesCommand.swift b/Sources/ContainerCLI/Image/ImagesCommand.swift new file mode 100644 index 000000000..968dfd239 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImagesCommand.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct ImagesCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "images", + abstract: "Manage images", + subcommands: [ + ImageInspect.self, + ImageList.self, + ImageLoad.self, + ImagePrune.self, + ImagePull.self, + ImagePush.self, + ImageRemove.self, + ImageSave.self, + ImageTag.self, + ], + aliases: ["image", "i"] + ) + } +} diff --git a/Sources/ContainerCLI/Network/NetworkCommand.swift b/Sources/ContainerCLI/Network/NetworkCommand.swift new file mode 100644 index 000000000..7e502431b --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkCommand.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct NetworkCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "network", + abstract: "Manage container networks", + subcommands: [ + NetworkCreate.self, + NetworkDelete.self, + NetworkList.self, + NetworkInspect.self, + ], + aliases: ["n"] + ) + } +} diff --git a/Sources/ContainerCLI/Network/NetworkCreate.swift b/Sources/ContainerCLI/Network/NetworkCreate.swift new file mode 100644 index 000000000..535e029ed --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkCreate.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkService +import ContainerizationError +import Foundation +import TerminalProgress + +extension Application { + struct NetworkCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a new network") + + @Argument(help: "Network name") + var name: String + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let config = NetworkConfiguration(id: self.name, mode: .nat) + let state = try await ClientNetwork.create(configuration: config) + print(state.id) + } + } +} diff --git a/Sources/ContainerCLI/Network/NetworkDelete.swift b/Sources/ContainerCLI/Network/NetworkDelete.swift new file mode 100644 index 000000000..836d6c8ca --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkDelete.swift @@ -0,0 +1,116 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkService +import ContainerizationError +import Foundation + +extension Application { + struct NetworkDelete: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete one or more networks", + aliases: ["rm"]) + + @Flag(name: .shortAndLong, help: "Remove all networks") + var all = false + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Network names") + var networkNames: [String] = [] + + func validate() throws { + if networkNames.count == 0 && !all { + throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied") + } + if networkNames.count > 0 && all { + throw ContainerizationError( + .invalidArgument, + message: "explicitly supplied network name(s) conflict with the --all flag" + ) + } + } + + mutating func run() async throws { + let uniqueNetworkNames = Set(networkNames) + let networks: [NetworkState] + + if all { + networks = try await ClientNetwork.list() + } else { + networks = try await ClientNetwork.list() + .filter { c in + uniqueNetworkNames.contains(c.id) + } + + // If one of the networks requested isn't present lets throw. We don't need to do + // this for --all as --all should be perfectly usable with no networks to remove, + // otherwise it'd be quite clunky. + if networks.count != uniqueNetworkNames.count { + let missing = uniqueNetworkNames.filter { id in + !networks.contains { n in + n.id == id + } + } + throw ContainerizationError( + .notFound, + message: "failed to delete one or more networks: \(missing)" + ) + } + } + + if uniqueNetworkNames.contains(ClientNetwork.defaultNetworkName) { + throw ContainerizationError( + .invalidArgument, + message: "cannot delete the default network" + ) + } + + var failed = [String]() + try await withThrowingTaskGroup(of: NetworkState?.self) { group in + for network in networks { + group.addTask { + do { + // delete atomically disables the IP allocator, then deletes + // the allocator disable fails if any IPs are still in use + try await ClientNetwork.delete(id: network.id) + print(network.id) + return nil + } catch { + log.error("failed to delete network \(network.id): \(error)") + return network + } + } + } + + for try await network in group { + guard let network else { + continue + } + failed.append(network.id) + } + } + + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "delete failed for one or more networks: \(failed)") + } + } + } +} diff --git a/Sources/ContainerCLI/Network/NetworkInspect.swift b/Sources/ContainerCLI/Network/NetworkInspect.swift new file mode 100644 index 000000000..614c8b111 --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkInspect.swift @@ -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 ContainerNetworkService +import Foundation +import SwiftProtobuf + +extension Application { + struct NetworkInspect: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display information about one or more networks") + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Networks to inspect") + var networks: [String] + + func run() async throws { + let objects: [any Codable] = try await ClientNetwork.list().filter { + networks.contains($0.id) + }.map { + PrintableNetwork($0) + } + print(try objects.jsonArray()) + } + } +} diff --git a/Sources/ContainerCLI/Network/NetworkList.swift b/Sources/ContainerCLI/Network/NetworkList.swift new file mode 100644 index 000000000..9fb44dcb4 --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkList.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkService +import ContainerizationExtras +import Foundation +import SwiftProtobuf + +extension Application { + struct NetworkList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List networks", + 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 networks = try await ClientNetwork.list() + try printNetworks(networks: networks, format: format) + } + + private func createHeader() -> [[String]] { + [["NETWORK", "STATE", "SUBNET"]] + } + + private func printNetworks(networks: [NetworkState], format: ListFormat) throws { + if format == .json { + let printables = networks.map { + PrintableNetwork($0) + } + let data = try JSONEncoder().encode(printables) + print(String(data: data, encoding: .utf8)!) + + return + } + + if self.quiet { + networks.forEach { + print($0.id) + } + return + } + + var rows = createHeader() + for network in networks { + rows.append(network.asRow) + } + + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + } +} + +extension NetworkState { + var asRow: [String] { + switch self { + case .created(_): + return [self.id, self.state, "none"] + case .running(_, let status): + return [self.id, self.state, status.address] + } + } +} + +struct PrintableNetwork: Codable { + let id: String + let state: String + let config: NetworkConfiguration + let status: NetworkStatus? + + init(_ network: NetworkState) { + self.id = network.id + self.state = network.state + switch network { + case .created(let config): + self.config = config + self.status = nil + case .running(let config, let status): + self.config = config + self.status = status + } + } +} diff --git a/Sources/ContainerCLI/Registry/Login.swift b/Sources/ContainerCLI/Registry/Login.swift new file mode 100644 index 000000000..7de7fe7e4 --- /dev/null +++ b/Sources/ContainerCLI/Registry/Login.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationOCI +import Foundation + +extension Application { + struct Login: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Login to a registry" + ) + + @Option(name: .shortAndLong, help: "Username") + var username: String = "" + + @Flag(help: "Take the password from stdin") + var passwordStdin: Bool = false + + @Argument(help: "Registry server name") + var server: String + + @OptionGroup + var registry: Flags.Registry + + func run() async throws { + var username = self.username + var password = "" + if passwordStdin { + if username == "" { + throw ContainerizationError( + .invalidArgument, message: "must provide --username with --password-stdin") + } + guard let passwordData = try FileHandle.standardInput.readToEnd() else { + throw ContainerizationError(.invalidArgument, message: "failed to read password from stdin") + } + password = String(decoding: passwordData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + } + let keychain = KeychainHelper(id: Constants.keychainID) + if username == "" { + username = try keychain.userPrompt(domain: server) + } + if password == "" { + password = try keychain.passwordPrompt() + print() + } + + let server = Reference.resolveDomain(domain: server) + let scheme = try RequestScheme(registry.scheme).schemeFor(host: server) + let _url = "\(scheme)://\(server)" + guard let url = URL(string: _url) else { + throw ContainerizationError(.invalidArgument, message: "Cannot convert \(_url) to URL") + } + guard let host = url.host else { + throw ContainerizationError(.invalidArgument, message: "Invalid host \(server)") + } + + let client = RegistryClient( + host: host, + scheme: scheme.rawValue, + port: url.port, + authentication: BasicAuthentication(username: username, password: password), + retryOptions: .init( + maxRetries: 10, + retryInterval: 300_000_000, + shouldRetry: ({ response in + response.status.code >= 500 + }) + ) + ) + try await client.ping() + try keychain.save(domain: server, username: username, password: password) + print("Login succeeded") + } + } +} diff --git a/Sources/ContainerCLI/Registry/Logout.swift b/Sources/ContainerCLI/Registry/Logout.swift new file mode 100644 index 000000000..a24996e12 --- /dev/null +++ b/Sources/ContainerCLI/Registry/Logout.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationOCI + +extension Application { + struct Logout: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Log out from a registry") + + @Argument(help: "Registry server name") + var registry: String + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let keychain = KeychainHelper(id: Constants.keychainID) + let r = Reference.resolveDomain(domain: registry) + try keychain.delete(domain: r) + } + } +} diff --git a/Sources/ContainerCLI/Registry/RegistryCommand.swift b/Sources/ContainerCLI/Registry/RegistryCommand.swift new file mode 100644 index 000000000..c160c9469 --- /dev/null +++ b/Sources/ContainerCLI/Registry/RegistryCommand.swift @@ -0,0 +1,32 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct RegistryCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "registry", + abstract: "Manage registry configurations", + subcommands: [ + Login.self, + Logout.self, + RegistryDefault.self, + ], + aliases: ["r"] + ) + } +} diff --git a/Sources/ContainerCLI/Registry/RegistryDefault.swift b/Sources/ContainerCLI/Registry/RegistryDefault.swift new file mode 100644 index 000000000..593d41e27 --- /dev/null +++ b/Sources/ContainerCLI/Registry/RegistryDefault.swift @@ -0,0 +1,98 @@ +//===----------------------------------------------------------------------===// +// 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 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 + } + } + ClientDefaults.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 { + ClientDefaults.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(ClientDefaults.get(key: .defaultRegistryDomain)) + } + } +} diff --git a/Sources/ContainerCLI/RunCommand.swift b/Sources/ContainerCLI/RunCommand.swift new file mode 100644 index 000000000..3a818e939 --- /dev/null +++ b/Sources/ContainerCLI/RunCommand.swift @@ -0,0 +1,317 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOS +import Foundation +import NIOCore +import NIOPosix +import TerminalProgress + +extension Application { + struct ContainerRunCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "run", + abstract: "Run a container") + + @OptionGroup + var processFlags: Flags.Process + + @OptionGroup + var resourceFlags: Flags.Resource + + @OptionGroup + var managementFlags: Flags.Management + + @OptionGroup + var registryFlags: Flags.Registry + + @OptionGroup + var global: Flags.Global + + @OptionGroup + var progressFlags: Flags.Progress + + @Argument(help: "Image name") + var image: String + + @Argument(parsing: .captureForPassthrough, help: "Container init process arguments") + var arguments: [String] = [] + + func run() async throws { + var exitCode: Int32 = 127 + let id = Utility.createContainerID(name: self.managementFlags.name) + + var progressConfig: ProgressConfig + if progressFlags.disableProgressUpdates { + progressConfig = try ProgressConfig(disableProgressUpdates: progressFlags.disableProgressUpdates) + } else { + progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + ignoreSmallSize: true, + totalTasks: 6 + ) + } + + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + try Utility.validEntityName(id) + + // Check if container with id already exists. + let existing = try? await ClientContainer.get(id: id) + guard existing == nil else { + throw ContainerizationError( + .exists, + message: "container with id \(id) already exists" + ) + } + + let ck = try await Utility.containerConfigFromFlags( + id: id, + image: image, + arguments: arguments, + process: processFlags, + management: managementFlags, + resource: resourceFlags, + registry: registryFlags, + progressUpdate: progress.handler + ) + + progress.set(description: "Starting container") + + let options = ContainerCreateOptions(autoRemove: managementFlags.remove) + let container = try await ClientContainer.create( + configuration: ck.0, + options: options, + kernel: ck.1 + ) + + let detach = self.managementFlags.detach + + let process = try await container.bootstrap() + progress.finish() + + do { + let io = try ProcessIO.create( + tty: self.processFlags.tty, + interactive: self.processFlags.interactive, + detach: detach + ) + + if !self.managementFlags.cidfile.isEmpty { + let path = self.managementFlags.cidfile + let data = id.data(using: .utf8) + var attributes = [FileAttributeKey: Any]() + attributes[.posixPermissions] = 0o644 + let success = FileManager.default.createFile( + atPath: path, + contents: data, + attributes: attributes + ) + guard success else { + throw ContainerizationError( + .internalError, message: "failed to create cidfile at \(path): \(errno)") + } + } + + if detach { + try await process.start(io.stdio) + defer { + try? io.close() + } + try io.closeAfterStart() + print(id) + return + } + + if !self.processFlags.tty { + var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) + handler.start { + print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") + Darwin.exit(1) + } + } + + exitCode = try await Application.handleProcess(io: io, process: process) + } catch { + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to run container: \(error)") + } + throw ArgumentParser.ExitCode(exitCode) + } + } +} + +struct ProcessIO { + let stdin: Pipe? + let stdout: Pipe? + let stderr: Pipe? + var ioTracker: IoTracker? + + struct IoTracker { + let stream: AsyncStream + let cont: AsyncStream.Continuation + let configuredStreams: Int + } + + let stdio: [FileHandle?] + + let console: Terminal? + + func closeAfterStart() throws { + try stdin?.fileHandleForReading.close() + try stdout?.fileHandleForWriting.close() + try stderr?.fileHandleForWriting.close() + } + + func close() throws { + try console?.reset() + } + + static func create(tty: Bool, interactive: Bool, detach: Bool) throws -> ProcessIO { + let current: Terminal? = try { + if !tty { + return nil + } + let current = try Terminal.current + try current.setraw() + return current + }() + + var stdio = [FileHandle?](repeating: nil, count: 3) + + let stdin: Pipe? = { + if !interactive && !tty { + return nil + } + return Pipe() + }() + + if let stdin { + if interactive { + let pin = FileHandle.standardInput + pin.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + pin.readabilityHandler = nil + return + } + try! stdin.fileHandleForWriting.write(contentsOf: data) + } + } + stdio[0] = stdin.fileHandleForReading + } + + let stdout: Pipe? = { + if detach { + return nil + } + return Pipe() + }() + + var configuredStreams = 0 + let (stream, cc) = AsyncStream.makeStream() + if let stdout { + configuredStreams += 1 + let pout: FileHandle = { + if let current { + return current.handle + } + return .standardOutput + }() + + let rout = stdout.fileHandleForReading + rout.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + rout.readabilityHandler = nil + cc.yield() + return + } + try! pout.write(contentsOf: data) + } + stdio[1] = stdout.fileHandleForWriting + } + + let stderr: Pipe? = { + if detach || tty { + return nil + } + return Pipe() + }() + if let stderr { + configuredStreams += 1 + let perr: FileHandle = .standardError + let rerr = stderr.fileHandleForReading + rerr.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + rerr.readabilityHandler = nil + cc.yield() + return + } + try! perr.write(contentsOf: data) + } + stdio[2] = stderr.fileHandleForWriting + } + + var ioTracker: IoTracker? = nil + if configuredStreams > 0 { + ioTracker = .init(stream: stream, cont: cc, configuredStreams: configuredStreams) + } + + return .init( + stdin: stdin, + stdout: stdout, + stderr: stderr, + ioTracker: ioTracker, + stdio: stdio, + console: current + ) + } + + public func wait() async throws { + guard let ioTracker = self.ioTracker else { + return + } + do { + try await Timeout.run(seconds: 3) { + var counter = ioTracker.configuredStreams + for await _ in ioTracker.stream { + counter -= 1 + if counter == 0 { + ioTracker.cont.finish() + break + } + } + } + } catch { + log.error("Timeout waiting for IO to complete : \(error)") + throw error + } + } +} diff --git a/Sources/ContainerCLI/System/DNS/DNSCreate.swift b/Sources/ContainerCLI/System/DNS/DNSCreate.swift new file mode 100644 index 000000000..2dbe2d8ac --- /dev/null +++ b/Sources/ContainerCLI/System/DNS/DNSCreate.swift @@ -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 ContainerizationError +import ContainerizationExtras +import Foundation + +extension Application { + struct DNSCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a local DNS domain for containers (must run as an administrator)" + ) + + @Argument(help: "the local domain name") + var domainName: String + + func run() async throws { + let resolver: HostDNSResolver = HostDNSResolver() + do { + try resolver.createDomain(name: domainName) + print(domainName) + } catch let error as ContainerizationError { + throw error + } catch { + throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") + } + + do { + try HostDNSResolver.reinitialize() + } catch { + throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") + } + } + } +} diff --git a/Sources/ContainerCLI/System/DNS/DNSDefault.swift b/Sources/ContainerCLI/System/DNS/DNSDefault.swift new file mode 100644 index 000000000..5a746eab5 --- /dev/null +++ b/Sources/ContainerCLI/System/DNS/DNSDefault.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// 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 + +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 { + ClientDefaults.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 { + ClientDefaults.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(ClientDefaults.getOptional(key: .defaultDNSDomain) ?? "") + } + } + } +} diff --git a/Sources/ContainerCLI/System/DNS/DNSDelete.swift b/Sources/ContainerCLI/System/DNS/DNSDelete.swift new file mode 100644 index 000000000..b3360bb57 --- /dev/null +++ b/Sources/ContainerCLI/System/DNS/DNSDelete.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct DNSDelete: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete a local DNS domain (must run as an administrator)", + aliases: ["rm"] + ) + + @Argument(help: "the local domain name") + var domainName: String + + func run() async throws { + let resolver = HostDNSResolver() + do { + try resolver.deleteDomain(name: domainName) + print(domainName) + } catch { + throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") + } + + do { + try HostDNSResolver.reinitialize() + } catch { + throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") + } + } + } +} diff --git a/Sources/ContainerCLI/System/DNS/DNSList.swift b/Sources/ContainerCLI/System/DNS/DNSList.swift new file mode 100644 index 000000000..616415775 --- /dev/null +++ b/Sources/ContainerCLI/System/DNS/DNSList.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension Application { + struct DNSList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List local DNS domains", + aliases: ["ls"] + ) + + func run() async throws { + let resolver: HostDNSResolver = HostDNSResolver() + let domains = resolver.listDomains() + print(domains.joined(separator: "\n")) + } + + } +} diff --git a/Sources/ContainerCLI/System/Kernel/KernelSet.swift b/Sources/ContainerCLI/System/Kernel/KernelSet.swift new file mode 100644 index 000000000..6a1ac1790 --- /dev/null +++ b/Sources/ContainerCLI/System/Kernel/KernelSet.swift @@ -0,0 +1,114 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Foundation +import TerminalProgress + +extension Application { + struct KernelSet: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "set", + abstract: "Set the default kernel" + ) + + @Option(name: .customLong("binary"), help: "Path to the binary to set as the default kernel. If used with --tar, this points to a location inside the tar") + var binaryPath: String? = nil + + @Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar ball that contains the kernel to use") + var tarPath: String? = nil + + @Option(name: .customLong("arch"), help: "The architecture of the kernel binary. One of (amd64, arm64)") + var architecture: String = ContainerizationOCI.Platform.current.architecture.description + + @Flag(name: .customLong("recommended"), help: "Download and install the recommended kernel as the default. This flag ignores any other arguments") + var recommended: Bool = false + + func run() async throws { + if recommended { + let url = ClientDefaults.get(key: .defaultKernelURL) + let path = ClientDefaults.get(key: .defaultKernelBinaryPath) + print("Installing the recommended kernel from \(url)...") + try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path) + return + } + guard tarPath != nil else { + return try await self.setKernelFromBinary() + } + try await self.setKernelFromTar() + } + + private func setKernelFromBinary() async throws { + guard let binaryPath else { + throw ArgumentParser.ValidationError("Missing argument '--binary'") + } + let absolutePath = URL(fileURLWithPath: binaryPath, relativeTo: .currentDirectory()).absoluteURL.absoluteString + let platform = try getSystemPlatform() + try await ClientKernel.installKernel(kernelFilePath: absolutePath, platform: platform) + } + + private func setKernelFromTar() async throws { + guard let binaryPath else { + throw ArgumentParser.ValidationError("Missing argument '--binary'") + } + guard let tarPath else { + throw ArgumentParser.ValidationError("Missing argument '--tar") + } + let platform = try getSystemPlatform() + let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).absoluteString + let fm = FileManager.default + if fm.fileExists(atPath: localTarPath) { + try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform) + return + } + guard let remoteURL = URL(string: tarPath) else { + throw ContainerizationError(.invalidArgument, message: "Invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?") + } + try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL.absoluteString, kernelFilePath: binaryPath, platform: platform) + } + + private func getSystemPlatform() throws -> SystemPlatform { + switch architecture { + case "arm64": + return .linuxArm + case "amd64": + return .linuxAmd + default: + throw ContainerizationError(.unsupported, message: "Unsupported architecture \(architecture)") + } + } + + public static func downloadAndInstallWithProgressBar(tarRemoteURL: String, kernelFilePath: String, platform: SystemPlatform = .current) async throws { + let progressConfig = try ProgressConfig( + showTasks: true, + totalTasks: 2 + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + try await ClientKernel.installKernelFromTar(tarFile: tarRemoteURL, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler) + progress.finish() + } + + } +} diff --git a/Sources/ContainerCLI/System/SystemCommand.swift b/Sources/ContainerCLI/System/SystemCommand.swift new file mode 100644 index 000000000..3a92bfb92 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemCommand.swift @@ -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 + +extension Application { + struct SystemCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "system", + abstract: "Manage system components", + subcommands: [ + SystemDNS.self, + SystemLogs.self, + SystemStart.self, + SystemStop.self, + SystemStatus.self, + SystemKernel.self, + ], + aliases: ["s"] + ) + } +} diff --git a/Sources/ContainerCLI/System/SystemDNS.swift b/Sources/ContainerCLI/System/SystemDNS.swift new file mode 100644 index 000000000..4f9b3e3b3 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemDNS.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct SystemDNS: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "dns", + abstract: "Manage local DNS domains", + subcommands: [ + DNSCreate.self, + DNSDelete.self, + DNSList.self, + DNSDefault.self, + ] + ) + } +} diff --git a/Sources/ContainerCLI/System/SystemKernel.swift b/Sources/ContainerCLI/System/SystemKernel.swift new file mode 100644 index 000000000..942bd6965 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemKernel.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct SystemKernel: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "kernel", + abstract: "Manage the default kernel configuration", + subcommands: [ + KernelSet.self + ] + ) + } +} diff --git a/Sources/ContainerCLI/System/SystemLogs.swift b/Sources/ContainerCLI/System/SystemLogs.swift new file mode 100644 index 000000000..e2b87ffb9 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemLogs.swift @@ -0,0 +1,82 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import Foundation +import OSLog + +extension Application { + struct SystemLogs: AsyncParsableCommand { + static let subsystem = "com.apple.container" + + static let configuration = CommandConfiguration( + commandName: "logs", + abstract: "Fetch system logs for `container` services" + ) + + @OptionGroup + var global: Flags.Global + + @Option( + name: .long, + help: "Fetch logs starting from the specified time period (minus the current time); supported formats: m, h, d" + ) + var last: String = "5m" + + @Flag(name: .shortAndLong, help: "Follow log output") + var follow: Bool = false + + func run() async throws { + let process = Process() + let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM]) + + Task { + for await _ in sigHandler.signals { + process.terminate() + Darwin.exit(0) + } + } + + do { + var args = ["log"] + args.append(self.follow ? "stream" : "show") + args.append(contentsOf: ["--info", "--debug"]) + if !self.follow { + args.append(contentsOf: ["--last", last]) + } + args.append(contentsOf: ["--predicate", "subsystem = 'com.apple.container'"]) + + process.launchPath = "/usr/bin/env" + process.arguments = args + + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + + try process.run() + process.waitUntilExit() + } catch { + throw ContainerizationError( + .invalidArgument, + message: "failed to system logs: \(error)" + ) + } + throw ArgumentParser.ExitCode(process.terminationStatus) + } + } +} diff --git a/Sources/ContainerCLI/System/SystemStart.swift b/Sources/ContainerCLI/System/SystemStart.swift new file mode 100644 index 000000000..acce91391 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemStart.swift @@ -0,0 +1,170 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin +import ContainerizationError +import Foundation +import TerminalProgress + +extension Application { + struct SystemStart: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Start `container` services" + ) + + @Option(name: .shortAndLong, help: "Path to the `container-apiserver` binary") + var path: String = Bundle.main.executablePath ?? "" + + @Flag(name: .long, help: "Enable debug logging for the runtime daemon.") + var debug = false + + @Flag( + name: .long, inversion: .prefixedEnableDisable, + help: "Specify whether the default kernel should be installed or not. The default behavior is to prompt the user for a response.") + var kernelInstall: Bool? + + func run() async throws { + // Without the true path to the binary in the plist, `container-apiserver` won't launch properly. + let executableUrl = URL(filePath: path) + .resolvingSymlinksInPath() + .deletingLastPathComponent() + .appendingPathComponent("container-apiserver") + + var args = [executableUrl.absolutePath()] + if debug { + args.append("--debug") + } + + let apiServerDataUrl = appRoot.appending(path: "apiserver") + try! FileManager.default.createDirectory(at: apiServerDataUrl, withIntermediateDirectories: true) + let env = ProcessInfo.processInfo.environment.filter { key, _ in + key.hasPrefix("CONTAINER_") + } + + let logURL = apiServerDataUrl.appending(path: "apiserver.log") + let plist = LaunchPlist( + label: "com.apple.container.apiserver", + arguments: args, + environment: env, + limitLoadToSessionType: [.Aqua, .Background, .System], + runAtLoad: true, + stdout: logURL.path, + stderr: logURL.path, + machServices: ["com.apple.container.apiserver"] + ) + + let plistURL = apiServerDataUrl.appending(path: "apiserver.plist") + let data = try plist.encode() + try data.write(to: plistURL) + + try ServiceManager.register(plistPath: plistURL.path) + + // Now ping our friendly daemon. Fail if we don't get a response. + do { + print("Verifying apiserver is running...") + try await ClientHealthCheck.ping(timeout: .seconds(10)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get a response from apiserver: \(error)" + ) + } + + if await !initImageExists() { + try? await installInitialFilesystem() + } + + guard await !kernelExists() else { + return + } + try await installDefaultKernel() + } + + private func installInitialFilesystem() async throws { + let dep = Dependencies.initFs + let pullCommand = ImagePull(reference: dep.source) + print("Installing base container filesystem...") + do { + try await pullCommand.run() + } catch { + log.error("Failed to install base container filesystem: \(error)") + } + } + + private func installDefaultKernel() async throws { + let kernelDependency = Dependencies.kernel + let defaultKernelURL = kernelDependency.source + let defaultKernelBinaryPath = ClientDefaults.get(key: .defaultKernelBinaryPath) + + var shouldInstallKernel = false + if kernelInstall == nil { + print("No default kernel configured.") + print("Install the recommended default kernel from [\(kernelDependency.source)]? [Y/n]: ", terminator: "") + guard let read = readLine(strippingNewline: true) else { + throw ContainerizationError(.internalError, message: "Failed to read user input") + } + guard read.lowercased() == "y" || read.count == 0 else { + print("Please use the `container system kernel set --recommended` command to configure the default kernel") + return + } + shouldInstallKernel = true + } else { + shouldInstallKernel = kernelInstall ?? false + } + guard shouldInstallKernel else { + return + } + print("Installing kernel...") + try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: defaultKernelURL, kernelFilePath: defaultKernelBinaryPath) + } + + private func initImageExists() async -> Bool { + do { + let img = try await ClientImage.get(reference: Dependencies.initFs.source) + let _ = try await img.getSnapshot(platform: .current) + return true + } catch { + return false + } + } + + private func kernelExists() async -> Bool { + do { + try await ClientKernel.getDefaultKernel(for: .current) + return true + } catch { + return false + } + } + } + + private enum Dependencies: String { + case kernel + case initFs + + var source: String { + switch self { + case .initFs: + return ClientDefaults.get(key: .defaultInitImage) + case .kernel: + return ClientDefaults.get(key: .defaultKernelURL) + } + } + } +} diff --git a/Sources/ContainerCLI/System/SystemStatus.swift b/Sources/ContainerCLI/System/SystemStatus.swift new file mode 100644 index 000000000..132607681 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemStatus.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin +import ContainerizationError +import Foundation +import Logging + +extension Application { + struct SystemStatus: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "status", + abstract: "Show the status of `container` services" + ) + + @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") + var prefix: String = "com.apple.container." + + func run() async throws { + let isRegistered = try ServiceManager.isRegistered(fullServiceLabel: "\(prefix)apiserver") + if !isRegistered { + print("apiserver is not running and not registered with launchd") + Application.exit(withError: ExitCode(1)) + } + + // Now ping our friendly daemon. Fail after 10 seconds with no response. + do { + print("Verifying apiserver is running...") + try await ClientHealthCheck.ping(timeout: .seconds(10)) + print("apiserver is running") + } catch { + print("apiserver is not running") + Application.exit(withError: ExitCode(1)) + } + } + } +} diff --git a/Sources/ContainerCLI/System/SystemStop.swift b/Sources/ContainerCLI/System/SystemStop.swift new file mode 100644 index 000000000..32824dd0c --- /dev/null +++ b/Sources/ContainerCLI/System/SystemStop.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin +import ContainerizationOS +import Foundation +import Logging + +extension Application { + struct SystemStop: AsyncParsableCommand { + private static let stopTimeoutSeconds: Int32 = 5 + private static let shutdownTimeoutSeconds: Int32 = 20 + + static let configuration = CommandConfiguration( + commandName: "stop", + abstract: "Stop all `container` services" + ) + + @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") + var prefix: String = "com.apple.container." + + func run() async throws { + let log = Logger( + label: "com.apple.container.cli", + factory: { label in + StreamLogHandler.standardOutput(label: label) + } + ) + + let launchdDomainString = try ServiceManager.getDomainString() + let fullLabel = "\(launchdDomainString)/\(prefix)apiserver" + + log.info("stopping containers", metadata: ["stopTimeoutSeconds": "\(Self.stopTimeoutSeconds)"]) + do { + let containers = try await ClientContainer.list() + let signal = try Signals.parseSignal("SIGTERM") + let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: signal) + let failed = try await ContainerStop.stopContainers(containers: containers, stopOptions: opts) + if !failed.isEmpty { + log.warning("some containers could not be stopped gracefully", metadata: ["ids": "\(failed)"]) + } + + } catch { + log.warning("failed to stop all containers", metadata: ["error": "\(error)"]) + } + + log.info("waiting for containers to exit") + do { + for _ in 0.. Date: Tue, 8 Jul 2025 23:21:38 -0700 Subject: [PATCH 30/80] Update Package.swift --- Package.swift | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/Package.swift b/Package.swift index 9ff27cb33..291adc195 100644 --- a/Package.swift +++ b/Package.swift @@ -42,7 +42,7 @@ let package = Package( .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService"]), .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerClient", targets: ["ContainerClient"]), - .library(name: "ContainerCLI", targets: ["ContainerCLI"]), +// .library(name: "ContainerCLI", targets: ["ContainerCLI"]), .library(name: "ContainerBuild", targets: ["ContainerBuild"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), @@ -84,26 +84,26 @@ let package = Package( ], path: "Sources/CLI" ), - .target( - name: "ContainerCLI", - dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "Logging", package: "swift-log"), - .product(name: "SwiftProtobuf", package: "swift-protobuf"), - .product(name: "Containerization", package: "containerization"), - .product(name: "ContainerizationOCI", package: "containerization"), - .product(name: "ContainerizationOS", package: "containerization"), - "CVersion", - "TerminalProgress", - "ContainerBuild", - "ContainerClient", - "ContainerPlugin", - "ContainerLog", - "Yams", - "Rainbow", - ], - path: "Sources/ContainerCLI" - ), +// .target( +// name: "ContainerCLI", +// dependencies: [ +// .product(name: "ArgumentParser", package: "swift-argument-parser"), +// .product(name: "Logging", package: "swift-log"), +// .product(name: "SwiftProtobuf", package: "swift-protobuf"), +// .product(name: "Containerization", package: "containerization"), +// .product(name: "ContainerizationOCI", package: "containerization"), +// .product(name: "ContainerizationOS", package: "containerization"), +// "CVersion", +// "TerminalProgress", +// "ContainerBuild", +// "ContainerClient", +// "ContainerPlugin", +// "ContainerLog", +// "Yams", +// "Rainbow", +// ], +// path: "Sources/ContainerCLI" +// ), .executableTarget( name: "container-apiserver", dependencies: [ From 71fbefe3412a8d93815bb9cb63f81839a2d2b801 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:22:09 -0700 Subject: [PATCH 31/80] Revert "copy cli to container cli" This reverts commit 1d9704fb709988053aa2250af229fedb6b12c875. --- Sources/ContainerCLI | Bin 0 -> 916 bytes Sources/ContainerCLI/Application.swift | 335 -------- Sources/ContainerCLI/BuildCommand.swift | 313 -------- Sources/ContainerCLI/Builder/Builder.swift | 31 - .../ContainerCLI/Builder/BuilderDelete.swift | 57 -- .../ContainerCLI/Builder/BuilderStart.swift | 262 ------ .../ContainerCLI/Builder/BuilderStatus.swift | 71 -- .../ContainerCLI/Builder/BuilderStop.swift | 49 -- Sources/ContainerCLI/Codable+JSON.swift | 25 - .../Compose/Codable Structs/Build.swift | 52 -- .../Compose/Codable Structs/Config.swift | 55 -- .../Compose/Codable Structs/Deploy.swift | 35 - .../Codable Structs/DeployResources.swift | 31 - .../Codable Structs/DeployRestartPolicy.swift | 35 - .../Codable Structs/DeviceReservation.swift | 35 - .../Codable Structs/DockerCompose.swift | 60 -- .../Codable Structs/ExternalConfig.swift | 31 - .../Codable Structs/ExternalNetwork.swift | 31 - .../Codable Structs/ExternalSecret.swift | 31 - .../Codable Structs/ExternalVolume.swift | 31 - .../Compose/Codable Structs/Healthcheck.swift | 37 - .../Compose/Codable Structs/Network.swift | 68 -- .../Codable Structs/ResourceLimits.swift | 31 - .../ResourceReservations.swift | 34 - .../Compose/Codable Structs/Secret.swift | 58 -- .../Compose/Codable Structs/Service.swift | 203 ----- .../Codable Structs/ServiceConfig.swift | 64 -- .../Codable Structs/ServiceSecret.swift | 64 -- .../Compose/Codable Structs/Volume.swift | 70 -- .../Compose/Commands/ComposeDown.swift | 105 --- .../Compose/Commands/ComposeUp.swift | 749 ------------------ .../ContainerCLI/Compose/ComposeCommand.swift | 55 -- Sources/ContainerCLI/Compose/Errors.swift | 66 -- .../Compose/Helper Functions.swift | 96 --- .../Container/ContainerCreate.swift | 100 --- .../Container/ContainerDelete.swift | 127 --- .../Container/ContainerExec.swift | 96 --- .../Container/ContainerInspect.swift | 43 - .../Container/ContainerKill.swift | 79 -- .../Container/ContainerList.swift | 110 --- .../Container/ContainerLogs.swift | 144 ---- .../Container/ContainerStart.swift | 87 -- .../Container/ContainerStop.swift | 102 --- .../Container/ContainersCommand.swift | 38 - .../ContainerCLI/Container/ProcessUtils.swift | 31 - Sources/ContainerCLI/DefaultCommand.swift | 54 -- Sources/ContainerCLI/Image/ImageInspect.swift | 53 -- Sources/ContainerCLI/Image/ImageList.swift | 175 ---- Sources/ContainerCLI/Image/ImageLoad.swift | 76 -- Sources/ContainerCLI/Image/ImagePrune.swift | 38 - Sources/ContainerCLI/Image/ImagePull.swift | 98 --- Sources/ContainerCLI/Image/ImagePush.swift | 73 -- Sources/ContainerCLI/Image/ImageRemove.swift | 99 --- Sources/ContainerCLI/Image/ImageSave.swift | 67 -- Sources/ContainerCLI/Image/ImageTag.swift | 42 - .../ContainerCLI/Image/ImagesCommand.swift | 38 - .../ContainerCLI/Network/NetworkCommand.swift | 33 - .../ContainerCLI/Network/NetworkCreate.swift | 42 - .../ContainerCLI/Network/NetworkDelete.swift | 116 --- .../ContainerCLI/Network/NetworkInspect.swift | 44 - .../ContainerCLI/Network/NetworkList.swift | 107 --- Sources/ContainerCLI/Registry/Login.swift | 92 --- Sources/ContainerCLI/Registry/Logout.swift | 39 - .../Registry/RegistryCommand.swift | 32 - .../Registry/RegistryDefault.swift | 98 --- Sources/ContainerCLI/RunCommand.swift | 317 -------- .../ContainerCLI/System/DNS/DNSCreate.swift | 51 -- .../ContainerCLI/System/DNS/DNSDefault.swift | 72 -- .../ContainerCLI/System/DNS/DNSDelete.swift | 49 -- Sources/ContainerCLI/System/DNS/DNSList.swift | 36 - .../System/Kernel/KernelSet.swift | 114 --- .../ContainerCLI/System/SystemCommand.swift | 35 - Sources/ContainerCLI/System/SystemDNS.swift | 34 - .../ContainerCLI/System/SystemKernel.swift | 29 - Sources/ContainerCLI/System/SystemLogs.swift | 82 -- Sources/ContainerCLI/System/SystemStart.swift | 170 ---- .../ContainerCLI/System/SystemStatus.swift | 52 -- Sources/ContainerCLI/System/SystemStop.swift | 91 --- 78 files changed, 6775 deletions(-) create mode 100644 Sources/ContainerCLI delete mode 100644 Sources/ContainerCLI/Application.swift delete mode 100644 Sources/ContainerCLI/BuildCommand.swift delete mode 100644 Sources/ContainerCLI/Builder/Builder.swift delete mode 100644 Sources/ContainerCLI/Builder/BuilderDelete.swift delete mode 100644 Sources/ContainerCLI/Builder/BuilderStart.swift delete mode 100644 Sources/ContainerCLI/Builder/BuilderStatus.swift delete mode 100644 Sources/ContainerCLI/Builder/BuilderStop.swift delete mode 100644 Sources/ContainerCLI/Codable+JSON.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Build.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Config.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Network.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Secret.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Service.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Volume.swift delete mode 100644 Sources/ContainerCLI/Compose/Commands/ComposeDown.swift delete mode 100644 Sources/ContainerCLI/Compose/Commands/ComposeUp.swift delete mode 100644 Sources/ContainerCLI/Compose/ComposeCommand.swift delete mode 100644 Sources/ContainerCLI/Compose/Errors.swift delete mode 100644 Sources/ContainerCLI/Compose/Helper Functions.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerCreate.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerDelete.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerExec.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerInspect.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerKill.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerList.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerLogs.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerStart.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerStop.swift delete mode 100644 Sources/ContainerCLI/Container/ContainersCommand.swift delete mode 100644 Sources/ContainerCLI/Container/ProcessUtils.swift delete mode 100644 Sources/ContainerCLI/DefaultCommand.swift delete mode 100644 Sources/ContainerCLI/Image/ImageInspect.swift delete mode 100644 Sources/ContainerCLI/Image/ImageList.swift delete mode 100644 Sources/ContainerCLI/Image/ImageLoad.swift delete mode 100644 Sources/ContainerCLI/Image/ImagePrune.swift delete mode 100644 Sources/ContainerCLI/Image/ImagePull.swift delete mode 100644 Sources/ContainerCLI/Image/ImagePush.swift delete mode 100644 Sources/ContainerCLI/Image/ImageRemove.swift delete mode 100644 Sources/ContainerCLI/Image/ImageSave.swift delete mode 100644 Sources/ContainerCLI/Image/ImageTag.swift delete mode 100644 Sources/ContainerCLI/Image/ImagesCommand.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkCommand.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkCreate.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkDelete.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkInspect.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkList.swift delete mode 100644 Sources/ContainerCLI/Registry/Login.swift delete mode 100644 Sources/ContainerCLI/Registry/Logout.swift delete mode 100644 Sources/ContainerCLI/Registry/RegistryCommand.swift delete mode 100644 Sources/ContainerCLI/Registry/RegistryDefault.swift delete mode 100644 Sources/ContainerCLI/RunCommand.swift delete mode 100644 Sources/ContainerCLI/System/DNS/DNSCreate.swift delete mode 100644 Sources/ContainerCLI/System/DNS/DNSDefault.swift delete mode 100644 Sources/ContainerCLI/System/DNS/DNSDelete.swift delete mode 100644 Sources/ContainerCLI/System/DNS/DNSList.swift delete mode 100644 Sources/ContainerCLI/System/Kernel/KernelSet.swift delete mode 100644 Sources/ContainerCLI/System/SystemCommand.swift delete mode 100644 Sources/ContainerCLI/System/SystemDNS.swift delete mode 100644 Sources/ContainerCLI/System/SystemKernel.swift delete mode 100644 Sources/ContainerCLI/System/SystemLogs.swift delete mode 100644 Sources/ContainerCLI/System/SystemStart.swift delete mode 100644 Sources/ContainerCLI/System/SystemStatus.swift delete mode 100644 Sources/ContainerCLI/System/SystemStop.swift diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI new file mode 100644 index 0000000000000000000000000000000000000000..ce9b7e01c3c1838dc5198f48c2a927a02ac3d673 GIT binary patch literal 916 zcmZvayG{a85Qfis#YR^#YUjq-5|G=16&H;fO)L~bVS~#OiNKOYV`GfI0TyN?bbnot|+K=eGCL%gLVKhi6YPCaIXpJU4ma;2bxp~)HIHT!3#7@5p{B1!!f z3@u+Mn(JP#*YOcAWsUs<%^#NUE5uXa0=50~ynH?1!C$5Qs80lfn+cz;dxCm2=n2O4 zkSCh-M?Hz8KN1eDg*L(oU7r1h_CRm$;gw?4%Zru1gMxR9ZIv)~5v z9Jt2p2G^N2@GG+qEc+`>+(C}df{}7;u8Em&Txk#LRj~ZNi|@U=OB+_eE*{P=_%T-2 literal 0 HcmV?d00001 diff --git a/Sources/ContainerCLI/Application.swift b/Sources/ContainerCLI/Application.swift deleted file mode 100644 index ba002a74c..000000000 --- a/Sources/ContainerCLI/Application.swift +++ /dev/null @@ -1,335 +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 CVersion -import ContainerClient -import ContainerLog -import ContainerPlugin -import ContainerizationError -import ContainerizationOS -import Foundation -import Logging -import TerminalProgress - -// `log` is updated only once in the `validate()` method. -nonisolated(unsafe) var log = { - LoggingSystem.bootstrap { label in - OSLogHandler( - label: label, - category: "CLI" - ) - } - var log = Logger(label: "com.apple.container") - log.logLevel = .debug - return log -}() - -@main -public struct Application: AsyncParsableCommand { - public init() {} - - @OptionGroup - var global: Flags.Global - - public static let configuration = CommandConfiguration( - commandName: "container", - abstract: "A container platform for macOS", - version: releaseVersion(), - subcommands: [ - DefaultCommand.self - ], - groupedSubcommands: [ - CommandGroup( - name: "Container", - subcommands: [ - ComposeCommand.self, - ContainerCreate.self, - ContainerDelete.self, - ContainerExec.self, - ContainerInspect.self, - ContainerKill.self, - ContainerList.self, - ContainerLogs.self, - ContainerRunCommand.self, - ContainerStart.self, - ContainerStop.self, - ] - ), - CommandGroup( - name: "Image", - subcommands: [ - BuildCommand.self, - ImagesCommand.self, - RegistryCommand.self, - ] - ), - CommandGroup( - name: "Other", - subcommands: Self.otherCommands() - ), - ], - // Hidden command to handle plugins on unrecognized input. - defaultSubcommand: DefaultCommand.self - ) - - static let appRoot: URL = { - FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first! - .appendingPathComponent("com.apple.container") - }() - - static let pluginLoader: PluginLoader = { - // create user-installed plugins directory if it doesn't exist - let pluginsURL = PluginLoader.userPluginsDir(root: Self.appRoot) - try! FileManager.default.createDirectory(at: pluginsURL, withIntermediateDirectories: true) - let pluginDirectories = [ - pluginsURL - ] - let pluginFactories = [ - DefaultPluginFactory() - ] - - let statePath = PluginLoader.defaultPluginResourcePath(root: Self.appRoot) - try! FileManager.default.createDirectory(at: statePath, withIntermediateDirectories: true) - return PluginLoader(pluginDirectories: pluginDirectories, pluginFactories: pluginFactories, defaultResourcePath: statePath, log: log) - }() - - public static func main() async throws { - restoreCursorAtExit() - - #if DEBUG - let warning = "Running debug build. Performance may be degraded." - let formattedWarning = "\u{001B}[33mWarning!\u{001B}[0m \(warning)\n" - let warningData = Data(formattedWarning.utf8) - FileHandle.standardError.write(warningData) - #endif - - let fullArgs = CommandLine.arguments - let args = Array(fullArgs.dropFirst()) - - do { - // container -> defaultHelpCommand - var command = try Application.parseAsRoot(args) - if var asyncCommand = command as? AsyncParsableCommand { - try await asyncCommand.run() - } else { - try command.run() - } - } catch { - // Regular ol `command` with no args will get caught by DefaultCommand. --help - // on the root command will land here. - let containsHelp = fullArgs.contains("-h") || fullArgs.contains("--help") - if fullArgs.count <= 2 && containsHelp { - Self.printModifiedHelpText() - return - } - let errorAsString: String = String(describing: error) - if errorAsString.contains("XPC connection error") { - let modifiedError = ContainerizationError(.interrupted, message: "\(error)\nEnsure container system service has been started with `container system start`.") - Application.exit(withError: modifiedError) - } else { - Application.exit(withError: error) - } - } - } - - static func handleProcess(io: ProcessIO, process: ClientProcess) async throws -> Int32 { - let signals = AsyncSignalHandler.create(notify: Application.signalSet) - return try await withThrowingTaskGroup(of: Int32?.self, returning: Int32.self) { group in - let waitAdded = group.addTaskUnlessCancelled { - let code = try await process.wait() - try await io.wait() - return code - } - - guard waitAdded else { - group.cancelAll() - return -1 - } - - try await process.start(io.stdio) - defer { - try? io.close() - } - try io.closeAfterStart() - - if let current = io.console { - let size = try current.size - // It's supremely possible the process could've exited already. We shouldn't treat - // this as fatal. - try? await process.resize(size) - _ = group.addTaskUnlessCancelled { - let winchHandler = AsyncSignalHandler.create(notify: [SIGWINCH]) - for await _ in winchHandler.signals { - do { - try await process.resize(try current.size) - } catch { - log.error( - "failed to send terminal resize event", - metadata: [ - "error": "\(error)" - ] - ) - } - } - return nil - } - } else { - _ = group.addTaskUnlessCancelled { - for await sig in signals.signals { - do { - try await process.kill(sig) - } catch { - log.error( - "failed to send signal", - metadata: [ - "signal": "\(sig)", - "error": "\(error)", - ] - ) - } - } - return nil - } - } - - while true { - let result = try await group.next() - if result == nil { - return -1 - } - let status = result! - if let status { - group.cancelAll() - return status - } - } - return -1 - } - } - - public func validate() throws { - // Not really a "validation", but a cheat to run this before - // any of the commands do their business. - let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"] - if self.global.debug || debugEnvVar != nil { - log.logLevel = .debug - } - // Ensure we're not running under Rosetta. - if try isTranslated() { - throw ValidationError( - """ - `container` is currently running under Rosetta Translation, which could be - caused by your terminal application. Please ensure this is turned off. - """ - ) - } - } - - private static func otherCommands() -> [any ParsableCommand.Type] { - guard #available(macOS 26, *) else { - return [ - BuilderCommand.self, - SystemCommand.self, - ] - } - - return [ - BuilderCommand.self, - NetworkCommand.self, - SystemCommand.self, - ] - } - - private static func restoreCursorAtExit() { - let signalHandler: @convention(c) (Int32) -> Void = { signal in - let exitCode = ExitCode(signal + 128) - Application.exit(withError: exitCode) - } - // Termination by Ctrl+C. - signal(SIGINT, signalHandler) - // Termination using `kill`. - signal(SIGTERM, signalHandler) - // Normal and explicit exit. - atexit { - if let progressConfig = try? ProgressConfig() { - let progressBar = ProgressBar(config: progressConfig) - progressBar.resetCursor() - } - } - } -} - -extension Application { - // Because we support plugins, we need to modify the help text to display - // any if we found some. - static func printModifiedHelpText() { - let altered = Self.pluginLoader.alterCLIHelpText( - original: Application.helpMessage(for: Application.self) - ) - print(altered) - } - - enum ListFormat: String, CaseIterable, ExpressibleByArgument { - case json - case table - } - - static let signalSet: [Int32] = [ - SIGTERM, - SIGINT, - SIGUSR1, - SIGUSR2, - SIGWINCH, - ] - - func isTranslated() throws -> Bool { - do { - return try Sysctl.byName("sysctl.proc_translated") == 1 - } catch let posixErr as POSIXError { - if posixErr.code == .ENOENT { - return false - } - throw posixErr - } - } - - private static func releaseVersion() -> String { - var versionDetails: [String: String] = ["build": "release"] - #if DEBUG - versionDetails["build"] = "debug" - #endif - let gitCommit = { - let sha = get_git_commit().map { String(cString: $0) } - guard let sha else { - return "unspecified" - } - return String(sha.prefix(7)) - }() - versionDetails["commit"] = gitCommit - let extras: String = versionDetails.map { "\($0): \($1)" }.sorted().joined(separator: ", ") - - let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) - let releaseVersion = bundleVersion ?? get_release_version().map { String(cString: $0) } ?? "0.0.0" - - return "container CLI version \(releaseVersion) (\(extras))" - } -} diff --git a/Sources/ContainerCLI/BuildCommand.swift b/Sources/ContainerCLI/BuildCommand.swift deleted file mode 100644 index a1e84c258..000000000 --- a/Sources/ContainerCLI/BuildCommand.swift +++ /dev/null @@ -1,313 +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 ContainerBuild -import ContainerClient -import ContainerImagesServiceClient -import Containerization -import ContainerizationError -import ContainerizationOCI -import ContainerizationOS -import Foundation -import NIO -import TerminalProgress - -extension Application { - struct BuildCommand: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "build" - config.abstract = "Build an image from a Dockerfile" - config._superCommandName = "container" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") - public var cpus: Int64 = 2 - - @Option( - name: [.customLong("memory"), .customShort("m")], - help: - "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" - ) - var memory: String = "2048MB" - - @Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val")) - var buildArg: [String] = [] - - @Argument(help: "Build directory") - var contextDir: String = "." - - @Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path")) - var file: String = "Dockerfile" - - @Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val")) - var label: [String] = [] - - @Flag(name: .long, help: "Do not use cache") - var noCache: Bool = false - - @Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build", valueName: "value")) - var output: [String] = { - ["type=oci"] - }() - - @Option(name: .long, help: ArgumentHelp("Cache imports for the build", valueName: "value", visibility: .hidden)) - var cacheIn: [String] = { - [] - }() - - @Option(name: .long, help: ArgumentHelp("Cache exports for the build", valueName: "value", visibility: .hidden)) - var cacheOut: [String] = { - [] - }() - - @Option(name: .long, help: ArgumentHelp("set the build architecture", valueName: "value")) - var arch: [String] = { - ["arm64"] - }() - - @Option(name: .long, help: ArgumentHelp("set the build os", valueName: "value")) - var os: [String] = { - ["linux"] - }() - - @Option(name: .long, help: ArgumentHelp("Progress type - one of [auto|plain|tty]", valueName: "type")) - var progress: String = "auto" - - @Option(name: .long, help: ArgumentHelp("Builder-shim vsock port", valueName: "port")) - var vsockPort: UInt32 = 8088 - - @Option(name: [.customShort("t"), .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) - var targetImageName: String = UUID().uuidString.lowercased() - - @Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage")) - var target: String = "" - - @Flag(name: .shortAndLong, help: "Suppress build output") - var quiet: Bool = false - - func run() async throws { - do { - let timeout: Duration = .seconds(300) - let progressConfig = try ProgressConfig( - showTasks: true, - showItems: true - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - progress.set(description: "Dialing builder") - - let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { group in - defer { - group.cancelAll() - } - - group.addTask { - while true { - do { - let container = try await ClientContainer.get(id: "buildkit") - let fh = try await container.dial(self.vsockPort) - - let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let b = try Builder(socket: fh, group: threadGroup) - - // If this call succeeds, then BuildKit is running. - let _ = try await b.info() - return b - } catch { - // If we get here, "Dialing builder" is shown for such a short period - // of time that it's invisible to the user. - progress.set(tasks: 0) - progress.set(totalTasks: 3) - - try await BuilderStart.start( - cpus: self.cpus, - memory: self.memory, - progressUpdate: progress.handler - ) - - // wait (seconds) for builder to start listening on vsock - try await Task.sleep(for: .seconds(5)) - continue - } - } - } - - group.addTask { - try await Task.sleep(for: timeout) - throw ValidationError( - """ - Timeout waiting for connection to builder - """ - ) - } - - return try await group.next() - } - - guard let builder else { - throw ValidationError("builder is not running") - } - - let dockerfile = try Data(contentsOf: URL(filePath: file)) - let exportPath = Application.appRoot.appendingPathComponent(".build") - - let buildID = UUID().uuidString - let tempURL = exportPath.appendingPathComponent(buildID) - try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil) - defer { - try? FileManager.default.removeItem(at: tempURL) - } - - let imageName: String = try { - let parsedReference = try Reference.parse(targetImageName) - parsedReference.normalize() - return parsedReference.description - }() - - var terminal: Terminal? - switch self.progress { - case "tty": - terminal = try Terminal(descriptor: STDERR_FILENO) - case "auto": - terminal = try? Terminal(descriptor: STDERR_FILENO) - case "plain": - terminal = nil - default: - throw ContainerizationError(.invalidArgument, message: "invalid progress mode \(self.progress)") - } - - defer { terminal?.tryReset() } - - let exports: [Builder.BuildExport] = try output.map { output in - var exp = try Builder.BuildExport(from: output) - if exp.destination == nil { - exp.destination = tempURL.appendingPathComponent("out.tar") - } - return exp - } - - try await withThrowingTaskGroup(of: Void.self) { [terminal] group in - defer { - group.cancelAll() - } - group.addTask { - let handler = AsyncSignalHandler.create(notify: [SIGTERM, SIGINT, SIGUSR1, SIGUSR2]) - for await sig in handler.signals { - throw ContainerizationError(.interrupted, message: "exiting on signal \(sig)") - } - } - let platforms: [Platform] = try { - var results: [Platform] = [] - for o in self.os { - for a in self.arch { - guard let platform = try? Platform(from: "\(o)/\(a)") else { - throw ValidationError("invalid os/architecture combination \(o)/\(a)") - } - results.append(platform) - } - } - return results - }() - group.addTask { [terminal] in - let config = ContainerBuild.Builder.BuildConfig( - buildID: buildID, - contentStore: RemoteContentStoreClient(), - buildArgs: buildArg, - contextDir: contextDir, - dockerfile: dockerfile, - labels: label, - noCache: noCache, - platforms: platforms, - terminal: terminal, - tag: imageName, - target: target, - quiet: quiet, - exports: exports, - cacheIn: cacheIn, - cacheOut: cacheOut - ) - progress.finish() - - try await builder.build(config) - } - - try await group.next() - } - - let unpackProgressConfig = try ProgressConfig( - description: "Unpacking built image", - itemsName: "entries", - showTasks: exports.count > 1, - totalTasks: exports.count - ) - let unpackProgress = ProgressBar(config: unpackProgressConfig) - defer { - unpackProgress.finish() - } - unpackProgress.start() - - let taskManager = ProgressTaskCoordinator() - // Currently, only a single export can be specified. - for exp in exports { - unpackProgress.add(tasks: 1) - let unpackTask = await taskManager.startTask() - switch exp.type { - case "oci": - try Task.checkCancellation() - guard let dest = exp.destination else { - throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)") - } - let loaded = try await ClientImage.load(from: dest.absolutePath()) - - for image in loaded { - try Task.checkCancellation() - try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler)) - } - case "tar": - break - default: - throw ContainerizationError(.invalidArgument, message: "invalid exporter \(exp.rawValue)") - } - } - await taskManager.finish() - unpackProgress.finish() - print("Successfully built \(imageName)") - } catch { - throw NSError(domain: "Build", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(error)"]) - } - } - - func validate() throws { - guard FileManager.default.fileExists(atPath: file) else { - throw ValidationError("Dockerfile does not exist at path: \(file)") - } - guard FileManager.default.fileExists(atPath: contextDir) else { - throw ValidationError("context dir does not exist \(contextDir)") - } - guard let _ = try? Reference.parse(targetImageName) else { - throw ValidationError("invalid reference \(targetImageName)") - } - } - } -} diff --git a/Sources/ContainerCLI/Builder/Builder.swift b/Sources/ContainerCLI/Builder/Builder.swift deleted file mode 100644 index ad9eb6c97..000000000 --- a/Sources/ContainerCLI/Builder/Builder.swift +++ /dev/null @@ -1,31 +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 - -extension Application { - struct BuilderCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "builder", - abstract: "Manage an image builder instance", - subcommands: [ - BuilderStart.self, - BuilderStatus.self, - BuilderStop.self, - BuilderDelete.self, - ]) - } -} diff --git a/Sources/ContainerCLI/Builder/BuilderDelete.swift b/Sources/ContainerCLI/Builder/BuilderDelete.swift deleted file mode 100644 index e848da95e..000000000 --- a/Sources/ContainerCLI/Builder/BuilderDelete.swift +++ /dev/null @@ -1,57 +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 ContainerizationError -import Foundation - -extension Application { - struct BuilderDelete: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "delete" - config._superCommandName = "builder" - config.abstract = "Delete builder" - config.usage = "\n\t builder delete [command options]" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - @Flag(name: .shortAndLong, help: "Force delete builder even if it is running") - var force = false - - func run() async throws { - do { - let container = try await ClientContainer.get(id: "buildkit") - if container.status != .stopped { - guard force else { - throw ContainerizationError(.invalidState, message: "BuildKit container is not stopped, use --force to override") - } - try await container.stop() - } - try await container.delete() - } catch { - if error is ContainerizationError { - if (error as? ContainerizationError)?.code == .notFound { - return - } - } - throw error - } - } - } -} diff --git a/Sources/ContainerCLI/Builder/BuilderStart.swift b/Sources/ContainerCLI/Builder/BuilderStart.swift deleted file mode 100644 index 5800b712e..000000000 --- a/Sources/ContainerCLI/Builder/BuilderStart.swift +++ /dev/null @@ -1,262 +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 ContainerBuild -import ContainerClient -import ContainerNetworkService -import Containerization -import ContainerizationError -import ContainerizationExtras -import ContainerizationOCI -import Foundation -import TerminalProgress - -extension Application { - struct BuilderStart: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "start" - config._superCommandName = "builder" - config.abstract = "Start builder" - config.usage = "\nbuilder start [command options]" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") - public var cpus: Int64 = 2 - - @Option( - name: [.customLong("memory"), .customShort("m")], - help: - "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" - ) - public var memory: String = "2048MB" - - func run() async throws { - let progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - totalTasks: 4 - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - try await Self.start(cpus: self.cpus, memory: self.memory, progressUpdate: progress.handler) - progress.finish() - } - - static func start(cpus: Int64?, memory: String?, progressUpdate: @escaping ProgressUpdateHandler) async throws { - await progressUpdate([ - .setDescription("Fetching BuildKit image"), - .setItemsName("blobs"), - ]) - let taskManager = ProgressTaskCoordinator() - let fetchTask = await taskManager.startTask() - - let builderImage: String = ClientDefaults.get(key: .defaultBuilderImage) - let exportsMount: String = Application.appRoot.appendingPathComponent(".build").absolutePath() - - if !FileManager.default.fileExists(atPath: exportsMount) { - try FileManager.default.createDirectory( - atPath: exportsMount, - withIntermediateDirectories: true, - attributes: nil - ) - } - - let builderPlatform = ContainerizationOCI.Platform(arch: "arm64", os: "linux", variant: "v8") - - let existingContainer = try? await ClientContainer.get(id: "buildkit") - if let existingContainer { - let existingImage = existingContainer.configuration.image.reference - let existingResources = existingContainer.configuration.resources - - // Check if we need to recreate the builder due to different image - let imageChanged = existingImage != builderImage - let cpuChanged = { - if let cpus { - if existingResources.cpus != cpus { - return true - } - } - return false - }() - let memChanged = try { - if let memory { - let memoryInBytes = try Parser.resources(cpus: nil, memory: memory).memoryInBytes - if existingResources.memoryInBytes != memoryInBytes { - return true - } - } - return false - }() - - switch existingContainer.status { - case .running: - guard imageChanged || cpuChanged || memChanged else { - // If image, mem and cpu are the same, continue using the existing builder - return - } - // If they changed, stop and delete the existing builder - try await existingContainer.stop() - try await existingContainer.delete() - case .stopped: - // If the builder is stopped and matches our requirements, start it - // Otherwise, delete it and create a new one - guard imageChanged || cpuChanged || memChanged else { - try await existingContainer.startBuildKit(progressUpdate, nil) - return - } - try await existingContainer.delete() - case .stopping: - throw ContainerizationError( - .invalidState, - message: "builder is stopping, please wait until it is fully stopped before proceeding" - ) - case .unknown: - break - } - } - - let shimArguments: [String] = [ - "--debug", - "--vsock", - ] - - let id = "buildkit" - try ContainerClient.Utility.validEntityName(id) - - let processConfig = ProcessConfiguration( - executable: "/usr/local/bin/container-builder-shim", - arguments: shimArguments, - environment: [], - workingDirectory: "/", - terminal: false, - user: .id(uid: 0, gid: 0) - ) - - let resources = try Parser.resources( - cpus: cpus, - memory: memory - ) - - let image = try await ClientImage.fetch( - reference: builderImage, - platform: builderPlatform, - progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate) - ) - // Unpack fetched image before use - await progressUpdate([ - .setDescription("Unpacking BuildKit image"), - .setItemsName("entries"), - ]) - - let unpackTask = await taskManager.startTask() - _ = try await image.getCreateSnapshot( - platform: builderPlatform, - progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate) - ) - let imageConfig = ImageDescription( - reference: builderImage, - descriptor: image.descriptor - ) - - var config = ContainerConfiguration(id: id, image: imageConfig, process: processConfig) - config.resources = resources - config.mounts = [ - .init( - type: .tmpfs, - source: "", - destination: "/run", - options: [] - ), - .init( - type: .virtiofs, - source: exportsMount, - destination: "/var/lib/container-builder-shim/exports", - options: [] - ), - ] - // Enable Rosetta only if the user didn't ask to disable it - config.rosetta = ClientDefaults.getBool(key: .buildRosetta) ?? true - - let network = try await ClientNetwork.get(id: ClientNetwork.defaultNetworkName) - guard case .running(_, let networkStatus) = network else { - throw ContainerizationError(.invalidState, message: "default network is not running") - } - config.networks = [network.id] - let subnet = try CIDRAddress(networkStatus.address) - let nameserver = IPv4Address(fromValue: subnet.lower.value + 1).description - let nameservers = [nameserver] - config.dns = ContainerConfiguration.DNSConfiguration(nameservers: nameservers) - - let kernel = try await { - await progressUpdate([ - .setDescription("Fetching kernel"), - .setItemsName("binary"), - ]) - - let kernel = try await ClientKernel.getDefaultKernel(for: .current) - return kernel - }() - - await progressUpdate([ - .setDescription("Starting BuildKit container") - ]) - - let container = try await ClientContainer.create( - configuration: config, - options: .default, - kernel: kernel - ) - - try await container.startBuildKit(progressUpdate, taskManager) - } - } -} - -// MARK: - ClientContainer Extension for BuildKit - -extension ClientContainer { - /// Starts the BuildKit process within the container - /// This method handles bootstrapping the container and starting the BuildKit process - fileprivate func startBuildKit(_ progress: @escaping ProgressUpdateHandler, _ taskManager: ProgressTaskCoordinator? = nil) async throws { - do { - let io = try ProcessIO.create( - tty: false, - interactive: false, - detach: true - ) - defer { try? io.close() } - let process = try await bootstrap() - _ = try await process.start(io.stdio) - await taskManager?.finish() - try io.closeAfterStart() - log.debug("starting BuildKit and BuildKit-shim") - } catch { - try? await stop() - try? await delete() - if error is ContainerizationError { - throw error - } - throw ContainerizationError(.internalError, message: "failed to start BuildKit: \(error)") - } - } -} diff --git a/Sources/ContainerCLI/Builder/BuilderStatus.swift b/Sources/ContainerCLI/Builder/BuilderStatus.swift deleted file mode 100644 index b1210a3dd..000000000 --- a/Sources/ContainerCLI/Builder/BuilderStatus.swift +++ /dev/null @@ -1,71 +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 ContainerizationError -import Foundation - -extension Application { - struct BuilderStatus: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "status" - config._superCommandName = "builder" - config.abstract = "Print builder status" - config.usage = "\n\t builder status [command options]" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - @Flag(name: .long, help: ArgumentHelp("Display detailed status in json format")) - var json: Bool = false - - func run() async throws { - do { - let container = try await ClientContainer.get(id: "buildkit") - if json { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let jsonData = try encoder.encode(container) - - guard let jsonString = String(data: jsonData, encoding: .utf8) else { - throw ContainerizationError(.internalError, message: "failed to encode BuildKit container as json") - } - print(jsonString) - return - } - - let image = container.configuration.image.reference - let resources = container.configuration.resources - let cpus = resources.cpus - let memory = resources.memoryInBytes / (1024 * 1024) // bytes to MB - let addr = "" - - print("ID IMAGE STATE ADDR CPUS MEMORY") - print("\(container.id) \(image) \(container.status.rawValue.uppercased()) \(addr) \(cpus) \(memory) MB") - } catch { - if error is ContainerizationError { - if (error as? ContainerizationError)?.code == .notFound { - print("builder is not running") - return - } - } - throw error - } - } - } -} diff --git a/Sources/ContainerCLI/Builder/BuilderStop.swift b/Sources/ContainerCLI/Builder/BuilderStop.swift deleted file mode 100644 index e7484c9c1..000000000 --- a/Sources/ContainerCLI/Builder/BuilderStop.swift +++ /dev/null @@ -1,49 +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 ContainerizationError -import Foundation - -extension Application { - struct BuilderStop: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "stop" - config._superCommandName = "builder" - config.abstract = "Stop builder" - config.usage = "\n\t builder stop" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - func run() async throws { - do { - let container = try await ClientContainer.get(id: "buildkit") - try await container.stop() - } catch { - if error is ContainerizationError { - if (error as? ContainerizationError)?.code == .notFound { - print("builder is not running") - return - } - } - throw error - } - } - } -} diff --git a/Sources/ContainerCLI/Codable+JSON.swift b/Sources/ContainerCLI/Codable+JSON.swift deleted file mode 100644 index 60cbd04d7..000000000 --- a/Sources/ContainerCLI/Codable+JSON.swift +++ /dev/null @@ -1,25 +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 Foundation - -extension [any Codable] { - func jsonArray() throws -> String { - "[\(try self.map { String(data: try JSONEncoder().encode($0), encoding: .utf8)! }.joined(separator: ","))]" - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Build.swift b/Sources/ContainerCLI/Compose/Codable Structs/Build.swift deleted file mode 100644 index 5dc9a7ffa..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Build.swift +++ /dev/null @@ -1,52 +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. -//===----------------------------------------------------------------------===// - -// -// Build.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents the `build` configuration for a service. -struct Build: Codable, Hashable { - /// Path to the build context - let context: String - /// Optional path to the Dockerfile within the context - let dockerfile: String? - /// Build arguments - let args: [String: String]? - - /// Custom initializer to handle `build: .` (string) or `build: { context: . }` (object) - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let contextString = try? container.decode(String.self) { - self.context = contextString - self.dockerfile = nil - self.args = nil - } else { - let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) - self.context = try keyedContainer.decode(String.self, forKey: .context) - self.dockerfile = try keyedContainer.decodeIfPresent(String.self, forKey: .dockerfile) - self.args = try keyedContainer.decodeIfPresent([String: String].self, forKey: .args) - } - } - - enum CodingKeys: String, CodingKey { - case context, dockerfile, args - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Config.swift b/Sources/ContainerCLI/Compose/Codable Structs/Config.swift deleted file mode 100644 index 6b982bfdb..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Config.swift +++ /dev/null @@ -1,55 +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. -//===----------------------------------------------------------------------===// - -// -// Config.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a top-level config definition (primarily for Swarm). -struct Config: Codable { - /// Path to the file containing the config content - let file: String? - /// Indicates if the config is external (pre-existing) - let external: ExternalConfig? - /// Explicit name for the config - let name: String? - /// Labels for the config - let labels: [String: String]? - - enum CodingKeys: String, CodingKey { - case file, external, name, labels - } - - /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_cfg" }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - file = try container.decodeIfPresent(String.self, forKey: .file) - name = try container.decodeIfPresent(String.self, forKey: .name) - labels = try container.decodeIfPresent([String: String].self, forKey: .labels) - - if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { - external = ExternalConfig(isExternal: externalBool, name: nil) - } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { - external = ExternalConfig(isExternal: true, name: externalDict["name"]) - } else { - external = nil - } - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift b/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift deleted file mode 100644 index d30f9ffa8..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift +++ /dev/null @@ -1,35 +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. -//===----------------------------------------------------------------------===// - -// -// Deploy.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents the `deploy` configuration for a service (primarily for Swarm orchestration). -struct Deploy: Codable, Hashable { - /// Deployment mode (e.g., 'replicated', 'global') - let mode: String? - /// Number of replicated service tasks - let replicas: Int? - /// Resource constraints (limits, reservations) - let resources: DeployResources? - /// Restart policy for tasks - let restart_policy: DeployRestartPolicy? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift deleted file mode 100644 index 370e61a46..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// DeployResources.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Resource constraints for deployment. -struct DeployResources: Codable, Hashable { - /// Hard limits on resources - let limits: ResourceLimits? - /// Guarantees for resources - let reservations: ResourceReservations? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift deleted file mode 100644 index 56daa6573..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift +++ /dev/null @@ -1,35 +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. -//===----------------------------------------------------------------------===// - -// -// DeployRestartPolicy.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Restart policy for deployed tasks. -struct DeployRestartPolicy: Codable, Hashable { - /// Condition to restart on (e.g., 'on-failure', 'any') - let condition: String? - /// Delay before attempting restart - let delay: String? - /// Maximum number of restart attempts - let max_attempts: Int? - /// Window to evaluate restart policy - let window: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift deleted file mode 100644 index 47a58acad..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift +++ /dev/null @@ -1,35 +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. -//===----------------------------------------------------------------------===// - -// -// DeviceReservation.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Device reservations for GPUs or other devices. -struct DeviceReservation: Codable, Hashable { - /// Device capabilities - let capabilities: [String]? - /// Device driver - let driver: String? - /// Number of devices - let count: String? - /// Specific device IDs - let device_ids: [String]? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift b/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift deleted file mode 100644 index 503d98664..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift +++ /dev/null @@ -1,60 +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. -//===----------------------------------------------------------------------===// - -// -// DockerCompose.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents the top-level structure of a docker-compose.yml file. -struct DockerCompose: Codable { - /// The Compose file format version (e.g., '3.8') - let version: String? - /// Optional project name - let name: String? - /// Dictionary of service definitions, keyed by service name - let services: [String: Service] - /// Optional top-level volume definitions - let volumes: [String: Volume]? - /// Optional top-level network definitions - let networks: [String: Network]? - /// Optional top-level config definitions (primarily for Swarm) - let configs: [String: Config]? - /// Optional top-level secret definitions (primarily for Swarm) - let secrets: [String: Secret]? - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - version = try container.decodeIfPresent(String.self, forKey: .version) - name = try container.decodeIfPresent(String.self, forKey: .name) - services = try container.decode([String: Service].self, forKey: .services) - - if let volumes = try container.decodeIfPresent([String: Optional].self, forKey: .volumes) { - let safeVolumes: [String : Volume] = volumes.mapValues { value in - value ?? Volume() - } - self.volumes = safeVolumes - } else { - self.volumes = nil - } - networks = try container.decodeIfPresent([String: Network].self, forKey: .networks) - configs = try container.decodeIfPresent([String: Config].self, forKey: .configs) - secrets = try container.decodeIfPresent([String: Secret].self, forKey: .secrets) - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift deleted file mode 100644 index d05ccd461..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ExternalConfig.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents an external config reference. -struct ExternalConfig: Codable { - /// True if the config is external - let isExternal: Bool - /// Optional name of the external config if different from key - let name: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift deleted file mode 100644 index 07d6c8ce9..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ExternalNetwork.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents an external network reference. -struct ExternalNetwork: Codable { - /// True if the network is external - let isExternal: Bool - // Optional name of the external network if different from key - let name: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift deleted file mode 100644 index ce4411362..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ExternalSecret.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents an external secret reference. -struct ExternalSecret: Codable { - /// True if the secret is external - let isExternal: Bool - /// Optional name of the external secret if different from key - let name: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift deleted file mode 100644 index 04cfe4f92..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ExternalVolume.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents an external volume reference. -struct ExternalVolume: Codable { - /// True if the volume is external - let isExternal: Bool - /// Optional name of the external volume if different from key - let name: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift b/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift deleted file mode 100644 index 27f5aa912..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift +++ /dev/null @@ -1,37 +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. -//===----------------------------------------------------------------------===// - -// -// Healthcheck.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Healthcheck configuration for a service. -struct Healthcheck: Codable, Hashable { - /// Command to run to check health - let test: [String]? - /// Grace period for the container to start - let start_period: String? - /// How often to run the check - let interval: String? - /// Number of consecutive failures to consider unhealthy - let retries: Int? - /// Timeout for each check - let timeout: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Network.swift b/Sources/ContainerCLI/Compose/Codable Structs/Network.swift deleted file mode 100644 index 44752aecc..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Network.swift +++ /dev/null @@ -1,68 +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. -//===----------------------------------------------------------------------===// - -// -// Network.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a top-level network definition. -struct Network: Codable { - /// Network driver (e.g., 'bridge', 'overlay') - let driver: String? - /// Driver-specific options - let driver_opts: [String: String]? - /// Allow standalone containers to attach to this network - let attachable: Bool? - /// Enable IPv6 networking - let enable_ipv6: Bool? - /// RENAMED: from `internal` to `isInternal` to avoid keyword clash - let isInternal: Bool? - /// Labels for the network - let labels: [String: String]? - /// Explicit name for the network - let name: String? - /// Indicates if the network is external (pre-existing) - let external: ExternalNetwork? - - /// Updated CodingKeys to map 'internal' from YAML to 'isInternal' Swift property - enum CodingKeys: String, CodingKey { - case driver, driver_opts, attachable, enable_ipv6, isInternal = "internal", labels, name, external - } - - /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_net" }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - driver = try container.decodeIfPresent(String.self, forKey: .driver) - driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts) - attachable = try container.decodeIfPresent(Bool.self, forKey: .attachable) - enable_ipv6 = try container.decodeIfPresent(Bool.self, forKey: .enable_ipv6) - isInternal = try container.decodeIfPresent(Bool.self, forKey: .isInternal) // Use isInternal here - labels = try container.decodeIfPresent([String: String].self, forKey: .labels) - name = try container.decodeIfPresent(String.self, forKey: .name) - - if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { - external = ExternalNetwork(isExternal: externalBool, name: nil) - } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { - external = ExternalNetwork(isExternal: true, name: externalDict["name"]) - } else { - external = nil - } - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift b/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift deleted file mode 100644 index 4643d961b..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ResourceLimits.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// CPU and memory limits. -struct ResourceLimits: Codable, Hashable { - /// CPU limit (e.g., "0.5") - let cpus: String? - /// Memory limit (e.g., "512M") - let memory: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift b/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift deleted file mode 100644 index 26052e6b3..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift +++ /dev/null @@ -1,34 +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. -//===----------------------------------------------------------------------===// - -// -// ResourceReservations.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// **FIXED**: Renamed from `ResourceReservables` to `ResourceReservations` and made `Codable`. -/// CPU and memory reservations. -struct ResourceReservations: Codable, Hashable { - /// CPU reservation (e.g., "0.25") - let cpus: String? - /// Memory reservation (e.g., "256M") - let memory: String? - /// Device reservations for GPUs or other devices - let devices: [DeviceReservation]? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift b/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift deleted file mode 100644 index ff464c671..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift +++ /dev/null @@ -1,58 +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. -//===----------------------------------------------------------------------===// - -// -// Secret.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a top-level secret definition (primarily for Swarm). -struct Secret: Codable { - /// Path to the file containing the secret content - let file: String? - /// Environment variable to populate with the secret content - let environment: String? - /// Indicates if the secret is external (pre-existing) - let external: ExternalSecret? - /// Explicit name for the secret - let name: String? - /// Labels for the secret - let labels: [String: String]? - - enum CodingKeys: String, CodingKey { - case file, environment, external, name, labels - } - - /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_sec" }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - file = try container.decodeIfPresent(String.self, forKey: .file) - environment = try container.decodeIfPresent(String.self, forKey: .environment) - name = try container.decodeIfPresent(String.self, forKey: .name) - labels = try container.decodeIfPresent([String: String].self, forKey: .labels) - - if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { - external = ExternalSecret(isExternal: externalBool, name: nil) - } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { - external = ExternalSecret(isExternal: true, name: externalDict["name"]) - } else { - external = nil - } - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Service.swift b/Sources/ContainerCLI/Compose/Codable Structs/Service.swift deleted file mode 100644 index 1c5aeb528..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Service.swift +++ /dev/null @@ -1,203 +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. -//===----------------------------------------------------------------------===// - -// -// Service.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - -import Foundation - - -/// Represents a single service definition within the `services` section. -struct Service: Codable, Hashable { - /// Docker image name - let image: String? - - /// Build configuration if the service is built from a Dockerfile - let build: Build? - - /// Deployment configuration (primarily for Swarm) - let deploy: Deploy? - - /// Restart policy (e.g., 'unless-stopped', 'always') - let restart: String? - - /// Healthcheck configuration - let healthcheck: Healthcheck? - - /// List of volume mounts (e.g., "hostPath:containerPath", "namedVolume:/path") - let volumes: [String]? - - /// Environment variables to set in the container - let environment: [String: String]? - - /// List of .env files to load environment variables from - let env_file: [String]? - - /// Port mappings (e.g., "hostPort:containerPort") - let ports: [String]? - - /// Command to execute in the container, overriding the image's default - let command: [String]? - - /// Services this service depends on (for startup order) - let depends_on: [String]? - - /// User or UID to run the container as - let user: String? - - /// Explicit name for the container instance - let container_name: String? - - /// List of networks the service will connect to - let networks: [String]? - - /// Container hostname - let hostname: String? - - /// Entrypoint to execute in the container, overriding the image's default - let entrypoint: [String]? - - /// Run container in privileged mode - let privileged: Bool? - - /// Mount container's root filesystem as read-only - let read_only: Bool? - - /// Working directory inside the container - let working_dir: String? - - /// Platform architecture for the service - let platform: String? - - /// Service-specific config usage (primarily for Swarm) - let configs: [ServiceConfig]? - - /// Service-specific secret usage (primarily for Swarm) - let secrets: [ServiceSecret]? - - /// Keep STDIN open (-i flag for `container run`) - let stdin_open: Bool? - - /// Allocate a pseudo-TTY (-t flag for `container run`) - let tty: Bool? - - /// Other services that depend on this service - var dependedBy: [String] = [] - - // Defines custom coding keys to map YAML keys to Swift properties - enum CodingKeys: String, CodingKey { - case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, - container_name, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform - } - - /// Custom initializer to handle decoding and basic validation. - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - image = try container.decodeIfPresent(String.self, forKey: .image) - build = try container.decodeIfPresent(Build.self, forKey: .build) - deploy = try container.decodeIfPresent(Deploy.self, forKey: .deploy) - - // Ensure that a service has either an image or a build context. - guard image != nil || build != nil else { - throw DecodingError.dataCorruptedError(forKey: .image, in: container, debugDescription: "Service must have either 'image' or 'build' specified.") - } - - restart = try container.decodeIfPresent(String.self, forKey: .restart) - healthcheck = try container.decodeIfPresent(Healthcheck.self, forKey: .healthcheck) - volumes = try container.decodeIfPresent([String].self, forKey: .volumes) - environment = try container.decodeIfPresent([String: String].self, forKey: .environment) - env_file = try container.decodeIfPresent([String].self, forKey: .env_file) - ports = try container.decodeIfPresent([String].self, forKey: .ports) - - // Decode 'command' which can be either a single string or an array of strings. - if let cmdArray = try? container.decodeIfPresent([String].self, forKey: .command) { - command = cmdArray - } else if let cmdString = try? container.decodeIfPresent(String.self, forKey: .command) { - command = [cmdString] - } else { - command = nil - } - - depends_on = try container.decodeIfPresent([String].self, forKey: .depends_on) - user = try container.decodeIfPresent(String.self, forKey: .user) - - container_name = try container.decodeIfPresent(String.self, forKey: .container_name) - networks = try container.decodeIfPresent([String].self, forKey: .networks) - hostname = try container.decodeIfPresent(String.self, forKey: .hostname) - - // Decode 'entrypoint' which can be either a single string or an array of strings. - if let entrypointArray = try? container.decodeIfPresent([String].self, forKey: .entrypoint) { - entrypoint = entrypointArray - } else if let entrypointString = try? container.decodeIfPresent(String.self, forKey: .entrypoint) { - entrypoint = [entrypointString] - } else { - entrypoint = nil - } - - privileged = try container.decodeIfPresent(Bool.self, forKey: .privileged) - read_only = try container.decodeIfPresent(Bool.self, forKey: .read_only) - working_dir = try container.decodeIfPresent(String.self, forKey: .working_dir) - configs = try container.decodeIfPresent([ServiceConfig].self, forKey: .configs) - secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets) - stdin_open = try container.decodeIfPresent(Bool.self, forKey: .stdin_open) - tty = try container.decodeIfPresent(Bool.self, forKey: .tty) - platform = try container.decodeIfPresent(String.self, forKey: .platform) - } - - /// Returns the services in topological order based on `depends_on` relationships. - static func topoSortConfiguredServices( - _ services: [(serviceName: String, service: Service)] - ) throws -> [(serviceName: String, service: Service)] { - - var visited = Set() - var visiting = Set() - var sorted: [(String, Service)] = [] - - func visit(_ name: String, from service: String? = nil) throws { - guard var serviceTuple = services.first(where: { $0.serviceName == name }) else { return } - if let service { - serviceTuple.service.dependedBy.append(service) - } - - if visiting.contains(name) { - throw NSError(domain: "ComposeError", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" - ]) - } - guard !visited.contains(name) else { return } - - visiting.insert(name) - for depName in serviceTuple.service.depends_on ?? [] { - try visit(depName, from: name) - } - visiting.remove(name) - visited.insert(name) - sorted.append(serviceTuple) - } - - for (serviceName, _) in services { - if !visited.contains(serviceName) { - try visit(serviceName) - } - } - - return sorted - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift b/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift deleted file mode 100644 index 712d42b7b..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift +++ /dev/null @@ -1,64 +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. -//===----------------------------------------------------------------------===// - -// -// ServiceConfig.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a service's usage of a config. -struct ServiceConfig: Codable, Hashable { - /// Name of the config being used - let source: String - - /// Path in the container where the config will be mounted - let target: String? - - /// User ID for the mounted config file - let uid: String? - - /// Group ID for the mounted config file - let gid: String? - - /// Permissions mode for the mounted config file - let mode: Int? - - /// Custom initializer to handle `config_name` (string) or `{ source: config_name, target: /path }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let sourceName = try? container.decode(String.self) { - self.source = sourceName - self.target = nil - self.uid = nil - self.gid = nil - self.mode = nil - } else { - let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) - self.source = try keyedContainer.decode(String.self, forKey: .source) - self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) - self.uid = try keyedContainer.decodeIfPresent(String.self, forKey: .uid) - self.gid = try keyedContainer.decodeIfPresent(String.self, forKey: .gid) - self.mode = try keyedContainer.decodeIfPresent(Int.self, forKey: .mode) - } - } - - enum CodingKeys: String, CodingKey { - case source, target, uid, gid, mode - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift b/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift deleted file mode 100644 index 1849c495c..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift +++ /dev/null @@ -1,64 +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. -//===----------------------------------------------------------------------===// - -// -// ServiceSecret.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a service's usage of a secret. -struct ServiceSecret: Codable, Hashable { - /// Name of the secret being used - let source: String - - /// Path in the container where the secret will be mounted - let target: String? - - /// User ID for the mounted secret file - let uid: String? - - /// Group ID for the mounted secret file - let gid: String? - - /// Permissions mode for the mounted secret file - let mode: Int? - - /// Custom initializer to handle `secret_name` (string) or `{ source: secret_name, target: /path }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let sourceName = try? container.decode(String.self) { - self.source = sourceName - self.target = nil - self.uid = nil - self.gid = nil - self.mode = nil - } else { - let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) - self.source = try keyedContainer.decode(String.self, forKey: .source) - self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) - self.uid = try keyedContainer.decodeIfPresent(String.self, forKey: .uid) - self.gid = try keyedContainer.decodeIfPresent(String.self, forKey: .gid) - self.mode = try keyedContainer.decodeIfPresent(Int.self, forKey: .mode) - } - } - - enum CodingKeys: String, CodingKey { - case source, target, uid, gid, mode - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift b/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift deleted file mode 100644 index b43a1cca5..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift +++ /dev/null @@ -1,70 +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. -//===----------------------------------------------------------------------===// - -// -// Volume.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a top-level volume definition. -struct Volume: Codable { - /// Volume driver (e.g., 'local') - let driver: String? - - /// Driver-specific options - let driver_opts: [String: String]? - - /// Explicit name for the volume - let name: String? - - /// Labels for the volume - let labels: [String: String]? - - /// Indicates if the volume is external (pre-existing) - let external: ExternalVolume? - - enum CodingKeys: String, CodingKey { - case driver, driver_opts, name, labels, external - } - - /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_vol" }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - driver = try container.decodeIfPresent(String.self, forKey: .driver) - driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts) - name = try container.decodeIfPresent(String.self, forKey: .name) - labels = try container.decodeIfPresent([String: String].self, forKey: .labels) - - if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { - external = ExternalVolume(isExternal: externalBool, name: nil) - } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { - external = ExternalVolume(isExternal: true, name: externalDict["name"]) - } else { - external = nil - } - } - - init(driver: String? = nil, driver_opts: [String : String]? = nil, name: String? = nil, labels: [String : String]? = nil, external: ExternalVolume? = nil) { - self.driver = driver - self.driver_opts = driver_opts - self.name = name - self.labels = labels - self.external = external - } -} diff --git a/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift b/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift deleted file mode 100644 index 8993f8ddb..000000000 --- a/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift +++ /dev/null @@ -1,105 +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. -//===----------------------------------------------------------------------===// - -// -// ComposeDown.swift -// Container-Compose -// -// Created by Morris Richman on 6/19/25. -// - -import ArgumentParser -import ContainerClient -import Foundation -import Yams - -extension Application { - public struct ComposeDown: AsyncParsableCommand { - public init() {} - - public static let configuration: CommandConfiguration = .init( - commandName: "down", - abstract: "Stop containers with compose" - ) - - @Argument(help: "Specify the services to stop") - var services: [String] = [] - - @OptionGroup - var process: Flags.Process - - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - - var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml - - private var fileManager: FileManager { FileManager.default } - private var projectName: String? - - public mutating func run() async throws { - // Read docker-compose.yml content - guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { - throw YamlError.dockerfileNotFound(dockerComposePath) - } - - // Decode the YAML file into the DockerCompose struct - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - - // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") - } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") - } - - var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) - services = try Service.topoSortConfiguredServices(services) - - // Filter for specified services - if !self.services.isEmpty { - services = services.filter({ serviceName, service in - self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) - }) - } - - try await stopOldStuff(services.map({ $0.serviceName }), remove: false) - } - - private func stopOldStuff(_ services: [String], remove: Bool) async throws { - guard let projectName else { return } - let containers = services.map { "\(projectName)-\($0)" } - - for container in containers { - print("Stopping container: \(container)") - guard let container = try? await ClientContainer.get(id: container) else { continue } - - do { - try await container.stop() - } catch { - } - if remove { - do { - try await container.delete() - } catch { - } - } - } - } - } -} diff --git a/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift b/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift deleted file mode 100644 index 6b1053670..000000000 --- a/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift +++ /dev/null @@ -1,749 +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. -//===----------------------------------------------------------------------===// - -// -// ComposeUp.swift -// Container-Compose -// -// Created by Morris Richman on 6/19/25. -// - -import ArgumentParser -import ContainerClient -import Foundation -@preconcurrency import Rainbow -import Yams -import ContainerizationExtras - -extension Application { - public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { - public init() {} - - public static let configuration: CommandConfiguration = .init( - commandName: "up", - abstract: "Start containers with compose" - ) - - @Argument(help: "Specify the services to start") - var services: [String] = [] - - @Flag( - name: [.customShort("d"), .customLong("detach")], - help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") - var detatch: Bool = false - - @Flag(name: [.customShort("b"), .customLong("build")]) - var rebuild: Bool = false - - @Flag(name: .long, help: "Do not use cache") - var noCache: Bool = false - - @OptionGroup - var process: Flags.Process - - @OptionGroup - var global: Flags.Global - - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml - var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file - // - private var fileManager: FileManager { FileManager.default } - private var projectName: String? - private var environmentVariables: [String: String] = [:] - private var containerIps: [String: String] = [:] - private var containerConsoleColors: [String: NamedColor] = [:] - - private static let availableContainerConsoleColors: Set = [ - .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, - ] - - public mutating func run() async throws { - // Read docker-compose.yml content - guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { - throw YamlError.dockerfileNotFound(dockerComposePath) - } - - // Decode the YAML file into the DockerCompose struct - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - - // Load environment variables from .env file - environmentVariables = loadEnvFile(path: envFilePath) - - // Handle 'version' field - if let version = dockerCompose.version { - print("Info: Docker Compose file version parsed as: \(version)") - print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") - } - - // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") - } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") - } - - // Get Services to use - var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) - services = try Service.topoSortConfiguredServices(services) - - // Filter for specified services - if !self.services.isEmpty { - services = services.filter({ serviceName, service in - self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) - }) - } - - // Stop Services - try await stopOldStuff(services.map({ $0.serviceName }), remove: true) - - // Process top-level networks - // This creates named networks defined in the docker-compose.yml - if let networks = dockerCompose.networks { - print("\n--- Processing Networks ---") - for (networkName, networkConfig) in networks { - try await setupNetwork(name: networkName, config: networkConfig) - } - print("--- Networks Processed ---\n") - } - - // Process top-level volumes - // This creates named volumes defined in the docker-compose.yml - if let volumes = dockerCompose.volumes { - print("\n--- Processing Volumes ---") - for (volumeName, volumeConfig) in volumes { - await createVolumeHardLink(name: volumeName, config: volumeConfig) - } - print("--- Volumes Processed ---\n") - } - - // Process each service defined in the docker-compose.yml - print("\n--- Processing Services ---") - - print(services.map(\.serviceName)) - for (serviceName, service) in services { - try await configService(service, serviceName: serviceName, from: dockerCompose) - } - - if !detatch { - await waitForever() - } - } - - func waitForever() async -> Never { - for await _ in AsyncStream(unfolding: {}) { - // This will never run - } - fatalError("unreachable") - } - - private func getIPForRunningService(_ serviceName: String) async throws -> String? { - guard let projectName else { return nil } - - let containerName = "\(projectName)-\(serviceName)" - - let container = try await ClientContainer.get(id: containerName) - let ip = container.networks.compactMap { try? CIDRAddress($0.address).address.description }.first - - return ip - } - - /// Repeatedly checks `container list -a` until the given container is listed as `running`. - /// - Parameters: - /// - containerName: The exact name of the container (e.g. "Assignment-Manager-API-db"). - /// - timeout: Max seconds to wait before failing. - /// - interval: How often to poll (in seconds). - /// - Returns: `true` if the container reached "running" state within the timeout. - private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { - guard let projectName else { return } - let containerName = "\(projectName)-\(serviceName)" - - let deadline = Date().addingTimeInterval(timeout) - - while Date() < deadline { - let container = try? await ClientContainer.get(id: containerName) - if container?.status == .running { - return - } - - try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - } - - throw NSError( - domain: "ContainerWait", code: 1, - userInfo: [ - NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." - ]) - } - - private func stopOldStuff(_ services: [String], remove: Bool) async throws { - guard let projectName else { return } - let containers = services.map { "\(projectName)-\($0)" } - - for container in containers { - print("Stopping container: \(container)") - guard let container = try? await ClientContainer.get(id: container) else { continue } - - do { - try await container.stop() - } catch { - } - if remove { - do { - try await container.delete() - } catch { - } - } - } - } - - // MARK: Compose Top Level Functions - - private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { - let ip = try await getIPForRunningService(serviceName) - self.containerIps[serviceName] = ip - for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { - self.environmentVariables[key] = ip ?? value - } - } - - private func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { - guard let projectName else { return } - let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name - - let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") - let volumePath = volumeUrl.path(percentEncoded: false) - - print( - "Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." - ) - try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) - } - - private func setupNetwork(name networkName: String, config networkConfig: Network) async throws { - let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name - - if let externalNetwork = networkConfig.external, externalNetwork.isExternal { - print("Info: Network '\(networkName)' is declared as external.") - print("This tool assumes external network '\(externalNetwork.name ?? actualNetworkName)' already exists and will not attempt to create it.") - } else { - var networkCreateArgs: [String] = ["network", "create"] - - #warning("Docker Compose Network Options Not Supported") - // Add driver and driver options - if let driver = networkConfig.driver, !driver.isEmpty { -// networkCreateArgs.append("--driver") -// networkCreateArgs.append(driver) - print("Network Driver Detected, But Not Supported") - } - if let driverOpts = networkConfig.driver_opts, !driverOpts.isEmpty { -// for (optKey, optValue) in driverOpts { -// networkCreateArgs.append("--opt") -// networkCreateArgs.append("\(optKey)=\(optValue)") -// } - print("Network Options Detected, But Not Supported") - } - // Add various network flags - if networkConfig.attachable == true { -// networkCreateArgs.append("--attachable") - print("Network Attachable Flag Detected, But Not Supported") - } - if networkConfig.enable_ipv6 == true { -// networkCreateArgs.append("--ipv6") - print("Network IPv6 Flag Detected, But Not Supported") - } - if networkConfig.isInternal == true { -// networkCreateArgs.append("--internal") - print("Network Internal Flag Detected, But Not Supported") - } // CORRECTED: Use isInternal - - // Add labels - if let labels = networkConfig.labels, !labels.isEmpty { - print("Network Labels Detected, But Not Supported") -// for (labelKey, labelValue) in labels { -// networkCreateArgs.append("--label") -// networkCreateArgs.append("\(labelKey)=\(labelValue)") -// } - } - - print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") - print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") - guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else { - print("Network '\(networkName)' already exists") - return - } - var networkCreate = NetworkCreate() - networkCreate.global = global - networkCreate.name = actualNetworkName - - try await networkCreate.run() - print("Network '\(networkName)' created") - } - } - - // MARK: Compose Service Level Functions - private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { - guard let projectName else { throw ComposeError.invalidProjectName } - - var imageToRun: String - - // Handle 'build' configuration - if let buildConfig = service.build { - imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) - } else if let img = service.image { - // Use specified image if no build config - // Pull image if necessary - try await pullImage(img, platform: service.container_name) - imageToRun = img - } else { - // Should not happen due to Service init validation, but as a fallback - throw ComposeError.imageNotFound(serviceName) - } - - // Handle 'deploy' configuration (note that this tool doesn't fully support it) - if service.deploy != nil { - print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") - print( - "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." - ) - print("The service will be run as a single container based on other configurations.") - } - - var runCommandArgs: [String] = [] - - // Add detach flag if specified on the CLI - if detatch { - runCommandArgs.append("-d") - } - - // Determine container name - let containerName: String - if let explicitContainerName = service.container_name { - containerName = explicitContainerName - print("Info: Using explicit container_name: \(containerName)") - } else { - // Default container name based on project and service name - containerName = "\(projectName)-\(serviceName)" - } - runCommandArgs.append("--name") - runCommandArgs.append(containerName) - - // REMOVED: Restart policy is not supported by `container run` - // if let restart = service.restart { - // runCommandArgs.append("--restart") - // runCommandArgs.append(restart) - // } - - // Add user - if let user = service.user { - runCommandArgs.append("--user") - runCommandArgs.append(user) - } - - // Add volume mounts - if let volumes = service.volumes { - for volume in volumes { - let args = try await configVolume(volume) - runCommandArgs.append(contentsOf: args) - } - } - - // Combine environment variables from .env files and service environment - var combinedEnv: [String: String] = environmentVariables - - if let envFiles = service.env_file { - for envFile in envFiles { - let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") - combinedEnv.merge(additionalEnvVars) { (current, _) in current } - } - } - - if let serviceEnv = service.environment { - combinedEnv.merge(serviceEnv) { (old, new) in - guard !new.contains("${") else { - return old - } - return new - } // Service env overrides .env files - } - - // Fill in variables - combinedEnv = combinedEnv.mapValues({ value in - guard value.contains("${") else { return value } - - let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) - return combinedEnv[variableName] ?? value - }) - - // Fill in IPs - combinedEnv = combinedEnv.mapValues({ value in - containerIps[value] ?? value - }) - - // MARK: Spinning Spot - // Add environment variables to run command - for (key, value) in combinedEnv { - runCommandArgs.append("-e") - runCommandArgs.append("\(key)=\(value)") - } - - // REMOVED: Port mappings (-p) are not supported by `container run` - // if let ports = service.ports { - // for port in ports { - // let resolvedPort = resolveVariable(port, with: envVarsFromFile) - // runCommandArgs.append("-p") - // runCommandArgs.append(resolvedPort) - // } - // } - - // Connect to specified networks - if let serviceNetworks = service.networks { - for network in serviceNetworks { - let resolvedNetwork = resolveVariable(network, with: environmentVariables) - // Use the explicit network name from top-level definition if available, otherwise resolved name - let networkToConnect = dockerCompose.networks?[network]?.name ?? resolvedNetwork - runCommandArgs.append("--network") - runCommandArgs.append(networkToConnect) - } - print( - "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml." - ) - print( - "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." - ) - } else { - print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.") - } - - // Add hostname - if let hostname = service.hostname { - let resolvedHostname = resolveVariable(hostname, with: environmentVariables) - runCommandArgs.append("--hostname") - runCommandArgs.append(resolvedHostname) - } - - // Add working directory - if let workingDir = service.working_dir { - let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) - runCommandArgs.append("--workdir") - runCommandArgs.append(resolvedWorkingDir) - } - - // Add privileged flag - if service.privileged == true { - runCommandArgs.append("--privileged") - } - - // Add read-only flag - if service.read_only == true { - runCommandArgs.append("--read-only") - } - - // Handle service-level configs (note: still only parsing/logging, not attaching) - if let serviceConfigs = service.configs { - print( - "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." - ) - print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.") - for serviceConfig in serviceConfigs { - print( - " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" - ) - } - } - // - // Handle service-level secrets (note: still only parsing/logging, not attaching) - if let serviceSecrets = service.secrets { - print( - "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." - ) - print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.") - for serviceSecret in serviceSecrets { - print( - " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" - ) - } - } - - // Add interactive and TTY flags - if service.stdin_open == true { - runCommandArgs.append("-i") // --interactive - } - if service.tty == true { - runCommandArgs.append("-t") // --tty - } - - runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint - - // Add entrypoint or command - if let entrypointParts = service.entrypoint { - runCommandArgs.append("--entrypoint") - runCommandArgs.append(contentsOf: entrypointParts) - } else if let commandParts = service.command { - runCommandArgs.append(contentsOf: commandParts) - } - - var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! - - if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { - while containerConsoleColors.values.contains(serviceColor) { - serviceColor = Self.availableContainerConsoleColors.randomElement()! - } - } - - self.containerConsoleColors[serviceName] = serviceColor - - Task { [self, serviceColor] in - @Sendable - func handleOutput(_ output: String) { - print("\(serviceName): \(output)".applyingColor(serviceColor)) - } - - print("\nStarting service: \(serviceName)") - print("Starting \(serviceName)") - print("----------------------------------------\n") - let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) - } - - do { - try await waitUntilServiceIsRunning(serviceName) - try await updateEnvironmentWithServiceIP(serviceName) - } catch { - print(error) - } - } - - private func pullImage(_ imageName: String, platform: String?) async throws { - let imageList = try await ClientImage.list() - guard !imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageName }) else { - return - } - - print("Pulling Image \(imageName)...") - var registry = Flags.Registry() - registry.scheme = "auto" // Set or SwiftArgumentParser gets mad - - var progress = Flags.Progress() - progress.disableProgressUpdates = false - - var imagePull = ImagePull() - imagePull.progressFlags = progress - imagePull.registry = registry - imagePull.global = global - imagePull.reference = imageName - imagePull.platform = platform - try await imagePull.run() - } - - /// Builds Docker Service - /// - /// - Parameters: - /// - buildConfig: The configuration for the build - /// - service: The service you would like to build - /// - serviceName: The fallback name for the image - /// - /// - Returns: Image Name (`String`) - private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { - // Determine image tag for built image - let imageToRun = service.image ?? "\(serviceName):latest" - let imageList = try await ClientImage.list() - if !rebuild, imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageToRun }) { - return imageToRun - } - - var buildCommand = BuildCommand() - - // Set Build Commands - buildCommand.buildArg = (buildConfig.args ?? [:]).map({ "\($0.key)=\(resolveVariable($0.value, with: environmentVariables))" }) - - // Locate Dockerfile and context - buildCommand.contextDir = "\(self.cwd)/\(buildConfig.context)" - buildCommand.file = "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")" - - // Handle Caching - buildCommand.noCache = noCache - buildCommand.cacheIn = [] - buildCommand.cacheOut = [] - - // Handle OS/Arch - let split = service.platform?.split(separator: "/") - buildCommand.os = [String(split?.first ?? "linux")] - buildCommand.arch = [String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64")] - - // Set Image Name - buildCommand.targetImageName = imageToRun - - // Set CPU & Memory - buildCommand.cpus = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 - buildCommand.memory = service.deploy?.resources?.limits?.memory ?? "2048MB" - - // Set Miscelaneous - buildCommand.label = [] // No Label Equivalent? - buildCommand.progress = "auto" - buildCommand.vsockPort = 8088 - buildCommand.quiet = false - buildCommand.target = "" - buildCommand.output = ["type=oci"] - print("\n----------------------------------------") - print("Building image for service: \(serviceName) (Tag: \(imageToRun))") - try buildCommand.validate() - try await buildCommand.run() - print("Image build for \(serviceName) completed.") - print("----------------------------------------") - - return imageToRun - } - - private func configVolume(_ volume: String) async throws -> [String] { - let resolvedVolume = resolveVariable(volume, with: environmentVariables) - - var runCommandArgs: [String] = [] - - // Parse the volume string: destination[:mode] - let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) - - guard components.count >= 2 else { - print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") - return [] - } - - let source = components[0] - let destination = components[1] - - // Check if the source looks like a host path (contains '/' or starts with '.') - // This heuristic helps distinguish bind mounts from named volume references. - if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { - // This is likely a bind mount (local path to container path) - var isDirectory: ObjCBool = false - // Ensure the path is absolute or relative to the current directory for FileManager - let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) - - if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { - if isDirectory.boolValue { - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument - } else { - // Host path exists but is a file - print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") - } - } else { - // Host path does not exist, assume it's meant to be a directory and try to create it. - do { - try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) - print("Info: Created missing host directory for volume: \(fullHostPath)") - runCommandArgs.append("-v") - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument - } catch { - print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") - } - } - } else { - guard let projectName else { return [] } - let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") - let volumePath = volumeUrl.path(percentEncoded: false) - - let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() - let destinationPath = destinationUrl.path(percentEncoded: false) - - print( - "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." - ) - try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) - - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument - } - - return runCommandArgs - } - } -} - -// MARK: CommandLine Functions -extension Application.ComposeUp { - - /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. - /// - /// - Parameters: - /// - command: The name of the command to run (e.g., `"container"`). - /// - args: Command-line arguments to pass to the command. - /// - onStdout: Closure called with streamed stdout data. - /// - onStderr: Closure called with streamed stderr data. - /// - Returns: The process's exit code. - /// - Throws: If the process fails to launch. - @discardableResult - func streamCommand( - _ command: String, - args: [String] = [], - onStdout: @escaping (@Sendable (String) -> Void), - onStderr: @escaping (@Sendable (String) -> Void) - ) async throws -> Int32 { - try await withCheckedThrowingContinuation { continuation in - let process = Process() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [command] + args - process.currentDirectoryURL = URL(fileURLWithPath: cwd) - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - process.environment = ProcessInfo.processInfo.environment.merging([ - "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" - ]) { _, new in new } - - let stdoutHandle = stdoutPipe.fileHandleForReading - let stderrHandle = stderrPipe.fileHandleForReading - - stdoutHandle.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - if let string = String(data: data, encoding: .utf8) { - onStdout(string) - } - } - - stderrHandle.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - if let string = String(data: data, encoding: .utf8) { - onStderr(string) - } - } - - process.terminationHandler = { proc in - stdoutHandle.readabilityHandler = nil - stderrHandle.readabilityHandler = nil - continuation.resume(returning: proc.terminationStatus) - } - - do { - try process.run() - } catch { - continuation.resume(throwing: error) - } - } - } -} diff --git a/Sources/ContainerCLI/Compose/ComposeCommand.swift b/Sources/ContainerCLI/Compose/ComposeCommand.swift deleted file mode 100644 index 03e940332..000000000 --- a/Sources/ContainerCLI/Compose/ComposeCommand.swift +++ /dev/null @@ -1,55 +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. -//===----------------------------------------------------------------------===// - -// -// File.swift -// Container-Compose -// -// Created by Morris Richman on 6/18/25. -// - -import ArgumentParser -import Foundation -import Rainbow -import Yams - -extension Application { - struct ComposeCommand: AsyncParsableCommand { - static let configuration: CommandConfiguration = .init( - commandName: "compose", - abstract: "Manage containers with Docker Compose files", - subcommands: [ - ComposeUp.self, - ComposeDown.self, - ]) - } -} - -/// A structure representing the result of a command-line process execution. -struct CommandResult { - /// The standard output captured from the process. - let stdout: String - - /// The standard error output captured from the process. - let stderr: String - - /// The exit code returned by the process upon termination. - let exitCode: Int32 -} - -extension NamedColor: Codable { - -} diff --git a/Sources/ContainerCLI/Compose/Errors.swift b/Sources/ContainerCLI/Compose/Errors.swift deleted file mode 100644 index c5b375aa2..000000000 --- a/Sources/ContainerCLI/Compose/Errors.swift +++ /dev/null @@ -1,66 +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. -//===----------------------------------------------------------------------===// - -// -// Errors.swift -// Container-Compose -// -// Created by Morris Richman on 6/18/25. -// - -import Foundation - -extension Application { - internal enum YamlError: Error, LocalizedError { - case dockerfileNotFound(String) - - var errorDescription: String? { - switch self { - case .dockerfileNotFound(let path): - return "docker-compose.yml not found at \(path)" - } - } - } - - internal enum ComposeError: Error, LocalizedError { - case imageNotFound(String) - case invalidProjectName - - var errorDescription: String? { - switch self { - case .imageNotFound(let name): - return "Service \(name) must define either 'image' or 'build'." - case .invalidProjectName: - return "Could not find project name." - } - } - } - - internal enum TerminalError: Error, LocalizedError { - case commandFailed(String) - - var errorDescription: String? { - "Command failed: \(self)" - } - } - - /// An enum representing streaming output from either `stdout` or `stderr`. - internal enum CommandOutput { - case stdout(String) - case stderr(String) - case exitCode(Int32) - } -} diff --git a/Sources/ContainerCLI/Compose/Helper Functions.swift b/Sources/ContainerCLI/Compose/Helper Functions.swift deleted file mode 100644 index e1068ad94..000000000 --- a/Sources/ContainerCLI/Compose/Helper Functions.swift +++ /dev/null @@ -1,96 +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. -//===----------------------------------------------------------------------===// - -// -// Helper Functions.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - -import Foundation -import Yams - -extension Application { - /// Loads environment variables from a .env file. - /// - Parameter path: The full path to the .env file. - /// - Returns: A dictionary of key-value pairs representing environment variables. - internal static func loadEnvFile(path: String) -> [String: String] { - var envVars: [String: String] = [:] - let fileURL = URL(fileURLWithPath: path) - do { - let content = try String(contentsOf: fileURL, encoding: .utf8) - let lines = content.split(separator: "\n") - for line in lines { - let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) - // Ignore empty lines and comments - if !trimmedLine.isEmpty && !trimmedLine.starts(with: "#") { - // Parse key=value pairs - if let eqIndex = trimmedLine.firstIndex(of: "=") { - let key = String(trimmedLine[.. String { - var resolvedValue = value - // Regex to find ${VAR}, ${VAR:-default}, ${VAR:?error} - let regex = try! NSRegularExpression(pattern: "\\$\\{([A-Z0-9_]+)(:?-(.*?))?(:\\?(.*?))?\\}", options: []) - - // Combine process environment with loaded .env file variables, prioritizing process environment - let combinedEnv = ProcessInfo.processInfo.environment.merging(envVars) { (current, _) in current } - - // Loop to resolve all occurrences of variables in the string - while let match = regex.firstMatch(in: resolvedValue, options: [], range: NSRange(resolvedValue.startIndex.. 0 && all { - throw ContainerizationError( - .invalidArgument, - message: "explicitly supplied container ID(s) conflict with the --all flag" - ) - } - } - - mutating func run() async throws { - let set = Set(containerIDs) - var containers = [ClientContainer]() - - if all { - containers = try await ClientContainer.list() - } else { - let ctrs = try await ClientContainer.list() - containers = ctrs.filter { c in - set.contains(c.id) - } - // If one of the containers requested isn't present, let's throw. We don't need to do - // this for --all as --all should be perfectly usable with no containers to remove; otherwise, - // it'd be quite clunky. - if containers.count != set.count { - let missing = set.filter { id in - !containers.contains { c in - c.id == id - } - } - throw ContainerizationError( - .notFound, - message: "failed to delete one or more containers: \(missing)" - ) - } - } - - var failed = [String]() - let force = self.force - let all = self.all - try await withThrowingTaskGroup(of: ClientContainer?.self) { group in - for container in containers { - group.addTask { - do { - // First we need to find if the container supports auto-remove - // and if so we need to skip deletion. - if container.status == .running { - if !force { - // We don't want to error if the user just wants all containers deleted. - // It's implied we'll skip containers we can't actually delete. - if all { - return nil - } - throw ContainerizationError(.invalidState, message: "container is running") - } - let stopOpts = ContainerStopOptions( - timeoutInSeconds: 5, - signal: SIGKILL - ) - try await container.stop(opts: stopOpts) - } - try await container.delete() - print(container.id) - return nil - } catch { - log.error("failed to delete container \(container.id): \(error)") - return container - } - } - } - - for try await ctr in group { - guard let ctr else { - continue - } - failed.append(ctr.id) - } - } - - if failed.count > 0 { - throw ContainerizationError(.internalError, message: "delete failed for one or more containers: \(failed)") - } - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerExec.swift b/Sources/ContainerCLI/Container/ContainerExec.swift deleted file mode 100644 index de3969585..000000000 --- a/Sources/ContainerCLI/Container/ContainerExec.swift +++ /dev/null @@ -1,96 +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 ContainerizationError -import ContainerizationOS -import Foundation - -extension Application { - struct ContainerExec: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "exec", - abstract: "Run a new command in a running container") - - @OptionGroup - var processFlags: Flags.Process - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Running containers ID") - var containerID: String - - @Argument(parsing: .captureForPassthrough, help: "New process arguments") - var arguments: [String] - - func run() async throws { - var exitCode: Int32 = 127 - let container = try await ClientContainer.get(id: containerID) - try ensureRunning(container: container) - - let stdin = self.processFlags.interactive - let tty = self.processFlags.tty - - var config = container.configuration.initProcess - config.executable = arguments.first! - config.arguments = [String](self.arguments.dropFirst()) - config.terminal = tty - config.environment.append( - contentsOf: try Parser.allEnv( - imageEnvs: [], - envFiles: self.processFlags.envFile, - envs: self.processFlags.env - )) - - if let cwd = self.processFlags.cwd { - config.workingDirectory = cwd - } - - let defaultUser = config.user - let (user, additionalGroups) = Parser.user( - user: processFlags.user, uid: processFlags.uid, - gid: processFlags.gid, defaultUser: defaultUser) - config.user = user - config.supplementalGroups.append(contentsOf: additionalGroups) - - do { - let io = try ProcessIO.create(tty: tty, interactive: stdin, detach: false) - - if !self.processFlags.tty { - var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) - handler.start { - print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") - Darwin.exit(1) - } - } - - let process = try await container.createProcess( - id: UUID().uuidString.lowercased(), - configuration: config) - - exitCode = try await Application.handleProcess(io: io, process: process) - } catch { - if error is ContainerizationError { - throw error - } - throw ContainerizationError(.internalError, message: "failed to exec process \(error)") - } - throw ArgumentParser.ExitCode(exitCode) - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerInspect.swift b/Sources/ContainerCLI/Container/ContainerInspect.swift deleted file mode 100644 index 43bda51a1..000000000 --- a/Sources/ContainerCLI/Container/ContainerInspect.swift +++ /dev/null @@ -1,43 +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 Foundation -import SwiftProtobuf - -extension Application { - struct ContainerInspect: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "inspect", - abstract: "Display information about one or more containers") - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Containers to inspect") - var containers: [String] - - func run() async throws { - let objects: [any Codable] = try await ClientContainer.list().filter { - containers.contains($0.id) - }.map { - PrintableContainer($0) - } - print(try objects.jsonArray()) - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerKill.swift b/Sources/ContainerCLI/Container/ContainerKill.swift deleted file mode 100644 index 9b9ef4ed4..000000000 --- a/Sources/ContainerCLI/Container/ContainerKill.swift +++ /dev/null @@ -1,79 +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 ContainerizationError -import ContainerizationOS -import Darwin - -extension Application { - struct ContainerKill: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "kill", - abstract: "Kill one or more running containers") - - @Option(name: .shortAndLong, help: "Signal to send the container(s)") - var signal: String = "KILL" - - @Flag(name: .shortAndLong, help: "Kill all running containers") - var all = false - - @Argument(help: "Container IDs") - var containerIDs: [String] = [] - - @OptionGroup - var global: Flags.Global - - func validate() throws { - if containerIDs.count == 0 && !all { - throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") - } - if containerIDs.count > 0 && all { - throw ContainerizationError(.invalidArgument, message: "explicitly supplied container IDs conflicts with the --all flag") - } - } - - mutating func run() async throws { - let set = Set(containerIDs) - - var containers = try await ClientContainer.list().filter { c in - c.status == .running - } - if !self.all { - containers = containers.filter { c in - set.contains(c.id) - } - } - - let signalNumber = try Signals.parseSignal(signal) - - var failed: [String] = [] - for container in containers { - do { - try await container.kill(signalNumber) - print(container.id) - } catch { - log.error("failed to kill container \(container.id): \(error)") - failed.append(container.id) - } - } - if failed.count > 0 { - throw ContainerizationError(.internalError, message: "kill failed for one or more containers") - } - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerList.swift b/Sources/ContainerCLI/Container/ContainerList.swift deleted file mode 100644 index 43e5a4cec..000000000 --- a/Sources/ContainerCLI/Container/ContainerList.swift +++ /dev/null @@ -1,110 +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 ContainerNetworkService -import ContainerizationExtras -import Foundation -import SwiftProtobuf - -extension Application { - struct ContainerList: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List containers", - aliases: ["ls"]) - - @Flag(name: .shortAndLong, help: "Show stopped containers as well") - var all = false - - @Flag(name: .shortAndLong, help: "Only output the container ID") - 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 containers = try await ClientContainer.list() - try printContainers(containers: containers, format: format) - } - - private func createHeader() -> [[String]] { - [["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR"]] - } - - private func printContainers(containers: [ClientContainer], format: ListFormat) throws { - if format == .json { - let printables = containers.map { - PrintableContainer($0) - } - let data = try JSONEncoder().encode(printables) - print(String(data: data, encoding: .utf8)!) - - return - } - - if self.quiet { - containers.forEach { - if !self.all && $0.status != .running { - return - } - print($0.id) - } - return - } - - var rows = createHeader() - for container in containers { - if !self.all && container.status != .running { - continue - } - rows.append(container.asRow) - } - - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } - } -} - -extension ClientContainer { - var asRow: [String] { - [ - self.id, - self.configuration.image.reference, - self.configuration.platform.os, - self.configuration.platform.architecture, - self.status.rawValue, - self.networks.compactMap { try? CIDRAddress($0.address).address.description }.joined(separator: ","), - ] - } -} - -struct PrintableContainer: Codable { - let status: RuntimeStatus - let configuration: ContainerConfiguration - let networks: [Attachment] - - init(_ container: ClientContainer) { - self.status = container.status - self.configuration = container.configuration - self.networks = container.networks - } -} diff --git a/Sources/ContainerCLI/Container/ContainerLogs.swift b/Sources/ContainerCLI/Container/ContainerLogs.swift deleted file mode 100644 index d70e80323..000000000 --- a/Sources/ContainerCLI/Container/ContainerLogs.swift +++ /dev/null @@ -1,144 +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 ContainerizationError -import Dispatch -import Foundation - -extension Application { - struct ContainerLogs: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "logs", - abstract: "Fetch container stdio or boot logs" - ) - - @OptionGroup - var global: Flags.Global - - @Flag(name: .shortAndLong, help: "Follow log output") - var follow: Bool = false - - @Flag(name: .long, help: "Display the boot log for the container instead of stdio") - var boot: Bool = false - - @Option(name: [.customShort("n")], help: "Number of lines to show from the end of the logs. If not provided this will print all of the logs") - var numLines: Int? - - @Argument(help: "Container to fetch logs for") - var container: String - - func run() async throws { - do { - let container = try await ClientContainer.get(id: container) - let fhs = try await container.logs() - let fileHandle = boot ? fhs[1] : fhs[0] - - try await Self.tail( - fh: fileHandle, - n: numLines, - follow: follow - ) - } catch { - throw ContainerizationError( - .invalidArgument, - message: "failed to fetch container logs for \(container): \(error)" - ) - } - } - - private static func tail( - fh: FileHandle, - n: Int?, - follow: Bool - ) async throws { - if let n { - var buffer = Data() - let size = try fh.seekToEnd() - var offset = size - var lines: [String] = [] - - while offset > 0, lines.count < n { - let readSize = min(1024, offset) - offset -= readSize - try fh.seek(toOffset: offset) - - let data = fh.readData(ofLength: Int(readSize)) - buffer.insert(contentsOf: data, at: 0) - - if let chunk = String(data: buffer, encoding: .utf8) { - lines = chunk.components(separatedBy: .newlines) - lines = lines.filter { !$0.isEmpty } - } - } - - lines = Array(lines.suffix(n)) - for line in lines { - print(line) - } - } else { - // Fast path if all they want is the full file. - guard let data = try fh.readToEnd() else { - // Seems you get nil if it's a zero byte read, or you - // try and read from dev/null. - return - } - guard let str = String(data: data, encoding: .utf8) else { - throw ContainerizationError( - .internalError, - message: "failed to convert container logs to utf8" - ) - } - print(str.trimmingCharacters(in: .newlines)) - } - - if follow { - try await Self.followFile(fh: fh) - } - } - - private static func followFile(fh: FileHandle) async throws { - _ = try fh.seekToEnd() - let stream = AsyncStream { cont in - fh.readabilityHandler = { handle in - let data = handle.availableData - if data.isEmpty { - // Triggers on container restart - can exit here as well - do { - _ = try fh.seekToEnd() // To continue streaming existing truncated log files - } catch { - fh.readabilityHandler = nil - cont.finish() - return - } - } - if let str = String(data: data, encoding: .utf8), !str.isEmpty { - var lines = str.components(separatedBy: .newlines) - lines = lines.filter { !$0.isEmpty } - for line in lines { - cont.yield(line) - } - } - } - } - - for await line in stream { - print(line) - } - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerStart.swift b/Sources/ContainerCLI/Container/ContainerStart.swift deleted file mode 100644 index a804b9c20..000000000 --- a/Sources/ContainerCLI/Container/ContainerStart.swift +++ /dev/null @@ -1,87 +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 ContainerizationError -import ContainerizationOS -import TerminalProgress - -extension Application { - struct ContainerStart: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "start", - abstract: "Start a container") - - @Flag(name: .shortAndLong, help: "Attach STDOUT/STDERR") - var attach = false - - @Flag(name: .shortAndLong, help: "Attach container's STDIN") - var interactive = false - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Container's ID") - var containerID: String - - func run() async throws { - var exitCode: Int32 = 127 - - let progressConfig = try ProgressConfig( - description: "Starting container" - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - let container = try await ClientContainer.get(id: containerID) - let process = try await container.bootstrap() - - progress.set(description: "Starting init process") - let detach = !self.attach && !self.interactive - do { - let io = try ProcessIO.create( - tty: container.configuration.initProcess.terminal, - interactive: self.interactive, - detach: detach - ) - progress.finish() - if detach { - try await process.start(io.stdio) - defer { - try? io.close() - } - try io.closeAfterStart() - print(self.containerID) - return - } - - exitCode = try await Application.handleProcess(io: io, process: process) - } catch { - try? await container.stop() - - if error is ContainerizationError { - throw error - } - throw ContainerizationError(.internalError, message: "failed to start container: \(error)") - } - throw ArgumentParser.ExitCode(exitCode) - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerStop.swift b/Sources/ContainerCLI/Container/ContainerStop.swift deleted file mode 100644 index 78f69090e..000000000 --- a/Sources/ContainerCLI/Container/ContainerStop.swift +++ /dev/null @@ -1,102 +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 ContainerizationError -import ContainerizationOS -import Foundation - -extension Application { - struct ContainerStop: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "stop", - abstract: "Stop one or more running containers") - - @Flag(name: .shortAndLong, help: "Stop all running containers") - var all = false - - @Option(name: .shortAndLong, help: "Signal to send the container(s)") - var signal: String = "SIGTERM" - - @Option(name: .shortAndLong, help: "Seconds to wait before killing the container(s)") - var time: Int32 = 5 - - @Argument - var containerIDs: [String] = [] - - @OptionGroup - var global: Flags.Global - - func validate() throws { - if containerIDs.count == 0 && !all { - throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") - } - if containerIDs.count > 0 && all { - throw ContainerizationError( - .invalidArgument, message: "explicitly supplied container IDs conflicts with the --all flag") - } - } - - mutating func run() async throws { - let set = Set(containerIDs) - var containers = [ClientContainer]() - if self.all { - containers = try await ClientContainer.list() - } else { - containers = try await ClientContainer.list().filter { c in - set.contains(c.id) - } - } - - let opts = ContainerStopOptions( - timeoutInSeconds: self.time, - signal: try Signals.parseSignal(self.signal) - ) - let failed = try await Self.stopContainers(containers: containers, stopOptions: opts) - if failed.count > 0 { - throw ContainerizationError(.internalError, message: "stop failed for one or more containers \(failed.joined(separator: ","))") - } - } - - static func stopContainers(containers: [ClientContainer], stopOptions: ContainerStopOptions) async throws -> [String] { - var failed: [String] = [] - try await withThrowingTaskGroup(of: ClientContainer?.self) { group in - for container in containers { - group.addTask { - do { - try await container.stop(opts: stopOptions) - print(container.id) - return nil - } catch { - log.error("failed to stop container \(container.id): \(error)") - return container - } - } - } - - for try await ctr in group { - guard let ctr else { - continue - } - failed.append(ctr.id) - } - } - - return failed - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainersCommand.swift b/Sources/ContainerCLI/Container/ContainersCommand.swift deleted file mode 100644 index ef6aff93e..000000000 --- a/Sources/ContainerCLI/Container/ContainersCommand.swift +++ /dev/null @@ -1,38 +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 - -extension Application { - struct ContainersCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "containers", - abstract: "Manage containers", - subcommands: [ - ContainerCreate.self, - ContainerDelete.self, - ContainerExec.self, - ContainerInspect.self, - ContainerKill.self, - ContainerList.self, - ContainerLogs.self, - ContainerStart.self, - ContainerStop.self, - ], - aliases: ["container", "c"] - ) - } -} diff --git a/Sources/ContainerCLI/Container/ProcessUtils.swift b/Sources/ContainerCLI/Container/ProcessUtils.swift deleted file mode 100644 index d4dda6a27..000000000 --- a/Sources/ContainerCLI/Container/ProcessUtils.swift +++ /dev/null @@ -1,31 +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 ContainerClient -import Containerization -import ContainerizationError -import ContainerizationOS -import Foundation - -extension Application { - static func ensureRunning(container: ClientContainer) throws { - if container.status != .running { - throw ContainerizationError(.invalidState, message: "container \(container.id) is not running") - } - } -} diff --git a/Sources/ContainerCLI/DefaultCommand.swift b/Sources/ContainerCLI/DefaultCommand.swift deleted file mode 100644 index ef88aaaa3..000000000 --- a/Sources/ContainerCLI/DefaultCommand.swift +++ /dev/null @@ -1,54 +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 ContainerPlugin - -struct DefaultCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: nil, - shouldDisplay: false - ) - - @OptionGroup(visibility: .hidden) - var global: Flags.Global - - @Argument(parsing: .captureForPassthrough) - var remaining: [String] = [] - - func run() async throws { - // See if we have a possible plugin command. - guard let command = remaining.first else { - Application.printModifiedHelpText() - return - } - - // Check for edge cases and unknown options to match the behavior in the absence of plugins. - if command.isEmpty { - throw ValidationError("Unknown argument '\(command)'") - } else if command.starts(with: "-") { - throw ValidationError("Unknown option '\(command)'") - } - - let pluginLoader = Application.pluginLoader - guard let plugin = pluginLoader.findPlugin(name: command), plugin.config.isCLI else { - throw ValidationError("failed to find plugin named container-\(command)") - } - // Exec performs execvp (with no fork). - try plugin.exec(args: remaining) - } -} diff --git a/Sources/ContainerCLI/Image/ImageInspect.swift b/Sources/ContainerCLI/Image/ImageInspect.swift deleted file mode 100644 index cea356867..000000000 --- a/Sources/ContainerCLI/Image/ImageInspect.swift +++ /dev/null @@ -1,53 +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 ContainerizationError -import Foundation -import SwiftProtobuf - -extension Application { - struct ImageInspect: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "inspect", - abstract: "Display information about one or more images") - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Images to inspect") - var images: [String] - - func run() async throws { - var printable = [any Codable]() - let result = try await ClientImage.get(names: images) - let notFound = result.error - for image in result.images { - guard !Utility.isInfraImage(name: image.reference) else { - continue - } - printable.append(try await image.details()) - } - if printable.count > 0 { - print(try printable.jsonArray()) - } - if notFound.count > 0 { - throw ContainerizationError(.notFound, message: "Images: \(notFound.joined(separator: "\n"))") - } - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageList.swift b/Sources/ContainerCLI/Image/ImageList.swift deleted file mode 100644 index e666feca7..000000000 --- a/Sources/ContainerCLI/Image/ImageList.swift +++ /dev/null @@ -1,175 +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 Containerization -import ContainerizationError -import ContainerizationOCI -import Foundation -import SwiftProtobuf - -extension Application { - struct ListImageOptions: ParsableArguments { - @Flag(name: .shortAndLong, help: "Only output the image name") - var quiet = false - - @Flag(name: .shortAndLong, help: "Verbose output") - var verbose = false - - @Option(name: .long, help: "Format of the output") - var format: ListFormat = .table - - @OptionGroup - var global: Flags.Global - } - - struct ListImageImplementation { - static private func createHeader() -> [[String]] { - [["NAME", "TAG", "DIGEST"]] - } - - static private func createVerboseHeader() -> [[String]] { - [["NAME", "TAG", "INDEX DIGEST", "OS", "ARCH", "VARIANT", "SIZE", "CREATED", "MANIFEST DIGEST"]] - } - - static private func printImagesVerbose(images: [ClientImage]) async throws { - - var rows = createVerboseHeader() - for image in images { - let formatter = ByteCountFormatter() - for descriptor in try await image.index().manifests { - // Don't list attestation manifests - if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], - referenceType == "attestation-manifest" - { - continue - } - - guard let platform = descriptor.platform else { - continue - } - - let os = platform.os - let arch = platform.architecture - let variant = platform.variant ?? "" - - var config: ContainerizationOCI.Image - var manifest: ContainerizationOCI.Manifest - do { - config = try await image.config(for: platform) - manifest = try await image.manifest(for: platform) - } catch { - continue - } - - let created = config.created ?? "" - let size = descriptor.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size }) - let formattedSize = formatter.string(fromByteCount: size) - - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) - let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) - let row = [ - reference.name, - reference.tag ?? "", - Utility.trimDigest(digest: image.descriptor.digest), - os, - arch, - variant, - formattedSize, - created, - Utility.trimDigest(digest: descriptor.digest), - ] - rows.append(row) - } - } - - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } - - static private func printImages(images: [ClientImage], format: ListFormat, options: ListImageOptions) async throws { - var images = images - images.sort { - $0.reference < $1.reference - } - - if format == .json { - let data = try JSONEncoder().encode(images.map { $0.description }) - print(String(data: data, encoding: .utf8)!) - return - } - - if options.quiet { - try images.forEach { image in - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) - print(processedReferenceString) - } - return - } - - if options.verbose { - try await Self.printImagesVerbose(images: images) - return - } - - var rows = createHeader() - for image in images { - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) - let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) - rows.append([ - reference.name, - reference.tag ?? "", - Utility.trimDigest(digest: image.descriptor.digest), - ]) - } - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } - - static func validate(options: ListImageOptions) throws { - if options.quiet && options.verbose { - throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite and --verbose together") - } - let modifier = options.quiet || options.verbose - if modifier && options.format == .json { - throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite or --verbose along with --format json") - } - } - - static func listImages(options: ListImageOptions) async throws { - let images = try await ClientImage.list().filter { img in - !Utility.isInfraImage(name: img.reference) - } - try await printImages(images: images, format: options.format, options: options) - } - } - - struct ImageList: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List images", - aliases: ["ls"]) - - @OptionGroup - var options: ListImageOptions - - mutating func run() async throws { - try ListImageImplementation.validate(options: options) - try await ListImageImplementation.listImages(options: options) - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageLoad.swift b/Sources/ContainerCLI/Image/ImageLoad.swift deleted file mode 100644 index 719fd19ec..000000000 --- a/Sources/ContainerCLI/Image/ImageLoad.swift +++ /dev/null @@ -1,76 +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 Containerization -import ContainerizationError -import Foundation -import TerminalProgress - -extension Application { - struct ImageLoad: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "load", - abstract: "Load images from an OCI compatible tar archive" - ) - - @OptionGroup - var global: Flags.Global - - @Option( - name: .shortAndLong, help: "Path to the tar archive to load images from", completion: .file(), - transform: { str in - URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) - }) - var input: String - - func run() async throws { - guard FileManager.default.fileExists(atPath: input) else { - print("File does not exist \(input)") - Application.exit(withError: ArgumentParser.ExitCode(1)) - } - - let progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - totalTasks: 2 - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - progress.set(description: "Loading tar archive") - let loaded = try await ClientImage.load(from: input) - - let taskManager = ProgressTaskCoordinator() - let unpackTask = await taskManager.startTask() - progress.set(description: "Unpacking image") - progress.set(itemsName: "entries") - for image in loaded { - try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) - } - await taskManager.finish() - progress.finish() - print("Loaded images:") - for image in loaded { - print(image.reference) - } - } - } -} diff --git a/Sources/ContainerCLI/Image/ImagePrune.swift b/Sources/ContainerCLI/Image/ImagePrune.swift deleted file mode 100644 index d233247f1..000000000 --- a/Sources/ContainerCLI/Image/ImagePrune.swift +++ /dev/null @@ -1,38 +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 Foundation - -extension Application { - struct ImagePrune: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "prune", - abstract: "Remove unreferenced and dangling images") - - @OptionGroup - var global: Flags.Global - - func run() async throws { - let (_, size) = try await ClientImage.pruneImages() - let formatter = ByteCountFormatter() - let freed = formatter.string(fromByteCount: Int64(size)) - print("Cleaned unreferenced images and snapshots") - print("Reclaimed \(freed) in disk space") - } - } -} diff --git a/Sources/ContainerCLI/Image/ImagePull.swift b/Sources/ContainerCLI/Image/ImagePull.swift deleted file mode 100644 index 58f6dc2c6..000000000 --- a/Sources/ContainerCLI/Image/ImagePull.swift +++ /dev/null @@ -1,98 +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 Containerization -import ContainerizationError -import ContainerizationOCI -import TerminalProgress - -extension Application { - struct ImagePull: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "pull", - abstract: "Pull an image" - ) - - @OptionGroup - var global: Flags.Global - - @OptionGroup - var registry: Flags.Registry - - @OptionGroup - var progressFlags: Flags.Progress - - @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? - - @Argument var reference: String - - init() {} - - init(platform: String? = nil, scheme: String = "auto", reference: String, disableProgress: Bool = false) { - self.global = Flags.Global() - self.registry = Flags.Registry(scheme: scheme) - self.progressFlags = Flags.Progress(disableProgressUpdates: disableProgress) - self.platform = platform - self.reference = reference - } - - func run() async throws { - var p: Platform? - if let platform { - p = try Platform(from: platform) - } - - let scheme = try RequestScheme(registry.scheme) - - let processedReference = try ClientImage.normalizeReference(reference) - - var progressConfig: ProgressConfig - if self.progressFlags.disableProgressUpdates { - progressConfig = try ProgressConfig(disableProgressUpdates: self.progressFlags.disableProgressUpdates) - } else { - progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - ignoreSmallSize: true, - totalTasks: 2 - ) - } - - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - progress.set(description: "Fetching image") - progress.set(itemsName: "blobs") - let taskManager = ProgressTaskCoordinator() - let fetchTask = await taskManager.startTask() - let image = try await ClientImage.pull( - reference: processedReference, platform: p, scheme: scheme, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler) - ) - - progress.set(description: "Unpacking image") - progress.set(itemsName: "entries") - let unpackTask = await taskManager.startTask() - try await image.unpack(platform: p, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) - await taskManager.finish() - progress.finish() - } - } -} diff --git a/Sources/ContainerCLI/Image/ImagePush.swift b/Sources/ContainerCLI/Image/ImagePush.swift deleted file mode 100644 index e61d162de..000000000 --- a/Sources/ContainerCLI/Image/ImagePush.swift +++ /dev/null @@ -1,73 +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 Containerization -import ContainerizationOCI -import TerminalProgress - -extension Application { - struct ImagePush: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "push", - abstract: "Push an image" - ) - - @OptionGroup - var global: Flags.Global - - @OptionGroup - var registry: Flags.Registry - - @OptionGroup - var progressFlags: Flags.Progress - - @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? - - @Argument var reference: String - - func run() async throws { - var p: Platform? - if let platform { - p = try Platform(from: platform) - } - - let scheme = try RequestScheme(registry.scheme) - let image = try await ClientImage.get(reference: reference) - - var progressConfig: ProgressConfig - if progressFlags.disableProgressUpdates { - progressConfig = try ProgressConfig(disableProgressUpdates: progressFlags.disableProgressUpdates) - } else { - progressConfig = try ProgressConfig( - description: "Pushing image \(image.reference)", - itemsName: "blobs", - showItems: true, - showSpeed: false, - ignoreSmallSize: true - ) - } - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - _ = try await image.push(platform: p, scheme: scheme, progressUpdate: progress.handler) - progress.finish() - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageRemove.swift b/Sources/ContainerCLI/Image/ImageRemove.swift deleted file mode 100644 index 2f0c86c22..000000000 --- a/Sources/ContainerCLI/Image/ImageRemove.swift +++ /dev/null @@ -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 Containerization -import ContainerizationError -import Foundation - -extension Application { - struct RemoveImageOptions: ParsableArguments { - @Flag(name: .shortAndLong, help: "Remove all images") - var all: Bool = false - - @Argument - var images: [String] = [] - - @OptionGroup - var global: Flags.Global - } - - struct RemoveImageImplementation { - static func validate(options: RemoveImageOptions) throws { - if options.images.count == 0 && !options.all { - throw ContainerizationError(.invalidArgument, message: "no image specified and --all not supplied") - } - if options.images.count > 0 && options.all { - throw ContainerizationError(.invalidArgument, message: "explicitly supplied images conflict with the --all flag") - } - } - - static func removeImage(options: RemoveImageOptions) async throws { - let (found, notFound) = try await { - if options.all { - let found = try await ClientImage.list() - let notFound: [String] = [] - return (found, notFound) - } - return try await ClientImage.get(names: options.images) - }() - var failures: [String] = notFound - var didDeleteAnyImage = false - for image in found { - guard !Utility.isInfraImage(name: image.reference) else { - continue - } - do { - try await ClientImage.delete(reference: image.reference, garbageCollect: false) - print(image.reference) - didDeleteAnyImage = true - } catch { - log.error("failed to remove \(image.reference): \(error)") - failures.append(image.reference) - } - } - let (_, size) = try await ClientImage.pruneImages() - let formatter = ByteCountFormatter() - let freed = formatter.string(fromByteCount: Int64(size)) - - if didDeleteAnyImage { - print("Reclaimed \(freed) in disk space") - } - if failures.count > 0 { - throw ContainerizationError(.internalError, message: "failed to delete one or more images: \(failures)") - } - } - } - - struct ImageRemove: AsyncParsableCommand { - @OptionGroup - var options: RemoveImageOptions - - static let configuration = CommandConfiguration( - commandName: "delete", - abstract: "Remove one or more images", - aliases: ["rm"]) - - func validate() throws { - try RemoveImageImplementation.validate(options: options) - } - - mutating func run() async throws { - try await RemoveImageImplementation.removeImage(options: options) - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageSave.swift b/Sources/ContainerCLI/Image/ImageSave.swift deleted file mode 100644 index 8c0b6eac4..000000000 --- a/Sources/ContainerCLI/Image/ImageSave.swift +++ /dev/null @@ -1,67 +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 Containerization -import ContainerizationOCI -import Foundation -import TerminalProgress - -extension Application { - struct ImageSave: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "save", - abstract: "Save an image as an OCI compatible tar archive" - ) - - @OptionGroup - var global: Flags.Global - - @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? - - @Option( - name: .shortAndLong, help: "Path to save the image tar archive", completion: .file(), - transform: { str in - URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) - }) - var output: String - - @Argument var reference: String - - func run() async throws { - var p: Platform? - if let platform { - p = try Platform(from: platform) - } - - let progressConfig = try ProgressConfig( - description: "Saving image" - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - let image = try await ClientImage.get(reference: reference) - try await image.save(out: output, platform: p) - - progress.finish() - print("Image saved") - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageTag.swift b/Sources/ContainerCLI/Image/ImageTag.swift deleted file mode 100644 index 01a76190f..000000000 --- a/Sources/ContainerCLI/Image/ImageTag.swift +++ /dev/null @@ -1,42 +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 - -extension Application { - struct ImageTag: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "tag", - abstract: "Tag an image") - - @Argument(help: "SOURCE_IMAGE[:TAG]") - var source: String - - @Argument(help: "TARGET_IMAGE[:TAG]") - var target: String - - @OptionGroup - var global: Flags.Global - - func run() async throws { - let existing = try await ClientImage.get(reference: source) - let targetReference = try ClientImage.normalizeReference(target) - try await existing.tag(new: targetReference) - print("Image \(source) tagged as \(target)") - } - } -} diff --git a/Sources/ContainerCLI/Image/ImagesCommand.swift b/Sources/ContainerCLI/Image/ImagesCommand.swift deleted file mode 100644 index 968dfd239..000000000 --- a/Sources/ContainerCLI/Image/ImagesCommand.swift +++ /dev/null @@ -1,38 +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 - -extension Application { - struct ImagesCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "images", - abstract: "Manage images", - subcommands: [ - ImageInspect.self, - ImageList.self, - ImageLoad.self, - ImagePrune.self, - ImagePull.self, - ImagePush.self, - ImageRemove.self, - ImageSave.self, - ImageTag.self, - ], - aliases: ["image", "i"] - ) - } -} diff --git a/Sources/ContainerCLI/Network/NetworkCommand.swift b/Sources/ContainerCLI/Network/NetworkCommand.swift deleted file mode 100644 index 7e502431b..000000000 --- a/Sources/ContainerCLI/Network/NetworkCommand.swift +++ /dev/null @@ -1,33 +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 - -extension Application { - struct NetworkCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "network", - abstract: "Manage container networks", - subcommands: [ - NetworkCreate.self, - NetworkDelete.self, - NetworkList.self, - NetworkInspect.self, - ], - aliases: ["n"] - ) - } -} diff --git a/Sources/ContainerCLI/Network/NetworkCreate.swift b/Sources/ContainerCLI/Network/NetworkCreate.swift deleted file mode 100644 index 535e029ed..000000000 --- a/Sources/ContainerCLI/Network/NetworkCreate.swift +++ /dev/null @@ -1,42 +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 ContainerNetworkService -import ContainerizationError -import Foundation -import TerminalProgress - -extension Application { - struct NetworkCreate: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "create", - abstract: "Create a new network") - - @Argument(help: "Network name") - var name: String - - @OptionGroup - var global: Flags.Global - - func run() async throws { - let config = NetworkConfiguration(id: self.name, mode: .nat) - let state = try await ClientNetwork.create(configuration: config) - print(state.id) - } - } -} diff --git a/Sources/ContainerCLI/Network/NetworkDelete.swift b/Sources/ContainerCLI/Network/NetworkDelete.swift deleted file mode 100644 index 836d6c8ca..000000000 --- a/Sources/ContainerCLI/Network/NetworkDelete.swift +++ /dev/null @@ -1,116 +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 ContainerNetworkService -import ContainerizationError -import Foundation - -extension Application { - struct NetworkDelete: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "delete", - abstract: "Delete one or more networks", - aliases: ["rm"]) - - @Flag(name: .shortAndLong, help: "Remove all networks") - var all = false - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Network names") - var networkNames: [String] = [] - - func validate() throws { - if networkNames.count == 0 && !all { - throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied") - } - if networkNames.count > 0 && all { - throw ContainerizationError( - .invalidArgument, - message: "explicitly supplied network name(s) conflict with the --all flag" - ) - } - } - - mutating func run() async throws { - let uniqueNetworkNames = Set(networkNames) - let networks: [NetworkState] - - if all { - networks = try await ClientNetwork.list() - } else { - networks = try await ClientNetwork.list() - .filter { c in - uniqueNetworkNames.contains(c.id) - } - - // If one of the networks requested isn't present lets throw. We don't need to do - // this for --all as --all should be perfectly usable with no networks to remove, - // otherwise it'd be quite clunky. - if networks.count != uniqueNetworkNames.count { - let missing = uniqueNetworkNames.filter { id in - !networks.contains { n in - n.id == id - } - } - throw ContainerizationError( - .notFound, - message: "failed to delete one or more networks: \(missing)" - ) - } - } - - if uniqueNetworkNames.contains(ClientNetwork.defaultNetworkName) { - throw ContainerizationError( - .invalidArgument, - message: "cannot delete the default network" - ) - } - - var failed = [String]() - try await withThrowingTaskGroup(of: NetworkState?.self) { group in - for network in networks { - group.addTask { - do { - // delete atomically disables the IP allocator, then deletes - // the allocator disable fails if any IPs are still in use - try await ClientNetwork.delete(id: network.id) - print(network.id) - return nil - } catch { - log.error("failed to delete network \(network.id): \(error)") - return network - } - } - } - - for try await network in group { - guard let network else { - continue - } - failed.append(network.id) - } - } - - if failed.count > 0 { - throw ContainerizationError(.internalError, message: "delete failed for one or more networks: \(failed)") - } - } - } -} diff --git a/Sources/ContainerCLI/Network/NetworkInspect.swift b/Sources/ContainerCLI/Network/NetworkInspect.swift deleted file mode 100644 index 614c8b111..000000000 --- a/Sources/ContainerCLI/Network/NetworkInspect.swift +++ /dev/null @@ -1,44 +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 ContainerNetworkService -import Foundation -import SwiftProtobuf - -extension Application { - struct NetworkInspect: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "inspect", - abstract: "Display information about one or more networks") - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Networks to inspect") - var networks: [String] - - func run() async throws { - let objects: [any Codable] = try await ClientNetwork.list().filter { - networks.contains($0.id) - }.map { - PrintableNetwork($0) - } - print(try objects.jsonArray()) - } - } -} diff --git a/Sources/ContainerCLI/Network/NetworkList.swift b/Sources/ContainerCLI/Network/NetworkList.swift deleted file mode 100644 index 9fb44dcb4..000000000 --- a/Sources/ContainerCLI/Network/NetworkList.swift +++ /dev/null @@ -1,107 +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 ContainerNetworkService -import ContainerizationExtras -import Foundation -import SwiftProtobuf - -extension Application { - struct NetworkList: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List networks", - 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 networks = try await ClientNetwork.list() - try printNetworks(networks: networks, format: format) - } - - private func createHeader() -> [[String]] { - [["NETWORK", "STATE", "SUBNET"]] - } - - private func printNetworks(networks: [NetworkState], format: ListFormat) throws { - if format == .json { - let printables = networks.map { - PrintableNetwork($0) - } - let data = try JSONEncoder().encode(printables) - print(String(data: data, encoding: .utf8)!) - - return - } - - if self.quiet { - networks.forEach { - print($0.id) - } - return - } - - var rows = createHeader() - for network in networks { - rows.append(network.asRow) - } - - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } - } -} - -extension NetworkState { - var asRow: [String] { - switch self { - case .created(_): - return [self.id, self.state, "none"] - case .running(_, let status): - return [self.id, self.state, status.address] - } - } -} - -struct PrintableNetwork: Codable { - let id: String - let state: String - let config: NetworkConfiguration - let status: NetworkStatus? - - init(_ network: NetworkState) { - self.id = network.id - self.state = network.state - switch network { - case .created(let config): - self.config = config - self.status = nil - case .running(let config, let status): - self.config = config - self.status = status - } - } -} diff --git a/Sources/ContainerCLI/Registry/Login.swift b/Sources/ContainerCLI/Registry/Login.swift deleted file mode 100644 index 7de7fe7e4..000000000 --- a/Sources/ContainerCLI/Registry/Login.swift +++ /dev/null @@ -1,92 +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 Containerization -import ContainerizationError -import ContainerizationOCI -import Foundation - -extension Application { - struct Login: AsyncParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Login to a registry" - ) - - @Option(name: .shortAndLong, help: "Username") - var username: String = "" - - @Flag(help: "Take the password from stdin") - var passwordStdin: Bool = false - - @Argument(help: "Registry server name") - var server: String - - @OptionGroup - var registry: Flags.Registry - - func run() async throws { - var username = self.username - var password = "" - if passwordStdin { - if username == "" { - throw ContainerizationError( - .invalidArgument, message: "must provide --username with --password-stdin") - } - guard let passwordData = try FileHandle.standardInput.readToEnd() else { - throw ContainerizationError(.invalidArgument, message: "failed to read password from stdin") - } - password = String(decoding: passwordData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) - } - let keychain = KeychainHelper(id: Constants.keychainID) - if username == "" { - username = try keychain.userPrompt(domain: server) - } - if password == "" { - password = try keychain.passwordPrompt() - print() - } - - let server = Reference.resolveDomain(domain: server) - let scheme = try RequestScheme(registry.scheme).schemeFor(host: server) - let _url = "\(scheme)://\(server)" - guard let url = URL(string: _url) else { - throw ContainerizationError(.invalidArgument, message: "Cannot convert \(_url) to URL") - } - guard let host = url.host else { - throw ContainerizationError(.invalidArgument, message: "Invalid host \(server)") - } - - let client = RegistryClient( - host: host, - scheme: scheme.rawValue, - port: url.port, - authentication: BasicAuthentication(username: username, password: password), - retryOptions: .init( - maxRetries: 10, - retryInterval: 300_000_000, - shouldRetry: ({ response in - response.status.code >= 500 - }) - ) - ) - try await client.ping() - try keychain.save(domain: server, username: username, password: password) - print("Login succeeded") - } - } -} diff --git a/Sources/ContainerCLI/Registry/Logout.swift b/Sources/ContainerCLI/Registry/Logout.swift deleted file mode 100644 index a24996e12..000000000 --- a/Sources/ContainerCLI/Registry/Logout.swift +++ /dev/null @@ -1,39 +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 Containerization -import ContainerizationOCI - -extension Application { - struct Logout: AsyncParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Log out from a registry") - - @Argument(help: "Registry server name") - var registry: String - - @OptionGroup - var global: Flags.Global - - func run() async throws { - let keychain = KeychainHelper(id: Constants.keychainID) - let r = Reference.resolveDomain(domain: registry) - try keychain.delete(domain: r) - } - } -} diff --git a/Sources/ContainerCLI/Registry/RegistryCommand.swift b/Sources/ContainerCLI/Registry/RegistryCommand.swift deleted file mode 100644 index c160c9469..000000000 --- a/Sources/ContainerCLI/Registry/RegistryCommand.swift +++ /dev/null @@ -1,32 +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 - -extension Application { - struct RegistryCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "registry", - abstract: "Manage registry configurations", - subcommands: [ - Login.self, - Logout.self, - RegistryDefault.self, - ], - aliases: ["r"] - ) - } -} diff --git a/Sources/ContainerCLI/Registry/RegistryDefault.swift b/Sources/ContainerCLI/Registry/RegistryDefault.swift deleted file mode 100644 index 593d41e27..000000000 --- a/Sources/ContainerCLI/Registry/RegistryDefault.swift +++ /dev/null @@ -1,98 +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 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 - } - } - ClientDefaults.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 { - ClientDefaults.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(ClientDefaults.get(key: .defaultRegistryDomain)) - } - } -} diff --git a/Sources/ContainerCLI/RunCommand.swift b/Sources/ContainerCLI/RunCommand.swift deleted file mode 100644 index 3a818e939..000000000 --- a/Sources/ContainerCLI/RunCommand.swift +++ /dev/null @@ -1,317 +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 Containerization -import ContainerizationError -import ContainerizationExtras -import ContainerizationOS -import Foundation -import NIOCore -import NIOPosix -import TerminalProgress - -extension Application { - struct ContainerRunCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "run", - abstract: "Run a container") - - @OptionGroup - var processFlags: Flags.Process - - @OptionGroup - var resourceFlags: Flags.Resource - - @OptionGroup - var managementFlags: Flags.Management - - @OptionGroup - var registryFlags: Flags.Registry - - @OptionGroup - var global: Flags.Global - - @OptionGroup - var progressFlags: Flags.Progress - - @Argument(help: "Image name") - var image: String - - @Argument(parsing: .captureForPassthrough, help: "Container init process arguments") - var arguments: [String] = [] - - func run() async throws { - var exitCode: Int32 = 127 - let id = Utility.createContainerID(name: self.managementFlags.name) - - var progressConfig: ProgressConfig - if progressFlags.disableProgressUpdates { - progressConfig = try ProgressConfig(disableProgressUpdates: progressFlags.disableProgressUpdates) - } else { - progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - ignoreSmallSize: true, - totalTasks: 6 - ) - } - - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - try Utility.validEntityName(id) - - // Check if container with id already exists. - let existing = try? await ClientContainer.get(id: id) - guard existing == nil else { - throw ContainerizationError( - .exists, - message: "container with id \(id) already exists" - ) - } - - let ck = try await Utility.containerConfigFromFlags( - id: id, - image: image, - arguments: arguments, - process: processFlags, - management: managementFlags, - resource: resourceFlags, - registry: registryFlags, - progressUpdate: progress.handler - ) - - progress.set(description: "Starting container") - - let options = ContainerCreateOptions(autoRemove: managementFlags.remove) - let container = try await ClientContainer.create( - configuration: ck.0, - options: options, - kernel: ck.1 - ) - - let detach = self.managementFlags.detach - - let process = try await container.bootstrap() - progress.finish() - - do { - let io = try ProcessIO.create( - tty: self.processFlags.tty, - interactive: self.processFlags.interactive, - detach: detach - ) - - if !self.managementFlags.cidfile.isEmpty { - let path = self.managementFlags.cidfile - let data = id.data(using: .utf8) - var attributes = [FileAttributeKey: Any]() - attributes[.posixPermissions] = 0o644 - let success = FileManager.default.createFile( - atPath: path, - contents: data, - attributes: attributes - ) - guard success else { - throw ContainerizationError( - .internalError, message: "failed to create cidfile at \(path): \(errno)") - } - } - - if detach { - try await process.start(io.stdio) - defer { - try? io.close() - } - try io.closeAfterStart() - print(id) - return - } - - if !self.processFlags.tty { - var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) - handler.start { - print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") - Darwin.exit(1) - } - } - - exitCode = try await Application.handleProcess(io: io, process: process) - } catch { - if error is ContainerizationError { - throw error - } - throw ContainerizationError(.internalError, message: "failed to run container: \(error)") - } - throw ArgumentParser.ExitCode(exitCode) - } - } -} - -struct ProcessIO { - let stdin: Pipe? - let stdout: Pipe? - let stderr: Pipe? - var ioTracker: IoTracker? - - struct IoTracker { - let stream: AsyncStream - let cont: AsyncStream.Continuation - let configuredStreams: Int - } - - let stdio: [FileHandle?] - - let console: Terminal? - - func closeAfterStart() throws { - try stdin?.fileHandleForReading.close() - try stdout?.fileHandleForWriting.close() - try stderr?.fileHandleForWriting.close() - } - - func close() throws { - try console?.reset() - } - - static func create(tty: Bool, interactive: Bool, detach: Bool) throws -> ProcessIO { - let current: Terminal? = try { - if !tty { - return nil - } - let current = try Terminal.current - try current.setraw() - return current - }() - - var stdio = [FileHandle?](repeating: nil, count: 3) - - let stdin: Pipe? = { - if !interactive && !tty { - return nil - } - return Pipe() - }() - - if let stdin { - if interactive { - let pin = FileHandle.standardInput - pin.readabilityHandler = { handle in - let data = handle.availableData - if data.isEmpty { - pin.readabilityHandler = nil - return - } - try! stdin.fileHandleForWriting.write(contentsOf: data) - } - } - stdio[0] = stdin.fileHandleForReading - } - - let stdout: Pipe? = { - if detach { - return nil - } - return Pipe() - }() - - var configuredStreams = 0 - let (stream, cc) = AsyncStream.makeStream() - if let stdout { - configuredStreams += 1 - let pout: FileHandle = { - if let current { - return current.handle - } - return .standardOutput - }() - - let rout = stdout.fileHandleForReading - rout.readabilityHandler = { handle in - let data = handle.availableData - if data.isEmpty { - rout.readabilityHandler = nil - cc.yield() - return - } - try! pout.write(contentsOf: data) - } - stdio[1] = stdout.fileHandleForWriting - } - - let stderr: Pipe? = { - if detach || tty { - return nil - } - return Pipe() - }() - if let stderr { - configuredStreams += 1 - let perr: FileHandle = .standardError - let rerr = stderr.fileHandleForReading - rerr.readabilityHandler = { handle in - let data = handle.availableData - if data.isEmpty { - rerr.readabilityHandler = nil - cc.yield() - return - } - try! perr.write(contentsOf: data) - } - stdio[2] = stderr.fileHandleForWriting - } - - var ioTracker: IoTracker? = nil - if configuredStreams > 0 { - ioTracker = .init(stream: stream, cont: cc, configuredStreams: configuredStreams) - } - - return .init( - stdin: stdin, - stdout: stdout, - stderr: stderr, - ioTracker: ioTracker, - stdio: stdio, - console: current - ) - } - - public func wait() async throws { - guard let ioTracker = self.ioTracker else { - return - } - do { - try await Timeout.run(seconds: 3) { - var counter = ioTracker.configuredStreams - for await _ in ioTracker.stream { - counter -= 1 - if counter == 0 { - ioTracker.cont.finish() - break - } - } - } - } catch { - log.error("Timeout waiting for IO to complete : \(error)") - throw error - } - } -} diff --git a/Sources/ContainerCLI/System/DNS/DNSCreate.swift b/Sources/ContainerCLI/System/DNS/DNSCreate.swift deleted file mode 100644 index 2dbe2d8ac..000000000 --- a/Sources/ContainerCLI/System/DNS/DNSCreate.swift +++ /dev/null @@ -1,51 +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 ContainerizationError -import ContainerizationExtras -import Foundation - -extension Application { - struct DNSCreate: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "create", - abstract: "Create a local DNS domain for containers (must run as an administrator)" - ) - - @Argument(help: "the local domain name") - var domainName: String - - func run() async throws { - let resolver: HostDNSResolver = HostDNSResolver() - do { - try resolver.createDomain(name: domainName) - print(domainName) - } catch let error as ContainerizationError { - throw error - } catch { - throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") - } - - do { - try HostDNSResolver.reinitialize() - } catch { - throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") - } - } - } -} diff --git a/Sources/ContainerCLI/System/DNS/DNSDefault.swift b/Sources/ContainerCLI/System/DNS/DNSDefault.swift deleted file mode 100644 index 5a746eab5..000000000 --- a/Sources/ContainerCLI/System/DNS/DNSDefault.swift +++ /dev/null @@ -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 ContainerClient - -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 { - ClientDefaults.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 { - ClientDefaults.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(ClientDefaults.getOptional(key: .defaultDNSDomain) ?? "") - } - } - } -} diff --git a/Sources/ContainerCLI/System/DNS/DNSDelete.swift b/Sources/ContainerCLI/System/DNS/DNSDelete.swift deleted file mode 100644 index b3360bb57..000000000 --- a/Sources/ContainerCLI/System/DNS/DNSDelete.swift +++ /dev/null @@ -1,49 +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 ContainerizationError -import Foundation - -extension Application { - struct DNSDelete: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "delete", - abstract: "Delete a local DNS domain (must run as an administrator)", - aliases: ["rm"] - ) - - @Argument(help: "the local domain name") - var domainName: String - - func run() async throws { - let resolver = HostDNSResolver() - do { - try resolver.deleteDomain(name: domainName) - print(domainName) - } catch { - throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") - } - - do { - try HostDNSResolver.reinitialize() - } catch { - throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") - } - } - } -} diff --git a/Sources/ContainerCLI/System/DNS/DNSList.swift b/Sources/ContainerCLI/System/DNS/DNSList.swift deleted file mode 100644 index 616415775..000000000 --- a/Sources/ContainerCLI/System/DNS/DNSList.swift +++ /dev/null @@ -1,36 +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 Foundation - -extension Application { - struct DNSList: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List local DNS domains", - aliases: ["ls"] - ) - - func run() async throws { - let resolver: HostDNSResolver = HostDNSResolver() - let domains = resolver.listDomains() - print(domains.joined(separator: "\n")) - } - - } -} diff --git a/Sources/ContainerCLI/System/Kernel/KernelSet.swift b/Sources/ContainerCLI/System/Kernel/KernelSet.swift deleted file mode 100644 index 6a1ac1790..000000000 --- a/Sources/ContainerCLI/System/Kernel/KernelSet.swift +++ /dev/null @@ -1,114 +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 Containerization -import ContainerizationError -import ContainerizationExtras -import ContainerizationOCI -import Foundation -import TerminalProgress - -extension Application { - struct KernelSet: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "set", - abstract: "Set the default kernel" - ) - - @Option(name: .customLong("binary"), help: "Path to the binary to set as the default kernel. If used with --tar, this points to a location inside the tar") - var binaryPath: String? = nil - - @Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar ball that contains the kernel to use") - var tarPath: String? = nil - - @Option(name: .customLong("arch"), help: "The architecture of the kernel binary. One of (amd64, arm64)") - var architecture: String = ContainerizationOCI.Platform.current.architecture.description - - @Flag(name: .customLong("recommended"), help: "Download and install the recommended kernel as the default. This flag ignores any other arguments") - var recommended: Bool = false - - func run() async throws { - if recommended { - let url = ClientDefaults.get(key: .defaultKernelURL) - let path = ClientDefaults.get(key: .defaultKernelBinaryPath) - print("Installing the recommended kernel from \(url)...") - try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path) - return - } - guard tarPath != nil else { - return try await self.setKernelFromBinary() - } - try await self.setKernelFromTar() - } - - private func setKernelFromBinary() async throws { - guard let binaryPath else { - throw ArgumentParser.ValidationError("Missing argument '--binary'") - } - let absolutePath = URL(fileURLWithPath: binaryPath, relativeTo: .currentDirectory()).absoluteURL.absoluteString - let platform = try getSystemPlatform() - try await ClientKernel.installKernel(kernelFilePath: absolutePath, platform: platform) - } - - private func setKernelFromTar() async throws { - guard let binaryPath else { - throw ArgumentParser.ValidationError("Missing argument '--binary'") - } - guard let tarPath else { - throw ArgumentParser.ValidationError("Missing argument '--tar") - } - let platform = try getSystemPlatform() - let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).absoluteString - let fm = FileManager.default - if fm.fileExists(atPath: localTarPath) { - try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform) - return - } - guard let remoteURL = URL(string: tarPath) else { - throw ContainerizationError(.invalidArgument, message: "Invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?") - } - try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL.absoluteString, kernelFilePath: binaryPath, platform: platform) - } - - private func getSystemPlatform() throws -> SystemPlatform { - switch architecture { - case "arm64": - return .linuxArm - case "amd64": - return .linuxAmd - default: - throw ContainerizationError(.unsupported, message: "Unsupported architecture \(architecture)") - } - } - - public static func downloadAndInstallWithProgressBar(tarRemoteURL: String, kernelFilePath: String, platform: SystemPlatform = .current) async throws { - let progressConfig = try ProgressConfig( - showTasks: true, - totalTasks: 2 - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - try await ClientKernel.installKernelFromTar(tarFile: tarRemoteURL, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler) - progress.finish() - } - - } -} diff --git a/Sources/ContainerCLI/System/SystemCommand.swift b/Sources/ContainerCLI/System/SystemCommand.swift deleted file mode 100644 index 3a92bfb92..000000000 --- a/Sources/ContainerCLI/System/SystemCommand.swift +++ /dev/null @@ -1,35 +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 - -extension Application { - struct SystemCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "system", - abstract: "Manage system components", - subcommands: [ - SystemDNS.self, - SystemLogs.self, - SystemStart.self, - SystemStop.self, - SystemStatus.self, - SystemKernel.self, - ], - aliases: ["s"] - ) - } -} diff --git a/Sources/ContainerCLI/System/SystemDNS.swift b/Sources/ContainerCLI/System/SystemDNS.swift deleted file mode 100644 index 4f9b3e3b3..000000000 --- a/Sources/ContainerCLI/System/SystemDNS.swift +++ /dev/null @@ -1,34 +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 ContainerizationError -import Foundation - -extension Application { - struct SystemDNS: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "dns", - abstract: "Manage local DNS domains", - subcommands: [ - DNSCreate.self, - DNSDelete.self, - DNSList.self, - DNSDefault.self, - ] - ) - } -} diff --git a/Sources/ContainerCLI/System/SystemKernel.swift b/Sources/ContainerCLI/System/SystemKernel.swift deleted file mode 100644 index 942bd6965..000000000 --- a/Sources/ContainerCLI/System/SystemKernel.swift +++ /dev/null @@ -1,29 +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 - -extension Application { - struct SystemKernel: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "kernel", - abstract: "Manage the default kernel configuration", - subcommands: [ - KernelSet.self - ] - ) - } -} diff --git a/Sources/ContainerCLI/System/SystemLogs.swift b/Sources/ContainerCLI/System/SystemLogs.swift deleted file mode 100644 index e2b87ffb9..000000000 --- a/Sources/ContainerCLI/System/SystemLogs.swift +++ /dev/null @@ -1,82 +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 ContainerizationError -import ContainerizationOS -import Foundation -import OSLog - -extension Application { - struct SystemLogs: AsyncParsableCommand { - static let subsystem = "com.apple.container" - - static let configuration = CommandConfiguration( - commandName: "logs", - abstract: "Fetch system logs for `container` services" - ) - - @OptionGroup - var global: Flags.Global - - @Option( - name: .long, - help: "Fetch logs starting from the specified time period (minus the current time); supported formats: m, h, d" - ) - var last: String = "5m" - - @Flag(name: .shortAndLong, help: "Follow log output") - var follow: Bool = false - - func run() async throws { - let process = Process() - let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM]) - - Task { - for await _ in sigHandler.signals { - process.terminate() - Darwin.exit(0) - } - } - - do { - var args = ["log"] - args.append(self.follow ? "stream" : "show") - args.append(contentsOf: ["--info", "--debug"]) - if !self.follow { - args.append(contentsOf: ["--last", last]) - } - args.append(contentsOf: ["--predicate", "subsystem = 'com.apple.container'"]) - - process.launchPath = "/usr/bin/env" - process.arguments = args - - process.standardOutput = FileHandle.standardOutput - process.standardError = FileHandle.standardError - - try process.run() - process.waitUntilExit() - } catch { - throw ContainerizationError( - .invalidArgument, - message: "failed to system logs: \(error)" - ) - } - throw ArgumentParser.ExitCode(process.terminationStatus) - } - } -} diff --git a/Sources/ContainerCLI/System/SystemStart.swift b/Sources/ContainerCLI/System/SystemStart.swift deleted file mode 100644 index acce91391..000000000 --- a/Sources/ContainerCLI/System/SystemStart.swift +++ /dev/null @@ -1,170 +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 ContainerPlugin -import ContainerizationError -import Foundation -import TerminalProgress - -extension Application { - struct SystemStart: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "start", - abstract: "Start `container` services" - ) - - @Option(name: .shortAndLong, help: "Path to the `container-apiserver` binary") - var path: String = Bundle.main.executablePath ?? "" - - @Flag(name: .long, help: "Enable debug logging for the runtime daemon.") - var debug = false - - @Flag( - name: .long, inversion: .prefixedEnableDisable, - help: "Specify whether the default kernel should be installed or not. The default behavior is to prompt the user for a response.") - var kernelInstall: Bool? - - func run() async throws { - // Without the true path to the binary in the plist, `container-apiserver` won't launch properly. - let executableUrl = URL(filePath: path) - .resolvingSymlinksInPath() - .deletingLastPathComponent() - .appendingPathComponent("container-apiserver") - - var args = [executableUrl.absolutePath()] - if debug { - args.append("--debug") - } - - let apiServerDataUrl = appRoot.appending(path: "apiserver") - try! FileManager.default.createDirectory(at: apiServerDataUrl, withIntermediateDirectories: true) - let env = ProcessInfo.processInfo.environment.filter { key, _ in - key.hasPrefix("CONTAINER_") - } - - let logURL = apiServerDataUrl.appending(path: "apiserver.log") - let plist = LaunchPlist( - label: "com.apple.container.apiserver", - arguments: args, - environment: env, - limitLoadToSessionType: [.Aqua, .Background, .System], - runAtLoad: true, - stdout: logURL.path, - stderr: logURL.path, - machServices: ["com.apple.container.apiserver"] - ) - - let plistURL = apiServerDataUrl.appending(path: "apiserver.plist") - let data = try plist.encode() - try data.write(to: plistURL) - - try ServiceManager.register(plistPath: plistURL.path) - - // Now ping our friendly daemon. Fail if we don't get a response. - do { - print("Verifying apiserver is running...") - try await ClientHealthCheck.ping(timeout: .seconds(10)) - } catch { - throw ContainerizationError( - .internalError, - message: "failed to get a response from apiserver: \(error)" - ) - } - - if await !initImageExists() { - try? await installInitialFilesystem() - } - - guard await !kernelExists() else { - return - } - try await installDefaultKernel() - } - - private func installInitialFilesystem() async throws { - let dep = Dependencies.initFs - let pullCommand = ImagePull(reference: dep.source) - print("Installing base container filesystem...") - do { - try await pullCommand.run() - } catch { - log.error("Failed to install base container filesystem: \(error)") - } - } - - private func installDefaultKernel() async throws { - let kernelDependency = Dependencies.kernel - let defaultKernelURL = kernelDependency.source - let defaultKernelBinaryPath = ClientDefaults.get(key: .defaultKernelBinaryPath) - - var shouldInstallKernel = false - if kernelInstall == nil { - print("No default kernel configured.") - print("Install the recommended default kernel from [\(kernelDependency.source)]? [Y/n]: ", terminator: "") - guard let read = readLine(strippingNewline: true) else { - throw ContainerizationError(.internalError, message: "Failed to read user input") - } - guard read.lowercased() == "y" || read.count == 0 else { - print("Please use the `container system kernel set --recommended` command to configure the default kernel") - return - } - shouldInstallKernel = true - } else { - shouldInstallKernel = kernelInstall ?? false - } - guard shouldInstallKernel else { - return - } - print("Installing kernel...") - try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: defaultKernelURL, kernelFilePath: defaultKernelBinaryPath) - } - - private func initImageExists() async -> Bool { - do { - let img = try await ClientImage.get(reference: Dependencies.initFs.source) - let _ = try await img.getSnapshot(platform: .current) - return true - } catch { - return false - } - } - - private func kernelExists() async -> Bool { - do { - try await ClientKernel.getDefaultKernel(for: .current) - return true - } catch { - return false - } - } - } - - private enum Dependencies: String { - case kernel - case initFs - - var source: String { - switch self { - case .initFs: - return ClientDefaults.get(key: .defaultInitImage) - case .kernel: - return ClientDefaults.get(key: .defaultKernelURL) - } - } - } -} diff --git a/Sources/ContainerCLI/System/SystemStatus.swift b/Sources/ContainerCLI/System/SystemStatus.swift deleted file mode 100644 index 132607681..000000000 --- a/Sources/ContainerCLI/System/SystemStatus.swift +++ /dev/null @@ -1,52 +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 ContainerPlugin -import ContainerizationError -import Foundation -import Logging - -extension Application { - struct SystemStatus: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "status", - abstract: "Show the status of `container` services" - ) - - @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") - var prefix: String = "com.apple.container." - - func run() async throws { - let isRegistered = try ServiceManager.isRegistered(fullServiceLabel: "\(prefix)apiserver") - if !isRegistered { - print("apiserver is not running and not registered with launchd") - Application.exit(withError: ExitCode(1)) - } - - // Now ping our friendly daemon. Fail after 10 seconds with no response. - do { - print("Verifying apiserver is running...") - try await ClientHealthCheck.ping(timeout: .seconds(10)) - print("apiserver is running") - } catch { - print("apiserver is not running") - Application.exit(withError: ExitCode(1)) - } - } - } -} diff --git a/Sources/ContainerCLI/System/SystemStop.swift b/Sources/ContainerCLI/System/SystemStop.swift deleted file mode 100644 index 32824dd0c..000000000 --- a/Sources/ContainerCLI/System/SystemStop.swift +++ /dev/null @@ -1,91 +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 ContainerPlugin -import ContainerizationOS -import Foundation -import Logging - -extension Application { - struct SystemStop: AsyncParsableCommand { - private static let stopTimeoutSeconds: Int32 = 5 - private static let shutdownTimeoutSeconds: Int32 = 20 - - static let configuration = CommandConfiguration( - commandName: "stop", - abstract: "Stop all `container` services" - ) - - @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") - var prefix: String = "com.apple.container." - - func run() async throws { - let log = Logger( - label: "com.apple.container.cli", - factory: { label in - StreamLogHandler.standardOutput(label: label) - } - ) - - let launchdDomainString = try ServiceManager.getDomainString() - let fullLabel = "\(launchdDomainString)/\(prefix)apiserver" - - log.info("stopping containers", metadata: ["stopTimeoutSeconds": "\(Self.stopTimeoutSeconds)"]) - do { - let containers = try await ClientContainer.list() - let signal = try Signals.parseSignal("SIGTERM") - let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: signal) - let failed = try await ContainerStop.stopContainers(containers: containers, stopOptions: opts) - if !failed.isEmpty { - log.warning("some containers could not be stopped gracefully", metadata: ["ids": "\(failed)"]) - } - - } catch { - log.warning("failed to stop all containers", metadata: ["error": "\(error)"]) - } - - log.info("waiting for containers to exit") - do { - for _ in 0.. Date: Tue, 8 Jul 2025 23:22:13 -0700 Subject: [PATCH 32/80] Update Package.swift --- Package.swift | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Package.swift b/Package.swift index 291adc195..40a03f0b7 100644 --- a/Package.swift +++ b/Package.swift @@ -84,26 +84,26 @@ let package = Package( ], path: "Sources/CLI" ), -// .target( -// name: "ContainerCLI", -// dependencies: [ -// .product(name: "ArgumentParser", package: "swift-argument-parser"), -// .product(name: "Logging", package: "swift-log"), -// .product(name: "SwiftProtobuf", package: "swift-protobuf"), -// .product(name: "Containerization", package: "containerization"), -// .product(name: "ContainerizationOCI", package: "containerization"), -// .product(name: "ContainerizationOS", package: "containerization"), -// "CVersion", -// "TerminalProgress", -// "ContainerBuild", -// "ContainerClient", -// "ContainerPlugin", -// "ContainerLog", -// "Yams", -// "Rainbow", -// ], -// path: "Sources/ContainerCLI" -// ), + .target( + name: "ContainerCLI", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "SwiftProtobuf", package: "swift-protobuf"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + "CVersion", + "TerminalProgress", + "ContainerBuild", + "ContainerClient", + "ContainerPlugin", + "ContainerLog", + "Yams", + "Rainbow", + ], + path: "Sources/ContainerCLI" + ), .executableTarget( name: "container-apiserver", dependencies: [ From 08946113c269ee752e789f5cf3409c6d51e724eb Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:22:48 -0700 Subject: [PATCH 33/80] Update ContainerCLI --- Sources/ContainerCLI | Bin 916 -> 916 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI index ce9b7e01c3c1838dc5198f48c2a927a02ac3d673..6c3354e6261006b2d823f861983b6eee8933a906 100644 GIT binary patch delta 16 XcmbQjK81aP2J60q+!Bf#b<>#vEh+^6 delta 16 XcmbQjK81aP25Zg7RBwfiy6MaSFZ%^~ From 159ce20034d9b9c5f58be2d2f53192eeb391b584 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:24:54 -0700 Subject: [PATCH 34/80] expose cli fixes --- Package.swift | 22 +--------------------- Sources/ContainerCLI | Bin 916 -> 0 bytes 2 files changed, 1 insertion(+), 21 deletions(-) delete mode 100644 Sources/ContainerCLI diff --git a/Package.swift b/Package.swift index 40a03f0b7..8fd3e6ef3 100644 --- a/Package.swift +++ b/Package.swift @@ -42,7 +42,7 @@ let package = Package( .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService"]), .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerClient", targets: ["ContainerClient"]), -// .library(name: "ContainerCLI", targets: ["ContainerCLI"]), + .executable(name: "ContainerCLI", targets: ["container"]), .library(name: "ContainerBuild", targets: ["ContainerBuild"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), @@ -84,26 +84,6 @@ let package = Package( ], path: "Sources/CLI" ), - .target( - name: "ContainerCLI", - dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "Logging", package: "swift-log"), - .product(name: "SwiftProtobuf", package: "swift-protobuf"), - .product(name: "Containerization", package: "containerization"), - .product(name: "ContainerizationOCI", package: "containerization"), - .product(name: "ContainerizationOS", package: "containerization"), - "CVersion", - "TerminalProgress", - "ContainerBuild", - "ContainerClient", - "ContainerPlugin", - "ContainerLog", - "Yams", - "Rainbow", - ], - path: "Sources/ContainerCLI" - ), .executableTarget( name: "container-apiserver", dependencies: [ diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI deleted file mode 100644 index 6c3354e6261006b2d823f861983b6eee8933a906..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 916 zcmZvaO)mpc6o&6qQIRlOp*y3oZCd@B)~X)_iNvC7VT3anwIM z-pcemA<`~n;S5k6U=WL<2vKa9b^|=be$B9}#?Ehk)37QgawD8KtolKzS~qR@!yL<7 zhqhrh1huA~xwTc{Cq`2`kOqaJG*o~pkOy*OLuVNPlYWK{^T+pWj`jK7%k@70_^RJO zXJ0=54As}rE!2eCPzR#t(Ht@@CHJ-wxN+(+Bm5kjVUQ~Y6%0+z5U=@jW{Q!?+&hxg zU%}9}t9#}`C=_yh#A_vEJ+DO)@_mK4iyoo&=R8jz_ZRTzXg}(+(b!Tptn0y;o{9xy z89g3MbKIo~@yR?0-e*?9C(IMz zTV^l#jM)diX4b&Z%we$XuQYK7IX(eK$}w*fGvT<>{;gNR^7~G|_U Date: Tue, 8 Jul 2025 23:50:48 -0700 Subject: [PATCH 35/80] Moved container executable to its own file, allowing the exposure of CLI as a library --- Package.swift | 10 ++++- Sources/ExecutableCLI/Application.swift | 52 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 Sources/ExecutableCLI/Application.swift diff --git a/Package.swift b/Package.swift index 8fd3e6ef3..6c3bed66d 100644 --- a/Package.swift +++ b/Package.swift @@ -42,7 +42,7 @@ let package = Package( .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService"]), .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerClient", targets: ["ContainerClient"]), - .executable(name: "ContainerCLI", targets: ["container"]), + .library(name: "ContainerCLI", targets: ["ContainerCLI"]), .library(name: "ContainerBuild", targets: ["ContainerBuild"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), @@ -66,6 +66,14 @@ let package = Package( targets: [ .executableTarget( name: "container", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + "ContainerCLI", + ], + path: "Sources/ExecutableCLI" + ), + .target( + name: "ContainerCLI", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), diff --git a/Sources/ExecutableCLI/Application.swift b/Sources/ExecutableCLI/Application.swift new file mode 100644 index 000000000..4ce5bdc35 --- /dev/null +++ b/Sources/ExecutableCLI/Application.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerCLI +import ArgumentParser +import CVersion +import ContainerClient +import ContainerLog +import ContainerPlugin +import ContainerizationError +import ContainerizationOS +import Foundation +import Logging +import TerminalProgress + +@main +public struct Executable: AsyncParsableCommand { + public init() {} + +// @OptionGroup +// var global: Flags.Global + + public static let configuration = CommandConfiguration( + commandName: "container", + abstract: "A container platform for macOS", + subcommands: [ + ] + ) + + public static func main() async throws { +// try await Application.main() + } + + public func validate() throws { +// try Application.validate() + } +} From 826523dd46ffc2b8579627d73e618a02a668ddcb Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:59:36 -0700 Subject: [PATCH 36/80] =?UTF-8?q?Moved=20container=20executable=20to=20its?= =?UTF-8?q?=20own=20file,=20allowing=20the=20exposure=20of=20CLI=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Package.swift | 1 + Sources/CLI/Application.swift | 3 +- .../{Application.swift => Executable.swift} | 32 +++++++------------ 3 files changed, 13 insertions(+), 23 deletions(-) rename Sources/ExecutableCLI/{Application.swift => Executable.swift} (64%) diff --git a/Package.swift b/Package.swift index 6c3bed66d..398a1edf8 100644 --- a/Package.swift +++ b/Package.swift @@ -68,6 +68,7 @@ let package = Package( name: "container", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), + "ContainerClient", "ContainerCLI", ], path: "Sources/ExecutableCLI" diff --git a/Sources/CLI/Application.swift b/Sources/CLI/Application.swift index ba002a74c..daad61d56 100644 --- a/Sources/CLI/Application.swift +++ b/Sources/CLI/Application.swift @@ -40,12 +40,11 @@ nonisolated(unsafe) var log = { return log }() -@main public struct Application: AsyncParsableCommand { public init() {} @OptionGroup - var global: Flags.Global + public var global: Flags.Global public static let configuration = CommandConfiguration( commandName: "container", diff --git a/Sources/ExecutableCLI/Application.swift b/Sources/ExecutableCLI/Executable.swift similarity index 64% rename from Sources/ExecutableCLI/Application.swift rename to Sources/ExecutableCLI/Executable.swift index 4ce5bdc35..56bcf0da5 100644 --- a/Sources/ExecutableCLI/Application.swift +++ b/Sources/ExecutableCLI/Executable.swift @@ -16,37 +16,27 @@ // -//import ContainerCLI -import ArgumentParser -import CVersion +import ContainerCLI import ContainerClient -import ContainerLog -import ContainerPlugin -import ContainerizationError -import ContainerizationOS -import Foundation -import Logging -import TerminalProgress +import ArgumentParser @main public struct Executable: AsyncParsableCommand { public init() {} -// @OptionGroup -// var global: Flags.Global + @OptionGroup + var global: Flags.Global - public static let configuration = CommandConfiguration( - commandName: "container", - abstract: "A container platform for macOS", - subcommands: [ - ] - ) + public static let configuration = Application.configuration public static func main() async throws { -// try await Application.main() + try await Application.main() } - public func validate() throws { -// try Application.validate() + public func run() async throws { + var application = Application() + application.global = global + try application.validate() + try application.run() } } From 7ca17a21d51e80a5806098b5cefa6cc3ced0f92b Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:59:53 -0600 Subject: [PATCH 37/80] =?UTF-8?q?Revert=20"Moved=20container=20executable?= =?UTF-8?q?=20to=20its=20own=20file,=20allowing=20the=20exposure=20of=20CL?= =?UTF-8?q?I=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 826523dd46ffc2b8579627d73e618a02a668ddcb. --- Package.swift | 1 - Sources/CLI/Application.swift | 3 +- .../{Executable.swift => Application.swift} | 32 ++++++++++++------- 3 files changed, 23 insertions(+), 13 deletions(-) rename Sources/ExecutableCLI/{Executable.swift => Application.swift} (64%) diff --git a/Package.swift b/Package.swift index db04a81f4..d3ea2b9ba 100644 --- a/Package.swift +++ b/Package.swift @@ -60,7 +60,6 @@ let package = Package( name: "container", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), - "ContainerClient", "ContainerCLI", ], path: "Sources/ExecutableCLI" diff --git a/Sources/CLI/Application.swift b/Sources/CLI/Application.swift index b3063d542..fe925b496 100644 --- a/Sources/CLI/Application.swift +++ b/Sources/CLI/Application.swift @@ -40,11 +40,12 @@ nonisolated(unsafe) var log = { return log }() +@main public struct Application: AsyncParsableCommand { public init() {} @OptionGroup - public var global: Flags.Global + var global: Flags.Global public static let configuration = CommandConfiguration( commandName: "container", diff --git a/Sources/ExecutableCLI/Executable.swift b/Sources/ExecutableCLI/Application.swift similarity index 64% rename from Sources/ExecutableCLI/Executable.swift rename to Sources/ExecutableCLI/Application.swift index 56bcf0da5..4ce5bdc35 100644 --- a/Sources/ExecutableCLI/Executable.swift +++ b/Sources/ExecutableCLI/Application.swift @@ -16,27 +16,37 @@ // -import ContainerCLI -import ContainerClient +//import ContainerCLI import ArgumentParser +import CVersion +import ContainerClient +import ContainerLog +import ContainerPlugin +import ContainerizationError +import ContainerizationOS +import Foundation +import Logging +import TerminalProgress @main public struct Executable: AsyncParsableCommand { public init() {} - @OptionGroup - var global: Flags.Global +// @OptionGroup +// var global: Flags.Global - public static let configuration = Application.configuration + public static let configuration = CommandConfiguration( + commandName: "container", + abstract: "A container platform for macOS", + subcommands: [ + ] + ) public static func main() async throws { - try await Application.main() +// try await Application.main() } - public func run() async throws { - var application = Application() - application.global = global - try application.validate() - try application.run() + public func validate() throws { +// try Application.validate() } } From 1b329d90421f00986c80c582acb1cd818c0ea57e Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:59:54 -0600 Subject: [PATCH 38/80] Revert "Moved container executable to its own file, allowing the exposure of CLI as a library" This reverts commit 237b028a8e15321fc7667318936f5a1a72d90789. --- Package.swift | 10 +---- Sources/ExecutableCLI/Application.swift | 52 ------------------------- 2 files changed, 1 insertion(+), 61 deletions(-) delete mode 100644 Sources/ExecutableCLI/Application.swift diff --git a/Package.swift b/Package.swift index d3ea2b9ba..80804ef95 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,7 @@ let package = Package( .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService"]), .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerClient", targets: ["ContainerClient"]), - .library(name: "ContainerCLI", targets: ["ContainerCLI"]), + .executable(name: "ContainerCLI", targets: ["container"]), .library(name: "ContainerBuild", targets: ["ContainerBuild"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), @@ -58,14 +58,6 @@ let package = Package( targets: [ .executableTarget( name: "container", - dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser"), - "ContainerCLI", - ], - path: "Sources/ExecutableCLI" - ), - .target( - name: "ContainerCLI", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), diff --git a/Sources/ExecutableCLI/Application.swift b/Sources/ExecutableCLI/Application.swift deleted file mode 100644 index 4ce5bdc35..000000000 --- a/Sources/ExecutableCLI/Application.swift +++ /dev/null @@ -1,52 +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 ContainerCLI -import ArgumentParser -import CVersion -import ContainerClient -import ContainerLog -import ContainerPlugin -import ContainerizationError -import ContainerizationOS -import Foundation -import Logging -import TerminalProgress - -@main -public struct Executable: AsyncParsableCommand { - public init() {} - -// @OptionGroup -// var global: Flags.Global - - public static let configuration = CommandConfiguration( - commandName: "container", - abstract: "A container platform for macOS", - subcommands: [ - ] - ) - - public static func main() async throws { -// try await Application.main() - } - - public func validate() throws { -// try Application.validate() - } -} From a359cf36fc779fbcbe6d5cdff71e4e624f7f3523 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:59:56 -0600 Subject: [PATCH 39/80] Revert "expose cli fixes" This reverts commit 159ce20034d9b9c5f58be2d2f53192eeb391b584. --- Package.swift | 22 +++++++++++++++++++++- Sources/ContainerCLI | Bin 0 -> 916 bytes 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 Sources/ContainerCLI diff --git a/Package.swift b/Package.swift index 80804ef95..3108e64f4 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,7 @@ let package = Package( .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService"]), .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerClient", targets: ["ContainerClient"]), - .executable(name: "ContainerCLI", targets: ["container"]), +// .library(name: "ContainerCLI", targets: ["ContainerCLI"]), .library(name: "ContainerBuild", targets: ["ContainerBuild"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), @@ -76,6 +76,26 @@ let package = Package( ], path: "Sources/CLI" ), + .target( + name: "ContainerCLI", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "SwiftProtobuf", package: "swift-protobuf"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + "CVersion", + "TerminalProgress", + "ContainerBuild", + "ContainerClient", + "ContainerPlugin", + "ContainerLog", + "Yams", + "Rainbow", + ], + path: "Sources/ContainerCLI" + ), .executableTarget( name: "container-apiserver", dependencies: [ diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI new file mode 100644 index 0000000000000000000000000000000000000000..6c3354e6261006b2d823f861983b6eee8933a906 GIT binary patch literal 916 zcmZvaO)mpc6o&6qQIRlOp*y3oZCd@B)~X)_iNvC7VT3anwIM z-pcemA<`~n;S5k6U=WL<2vKa9b^|=be$B9}#?Ehk)37QgawD8KtolKzS~qR@!yL<7 zhqhrh1huA~xwTc{Cq`2`kOqaJG*o~pkOy*OLuVNPlYWK{^T+pWj`jK7%k@70_^RJO zXJ0=54As}rE!2eCPzR#t(Ht@@CHJ-wxN+(+Bm5kjVUQ~Y6%0+z5U=@jW{Q!?+&hxg zU%}9}t9#}`C=_yh#A_vEJ+DO)@_mK4iyoo&=R8jz_ZRTzXg}(+(b!Tptn0y;o{9xy z89g3MbKIo~@yR?0-e*?9C(IMz zTV^l#jM)diX4b&Z%we$XuQYK7IX(eK$}w*fGvT<>{;gNR^7~G|_U Date: Tue, 15 Jul 2025 20:59:59 -0600 Subject: [PATCH 40/80] Revert "Update ContainerCLI" This reverts commit 08946113c269ee752e789f5cf3409c6d51e724eb. --- Sources/ContainerCLI | Bin 916 -> 916 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI index 6c3354e6261006b2d823f861983b6eee8933a906..ce9b7e01c3c1838dc5198f48c2a927a02ac3d673 100644 GIT binary patch delta 16 XcmbQjK81aP25Zg7RBwfiy6MaSFZ%^~ delta 16 XcmbQjK81aP2J60q+!Bf#b<>#vEh+^6 From 030830be9397a358e4b2e9c39d4df77bcb2cc163 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:00:04 -0600 Subject: [PATCH 41/80] Revert "Update Package.swift" This reverts commit 8e70e817b3a6b084c38531b490b61fdd2e3f70f7. --- Package.swift | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Package.swift b/Package.swift index 3108e64f4..a6be60538 100644 --- a/Package.swift +++ b/Package.swift @@ -76,26 +76,26 @@ let package = Package( ], path: "Sources/CLI" ), - .target( - name: "ContainerCLI", - dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "Logging", package: "swift-log"), - .product(name: "SwiftProtobuf", package: "swift-protobuf"), - .product(name: "Containerization", package: "containerization"), - .product(name: "ContainerizationOCI", package: "containerization"), - .product(name: "ContainerizationOS", package: "containerization"), - "CVersion", - "TerminalProgress", - "ContainerBuild", - "ContainerClient", - "ContainerPlugin", - "ContainerLog", - "Yams", - "Rainbow", - ], - path: "Sources/ContainerCLI" - ), +// .target( +// name: "ContainerCLI", +// dependencies: [ +// .product(name: "ArgumentParser", package: "swift-argument-parser"), +// .product(name: "Logging", package: "swift-log"), +// .product(name: "SwiftProtobuf", package: "swift-protobuf"), +// .product(name: "Containerization", package: "containerization"), +// .product(name: "ContainerizationOCI", package: "containerization"), +// .product(name: "ContainerizationOS", package: "containerization"), +// "CVersion", +// "TerminalProgress", +// "ContainerBuild", +// "ContainerClient", +// "ContainerPlugin", +// "ContainerLog", +// "Yams", +// "Rainbow", +// ], +// path: "Sources/ContainerCLI" +// ), .executableTarget( name: "container-apiserver", dependencies: [ From 2924fc013f5040a43d6e8e27f7f72a66159c3c9e Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:00:07 -0600 Subject: [PATCH 42/80] Reapply "copy cli to container cli" This reverts commit 71fbefe3412a8d93815bb9cb63f81839a2d2b801. --- Sources/ContainerCLI | Bin 916 -> 0 bytes Sources/ContainerCLI/Application.swift | 335 ++++++++ Sources/ContainerCLI/BuildCommand.swift | 313 ++++++++ Sources/ContainerCLI/Builder/Builder.swift | 31 + .../ContainerCLI/Builder/BuilderDelete.swift | 57 ++ .../ContainerCLI/Builder/BuilderStart.swift | 262 ++++++ .../ContainerCLI/Builder/BuilderStatus.swift | 71 ++ .../ContainerCLI/Builder/BuilderStop.swift | 49 ++ Sources/ContainerCLI/Codable+JSON.swift | 25 + .../Compose/Codable Structs/Build.swift | 52 ++ .../Compose/Codable Structs/Config.swift | 55 ++ .../Compose/Codable Structs/Deploy.swift | 35 + .../Codable Structs/DeployResources.swift | 31 + .../Codable Structs/DeployRestartPolicy.swift | 35 + .../Codable Structs/DeviceReservation.swift | 35 + .../Codable Structs/DockerCompose.swift | 60 ++ .../Codable Structs/ExternalConfig.swift | 31 + .../Codable Structs/ExternalNetwork.swift | 31 + .../Codable Structs/ExternalSecret.swift | 31 + .../Codable Structs/ExternalVolume.swift | 31 + .../Compose/Codable Structs/Healthcheck.swift | 37 + .../Compose/Codable Structs/Network.swift | 68 ++ .../Codable Structs/ResourceLimits.swift | 31 + .../ResourceReservations.swift | 34 + .../Compose/Codable Structs/Secret.swift | 58 ++ .../Compose/Codable Structs/Service.swift | 203 +++++ .../Codable Structs/ServiceConfig.swift | 64 ++ .../Codable Structs/ServiceSecret.swift | 64 ++ .../Compose/Codable Structs/Volume.swift | 70 ++ .../Compose/Commands/ComposeDown.swift | 105 +++ .../Compose/Commands/ComposeUp.swift | 749 ++++++++++++++++++ .../ContainerCLI/Compose/ComposeCommand.swift | 55 ++ Sources/ContainerCLI/Compose/Errors.swift | 66 ++ .../Compose/Helper Functions.swift | 96 +++ .../Container/ContainerCreate.swift | 100 +++ .../Container/ContainerDelete.swift | 127 +++ .../Container/ContainerExec.swift | 96 +++ .../Container/ContainerInspect.swift | 43 + .../Container/ContainerKill.swift | 79 ++ .../Container/ContainerList.swift | 110 +++ .../Container/ContainerLogs.swift | 144 ++++ .../Container/ContainerStart.swift | 87 ++ .../Container/ContainerStop.swift | 102 +++ .../Container/ContainersCommand.swift | 38 + .../ContainerCLI/Container/ProcessUtils.swift | 31 + Sources/ContainerCLI/DefaultCommand.swift | 54 ++ Sources/ContainerCLI/Image/ImageInspect.swift | 53 ++ Sources/ContainerCLI/Image/ImageList.swift | 175 ++++ Sources/ContainerCLI/Image/ImageLoad.swift | 76 ++ Sources/ContainerCLI/Image/ImagePrune.swift | 38 + Sources/ContainerCLI/Image/ImagePull.swift | 98 +++ Sources/ContainerCLI/Image/ImagePush.swift | 73 ++ Sources/ContainerCLI/Image/ImageRemove.swift | 99 +++ Sources/ContainerCLI/Image/ImageSave.swift | 67 ++ Sources/ContainerCLI/Image/ImageTag.swift | 42 + .../ContainerCLI/Image/ImagesCommand.swift | 38 + .../ContainerCLI/Network/NetworkCommand.swift | 33 + .../ContainerCLI/Network/NetworkCreate.swift | 42 + .../ContainerCLI/Network/NetworkDelete.swift | 116 +++ .../ContainerCLI/Network/NetworkInspect.swift | 44 + .../ContainerCLI/Network/NetworkList.swift | 107 +++ Sources/ContainerCLI/Registry/Login.swift | 92 +++ Sources/ContainerCLI/Registry/Logout.swift | 39 + .../Registry/RegistryCommand.swift | 32 + .../Registry/RegistryDefault.swift | 98 +++ Sources/ContainerCLI/RunCommand.swift | 317 ++++++++ .../ContainerCLI/System/DNS/DNSCreate.swift | 51 ++ .../ContainerCLI/System/DNS/DNSDefault.swift | 72 ++ .../ContainerCLI/System/DNS/DNSDelete.swift | 49 ++ Sources/ContainerCLI/System/DNS/DNSList.swift | 36 + .../System/Kernel/KernelSet.swift | 114 +++ .../ContainerCLI/System/SystemCommand.swift | 35 + Sources/ContainerCLI/System/SystemDNS.swift | 34 + .../ContainerCLI/System/SystemKernel.swift | 29 + Sources/ContainerCLI/System/SystemLogs.swift | 82 ++ Sources/ContainerCLI/System/SystemStart.swift | 170 ++++ .../ContainerCLI/System/SystemStatus.swift | 52 ++ Sources/ContainerCLI/System/SystemStop.swift | 91 +++ 78 files changed, 6775 insertions(+) delete mode 100644 Sources/ContainerCLI create mode 100644 Sources/ContainerCLI/Application.swift create mode 100644 Sources/ContainerCLI/BuildCommand.swift create mode 100644 Sources/ContainerCLI/Builder/Builder.swift create mode 100644 Sources/ContainerCLI/Builder/BuilderDelete.swift create mode 100644 Sources/ContainerCLI/Builder/BuilderStart.swift create mode 100644 Sources/ContainerCLI/Builder/BuilderStatus.swift create mode 100644 Sources/ContainerCLI/Builder/BuilderStop.swift create mode 100644 Sources/ContainerCLI/Codable+JSON.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Build.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Config.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Network.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Secret.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Service.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Volume.swift create mode 100644 Sources/ContainerCLI/Compose/Commands/ComposeDown.swift create mode 100644 Sources/ContainerCLI/Compose/Commands/ComposeUp.swift create mode 100644 Sources/ContainerCLI/Compose/ComposeCommand.swift create mode 100644 Sources/ContainerCLI/Compose/Errors.swift create mode 100644 Sources/ContainerCLI/Compose/Helper Functions.swift create mode 100644 Sources/ContainerCLI/Container/ContainerCreate.swift create mode 100644 Sources/ContainerCLI/Container/ContainerDelete.swift create mode 100644 Sources/ContainerCLI/Container/ContainerExec.swift create mode 100644 Sources/ContainerCLI/Container/ContainerInspect.swift create mode 100644 Sources/ContainerCLI/Container/ContainerKill.swift create mode 100644 Sources/ContainerCLI/Container/ContainerList.swift create mode 100644 Sources/ContainerCLI/Container/ContainerLogs.swift create mode 100644 Sources/ContainerCLI/Container/ContainerStart.swift create mode 100644 Sources/ContainerCLI/Container/ContainerStop.swift create mode 100644 Sources/ContainerCLI/Container/ContainersCommand.swift create mode 100644 Sources/ContainerCLI/Container/ProcessUtils.swift create mode 100644 Sources/ContainerCLI/DefaultCommand.swift create mode 100644 Sources/ContainerCLI/Image/ImageInspect.swift create mode 100644 Sources/ContainerCLI/Image/ImageList.swift create mode 100644 Sources/ContainerCLI/Image/ImageLoad.swift create mode 100644 Sources/ContainerCLI/Image/ImagePrune.swift create mode 100644 Sources/ContainerCLI/Image/ImagePull.swift create mode 100644 Sources/ContainerCLI/Image/ImagePush.swift create mode 100644 Sources/ContainerCLI/Image/ImageRemove.swift create mode 100644 Sources/ContainerCLI/Image/ImageSave.swift create mode 100644 Sources/ContainerCLI/Image/ImageTag.swift create mode 100644 Sources/ContainerCLI/Image/ImagesCommand.swift create mode 100644 Sources/ContainerCLI/Network/NetworkCommand.swift create mode 100644 Sources/ContainerCLI/Network/NetworkCreate.swift create mode 100644 Sources/ContainerCLI/Network/NetworkDelete.swift create mode 100644 Sources/ContainerCLI/Network/NetworkInspect.swift create mode 100644 Sources/ContainerCLI/Network/NetworkList.swift create mode 100644 Sources/ContainerCLI/Registry/Login.swift create mode 100644 Sources/ContainerCLI/Registry/Logout.swift create mode 100644 Sources/ContainerCLI/Registry/RegistryCommand.swift create mode 100644 Sources/ContainerCLI/Registry/RegistryDefault.swift create mode 100644 Sources/ContainerCLI/RunCommand.swift create mode 100644 Sources/ContainerCLI/System/DNS/DNSCreate.swift create mode 100644 Sources/ContainerCLI/System/DNS/DNSDefault.swift create mode 100644 Sources/ContainerCLI/System/DNS/DNSDelete.swift create mode 100644 Sources/ContainerCLI/System/DNS/DNSList.swift create mode 100644 Sources/ContainerCLI/System/Kernel/KernelSet.swift create mode 100644 Sources/ContainerCLI/System/SystemCommand.swift create mode 100644 Sources/ContainerCLI/System/SystemDNS.swift create mode 100644 Sources/ContainerCLI/System/SystemKernel.swift create mode 100644 Sources/ContainerCLI/System/SystemLogs.swift create mode 100644 Sources/ContainerCLI/System/SystemStart.swift create mode 100644 Sources/ContainerCLI/System/SystemStatus.swift create mode 100644 Sources/ContainerCLI/System/SystemStop.swift diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI deleted file mode 100644 index ce9b7e01c3c1838dc5198f48c2a927a02ac3d673..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 916 zcmZvayG{a85Qfis#YR^#YUjq-5|G=16&H;fO)L~bVS~#OiNKOYV`GfI0TyN?bbnot|+K=eGCL%gLVKhi6YPCaIXpJU4ma;2bxp~)HIHT!3#7@5p{B1!!f z3@u+Mn(JP#*YOcAWsUs<%^#NUE5uXa0=50~ynH?1!C$5Qs80lfn+cz;dxCm2=n2O4 zkSCh-M?Hz8KN1eDg*L(oU7r1h_CRm$;gw?4%Zru1gMxR9ZIv)~5v z9Jt2p2G^N2@GG+qEc+`>+(C}df{}7;u8Em&Txk#LRj~ZNi|@U=OB+_eE*{P=_%T-2 diff --git a/Sources/ContainerCLI/Application.swift b/Sources/ContainerCLI/Application.swift new file mode 100644 index 000000000..ba002a74c --- /dev/null +++ b/Sources/ContainerCLI/Application.swift @@ -0,0 +1,335 @@ +//===----------------------------------------------------------------------===// +// 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 CVersion +import ContainerClient +import ContainerLog +import ContainerPlugin +import ContainerizationError +import ContainerizationOS +import Foundation +import Logging +import TerminalProgress + +// `log` is updated only once in the `validate()` method. +nonisolated(unsafe) var log = { + LoggingSystem.bootstrap { label in + OSLogHandler( + label: label, + category: "CLI" + ) + } + var log = Logger(label: "com.apple.container") + log.logLevel = .debug + return log +}() + +@main +public struct Application: AsyncParsableCommand { + public init() {} + + @OptionGroup + var global: Flags.Global + + public static let configuration = CommandConfiguration( + commandName: "container", + abstract: "A container platform for macOS", + version: releaseVersion(), + subcommands: [ + DefaultCommand.self + ], + groupedSubcommands: [ + CommandGroup( + name: "Container", + subcommands: [ + ComposeCommand.self, + ContainerCreate.self, + ContainerDelete.self, + ContainerExec.self, + ContainerInspect.self, + ContainerKill.self, + ContainerList.self, + ContainerLogs.self, + ContainerRunCommand.self, + ContainerStart.self, + ContainerStop.self, + ] + ), + CommandGroup( + name: "Image", + subcommands: [ + BuildCommand.self, + ImagesCommand.self, + RegistryCommand.self, + ] + ), + CommandGroup( + name: "Other", + subcommands: Self.otherCommands() + ), + ], + // Hidden command to handle plugins on unrecognized input. + defaultSubcommand: DefaultCommand.self + ) + + static let appRoot: URL = { + FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + .appendingPathComponent("com.apple.container") + }() + + static let pluginLoader: PluginLoader = { + // create user-installed plugins directory if it doesn't exist + let pluginsURL = PluginLoader.userPluginsDir(root: Self.appRoot) + try! FileManager.default.createDirectory(at: pluginsURL, withIntermediateDirectories: true) + let pluginDirectories = [ + pluginsURL + ] + let pluginFactories = [ + DefaultPluginFactory() + ] + + let statePath = PluginLoader.defaultPluginResourcePath(root: Self.appRoot) + try! FileManager.default.createDirectory(at: statePath, withIntermediateDirectories: true) + return PluginLoader(pluginDirectories: pluginDirectories, pluginFactories: pluginFactories, defaultResourcePath: statePath, log: log) + }() + + public static func main() async throws { + restoreCursorAtExit() + + #if DEBUG + let warning = "Running debug build. Performance may be degraded." + let formattedWarning = "\u{001B}[33mWarning!\u{001B}[0m \(warning)\n" + let warningData = Data(formattedWarning.utf8) + FileHandle.standardError.write(warningData) + #endif + + let fullArgs = CommandLine.arguments + let args = Array(fullArgs.dropFirst()) + + do { + // container -> defaultHelpCommand + var command = try Application.parseAsRoot(args) + if var asyncCommand = command as? AsyncParsableCommand { + try await asyncCommand.run() + } else { + try command.run() + } + } catch { + // Regular ol `command` with no args will get caught by DefaultCommand. --help + // on the root command will land here. + let containsHelp = fullArgs.contains("-h") || fullArgs.contains("--help") + if fullArgs.count <= 2 && containsHelp { + Self.printModifiedHelpText() + return + } + let errorAsString: String = String(describing: error) + if errorAsString.contains("XPC connection error") { + let modifiedError = ContainerizationError(.interrupted, message: "\(error)\nEnsure container system service has been started with `container system start`.") + Application.exit(withError: modifiedError) + } else { + Application.exit(withError: error) + } + } + } + + static func handleProcess(io: ProcessIO, process: ClientProcess) async throws -> Int32 { + let signals = AsyncSignalHandler.create(notify: Application.signalSet) + return try await withThrowingTaskGroup(of: Int32?.self, returning: Int32.self) { group in + let waitAdded = group.addTaskUnlessCancelled { + let code = try await process.wait() + try await io.wait() + return code + } + + guard waitAdded else { + group.cancelAll() + return -1 + } + + try await process.start(io.stdio) + defer { + try? io.close() + } + try io.closeAfterStart() + + if let current = io.console { + let size = try current.size + // It's supremely possible the process could've exited already. We shouldn't treat + // this as fatal. + try? await process.resize(size) + _ = group.addTaskUnlessCancelled { + let winchHandler = AsyncSignalHandler.create(notify: [SIGWINCH]) + for await _ in winchHandler.signals { + do { + try await process.resize(try current.size) + } catch { + log.error( + "failed to send terminal resize event", + metadata: [ + "error": "\(error)" + ] + ) + } + } + return nil + } + } else { + _ = group.addTaskUnlessCancelled { + for await sig in signals.signals { + do { + try await process.kill(sig) + } catch { + log.error( + "failed to send signal", + metadata: [ + "signal": "\(sig)", + "error": "\(error)", + ] + ) + } + } + return nil + } + } + + while true { + let result = try await group.next() + if result == nil { + return -1 + } + let status = result! + if let status { + group.cancelAll() + return status + } + } + return -1 + } + } + + public func validate() throws { + // Not really a "validation", but a cheat to run this before + // any of the commands do their business. + let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"] + if self.global.debug || debugEnvVar != nil { + log.logLevel = .debug + } + // Ensure we're not running under Rosetta. + if try isTranslated() { + throw ValidationError( + """ + `container` is currently running under Rosetta Translation, which could be + caused by your terminal application. Please ensure this is turned off. + """ + ) + } + } + + private static func otherCommands() -> [any ParsableCommand.Type] { + guard #available(macOS 26, *) else { + return [ + BuilderCommand.self, + SystemCommand.self, + ] + } + + return [ + BuilderCommand.self, + NetworkCommand.self, + SystemCommand.self, + ] + } + + private static func restoreCursorAtExit() { + let signalHandler: @convention(c) (Int32) -> Void = { signal in + let exitCode = ExitCode(signal + 128) + Application.exit(withError: exitCode) + } + // Termination by Ctrl+C. + signal(SIGINT, signalHandler) + // Termination using `kill`. + signal(SIGTERM, signalHandler) + // Normal and explicit exit. + atexit { + if let progressConfig = try? ProgressConfig() { + let progressBar = ProgressBar(config: progressConfig) + progressBar.resetCursor() + } + } + } +} + +extension Application { + // Because we support plugins, we need to modify the help text to display + // any if we found some. + static func printModifiedHelpText() { + let altered = Self.pluginLoader.alterCLIHelpText( + original: Application.helpMessage(for: Application.self) + ) + print(altered) + } + + enum ListFormat: String, CaseIterable, ExpressibleByArgument { + case json + case table + } + + static let signalSet: [Int32] = [ + SIGTERM, + SIGINT, + SIGUSR1, + SIGUSR2, + SIGWINCH, + ] + + func isTranslated() throws -> Bool { + do { + return try Sysctl.byName("sysctl.proc_translated") == 1 + } catch let posixErr as POSIXError { + if posixErr.code == .ENOENT { + return false + } + throw posixErr + } + } + + private static func releaseVersion() -> String { + var versionDetails: [String: String] = ["build": "release"] + #if DEBUG + versionDetails["build"] = "debug" + #endif + let gitCommit = { + let sha = get_git_commit().map { String(cString: $0) } + guard let sha else { + return "unspecified" + } + return String(sha.prefix(7)) + }() + versionDetails["commit"] = gitCommit + let extras: String = versionDetails.map { "\($0): \($1)" }.sorted().joined(separator: ", ") + + let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) + let releaseVersion = bundleVersion ?? get_release_version().map { String(cString: $0) } ?? "0.0.0" + + return "container CLI version \(releaseVersion) (\(extras))" + } +} diff --git a/Sources/ContainerCLI/BuildCommand.swift b/Sources/ContainerCLI/BuildCommand.swift new file mode 100644 index 000000000..a1e84c258 --- /dev/null +++ b/Sources/ContainerCLI/BuildCommand.swift @@ -0,0 +1,313 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerBuild +import ContainerClient +import ContainerImagesServiceClient +import Containerization +import ContainerizationError +import ContainerizationOCI +import ContainerizationOS +import Foundation +import NIO +import TerminalProgress + +extension Application { + struct BuildCommand: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "build" + config.abstract = "Build an image from a Dockerfile" + config._superCommandName = "container" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") + public var cpus: Int64 = 2 + + @Option( + name: [.customLong("memory"), .customShort("m")], + help: + "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" + ) + var memory: String = "2048MB" + + @Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val")) + var buildArg: [String] = [] + + @Argument(help: "Build directory") + var contextDir: String = "." + + @Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path")) + var file: String = "Dockerfile" + + @Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val")) + var label: [String] = [] + + @Flag(name: .long, help: "Do not use cache") + var noCache: Bool = false + + @Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build", valueName: "value")) + var output: [String] = { + ["type=oci"] + }() + + @Option(name: .long, help: ArgumentHelp("Cache imports for the build", valueName: "value", visibility: .hidden)) + var cacheIn: [String] = { + [] + }() + + @Option(name: .long, help: ArgumentHelp("Cache exports for the build", valueName: "value", visibility: .hidden)) + var cacheOut: [String] = { + [] + }() + + @Option(name: .long, help: ArgumentHelp("set the build architecture", valueName: "value")) + var arch: [String] = { + ["arm64"] + }() + + @Option(name: .long, help: ArgumentHelp("set the build os", valueName: "value")) + var os: [String] = { + ["linux"] + }() + + @Option(name: .long, help: ArgumentHelp("Progress type - one of [auto|plain|tty]", valueName: "type")) + var progress: String = "auto" + + @Option(name: .long, help: ArgumentHelp("Builder-shim vsock port", valueName: "port")) + var vsockPort: UInt32 = 8088 + + @Option(name: [.customShort("t"), .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) + var targetImageName: String = UUID().uuidString.lowercased() + + @Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage")) + var target: String = "" + + @Flag(name: .shortAndLong, help: "Suppress build output") + var quiet: Bool = false + + func run() async throws { + do { + let timeout: Duration = .seconds(300) + let progressConfig = try ProgressConfig( + showTasks: true, + showItems: true + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + progress.set(description: "Dialing builder") + + let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { group in + defer { + group.cancelAll() + } + + group.addTask { + while true { + do { + let container = try await ClientContainer.get(id: "buildkit") + let fh = try await container.dial(self.vsockPort) + + let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + let b = try Builder(socket: fh, group: threadGroup) + + // If this call succeeds, then BuildKit is running. + let _ = try await b.info() + return b + } catch { + // If we get here, "Dialing builder" is shown for such a short period + // of time that it's invisible to the user. + progress.set(tasks: 0) + progress.set(totalTasks: 3) + + try await BuilderStart.start( + cpus: self.cpus, + memory: self.memory, + progressUpdate: progress.handler + ) + + // wait (seconds) for builder to start listening on vsock + try await Task.sleep(for: .seconds(5)) + continue + } + } + } + + group.addTask { + try await Task.sleep(for: timeout) + throw ValidationError( + """ + Timeout waiting for connection to builder + """ + ) + } + + return try await group.next() + } + + guard let builder else { + throw ValidationError("builder is not running") + } + + let dockerfile = try Data(contentsOf: URL(filePath: file)) + let exportPath = Application.appRoot.appendingPathComponent(".build") + + let buildID = UUID().uuidString + let tempURL = exportPath.appendingPathComponent(buildID) + try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil) + defer { + try? FileManager.default.removeItem(at: tempURL) + } + + let imageName: String = try { + let parsedReference = try Reference.parse(targetImageName) + parsedReference.normalize() + return parsedReference.description + }() + + var terminal: Terminal? + switch self.progress { + case "tty": + terminal = try Terminal(descriptor: STDERR_FILENO) + case "auto": + terminal = try? Terminal(descriptor: STDERR_FILENO) + case "plain": + terminal = nil + default: + throw ContainerizationError(.invalidArgument, message: "invalid progress mode \(self.progress)") + } + + defer { terminal?.tryReset() } + + let exports: [Builder.BuildExport] = try output.map { output in + var exp = try Builder.BuildExport(from: output) + if exp.destination == nil { + exp.destination = tempURL.appendingPathComponent("out.tar") + } + return exp + } + + try await withThrowingTaskGroup(of: Void.self) { [terminal] group in + defer { + group.cancelAll() + } + group.addTask { + let handler = AsyncSignalHandler.create(notify: [SIGTERM, SIGINT, SIGUSR1, SIGUSR2]) + for await sig in handler.signals { + throw ContainerizationError(.interrupted, message: "exiting on signal \(sig)") + } + } + let platforms: [Platform] = try { + var results: [Platform] = [] + for o in self.os { + for a in self.arch { + guard let platform = try? Platform(from: "\(o)/\(a)") else { + throw ValidationError("invalid os/architecture combination \(o)/\(a)") + } + results.append(platform) + } + } + return results + }() + group.addTask { [terminal] in + let config = ContainerBuild.Builder.BuildConfig( + buildID: buildID, + contentStore: RemoteContentStoreClient(), + buildArgs: buildArg, + contextDir: contextDir, + dockerfile: dockerfile, + labels: label, + noCache: noCache, + platforms: platforms, + terminal: terminal, + tag: imageName, + target: target, + quiet: quiet, + exports: exports, + cacheIn: cacheIn, + cacheOut: cacheOut + ) + progress.finish() + + try await builder.build(config) + } + + try await group.next() + } + + let unpackProgressConfig = try ProgressConfig( + description: "Unpacking built image", + itemsName: "entries", + showTasks: exports.count > 1, + totalTasks: exports.count + ) + let unpackProgress = ProgressBar(config: unpackProgressConfig) + defer { + unpackProgress.finish() + } + unpackProgress.start() + + let taskManager = ProgressTaskCoordinator() + // Currently, only a single export can be specified. + for exp in exports { + unpackProgress.add(tasks: 1) + let unpackTask = await taskManager.startTask() + switch exp.type { + case "oci": + try Task.checkCancellation() + guard let dest = exp.destination else { + throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)") + } + let loaded = try await ClientImage.load(from: dest.absolutePath()) + + for image in loaded { + try Task.checkCancellation() + try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler)) + } + case "tar": + break + default: + throw ContainerizationError(.invalidArgument, message: "invalid exporter \(exp.rawValue)") + } + } + await taskManager.finish() + unpackProgress.finish() + print("Successfully built \(imageName)") + } catch { + throw NSError(domain: "Build", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(error)"]) + } + } + + func validate() throws { + guard FileManager.default.fileExists(atPath: file) else { + throw ValidationError("Dockerfile does not exist at path: \(file)") + } + guard FileManager.default.fileExists(atPath: contextDir) else { + throw ValidationError("context dir does not exist \(contextDir)") + } + guard let _ = try? Reference.parse(targetImageName) else { + throw ValidationError("invalid reference \(targetImageName)") + } + } + } +} diff --git a/Sources/ContainerCLI/Builder/Builder.swift b/Sources/ContainerCLI/Builder/Builder.swift new file mode 100644 index 000000000..ad9eb6c97 --- /dev/null +++ b/Sources/ContainerCLI/Builder/Builder.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct BuilderCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "builder", + abstract: "Manage an image builder instance", + subcommands: [ + BuilderStart.self, + BuilderStatus.self, + BuilderStop.self, + BuilderDelete.self, + ]) + } +} diff --git a/Sources/ContainerCLI/Builder/BuilderDelete.swift b/Sources/ContainerCLI/Builder/BuilderDelete.swift new file mode 100644 index 000000000..e848da95e --- /dev/null +++ b/Sources/ContainerCLI/Builder/BuilderDelete.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct BuilderDelete: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "delete" + config._superCommandName = "builder" + config.abstract = "Delete builder" + config.usage = "\n\t builder delete [command options]" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Flag(name: .shortAndLong, help: "Force delete builder even if it is running") + var force = false + + func run() async throws { + do { + let container = try await ClientContainer.get(id: "buildkit") + if container.status != .stopped { + guard force else { + throw ContainerizationError(.invalidState, message: "BuildKit container is not stopped, use --force to override") + } + try await container.stop() + } + try await container.delete() + } catch { + if error is ContainerizationError { + if (error as? ContainerizationError)?.code == .notFound { + return + } + } + throw error + } + } + } +} diff --git a/Sources/ContainerCLI/Builder/BuilderStart.swift b/Sources/ContainerCLI/Builder/BuilderStart.swift new file mode 100644 index 000000000..5800b712e --- /dev/null +++ b/Sources/ContainerCLI/Builder/BuilderStart.swift @@ -0,0 +1,262 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerBuild +import ContainerClient +import ContainerNetworkService +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Foundation +import TerminalProgress + +extension Application { + struct BuilderStart: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "start" + config._superCommandName = "builder" + config.abstract = "Start builder" + config.usage = "\nbuilder start [command options]" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") + public var cpus: Int64 = 2 + + @Option( + name: [.customLong("memory"), .customShort("m")], + help: + "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" + ) + public var memory: String = "2048MB" + + func run() async throws { + let progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + totalTasks: 4 + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + try await Self.start(cpus: self.cpus, memory: self.memory, progressUpdate: progress.handler) + progress.finish() + } + + static func start(cpus: Int64?, memory: String?, progressUpdate: @escaping ProgressUpdateHandler) async throws { + await progressUpdate([ + .setDescription("Fetching BuildKit image"), + .setItemsName("blobs"), + ]) + let taskManager = ProgressTaskCoordinator() + let fetchTask = await taskManager.startTask() + + let builderImage: String = ClientDefaults.get(key: .defaultBuilderImage) + let exportsMount: String = Application.appRoot.appendingPathComponent(".build").absolutePath() + + if !FileManager.default.fileExists(atPath: exportsMount) { + try FileManager.default.createDirectory( + atPath: exportsMount, + withIntermediateDirectories: true, + attributes: nil + ) + } + + let builderPlatform = ContainerizationOCI.Platform(arch: "arm64", os: "linux", variant: "v8") + + let existingContainer = try? await ClientContainer.get(id: "buildkit") + if let existingContainer { + let existingImage = existingContainer.configuration.image.reference + let existingResources = existingContainer.configuration.resources + + // Check if we need to recreate the builder due to different image + let imageChanged = existingImage != builderImage + let cpuChanged = { + if let cpus { + if existingResources.cpus != cpus { + return true + } + } + return false + }() + let memChanged = try { + if let memory { + let memoryInBytes = try Parser.resources(cpus: nil, memory: memory).memoryInBytes + if existingResources.memoryInBytes != memoryInBytes { + return true + } + } + return false + }() + + switch existingContainer.status { + case .running: + guard imageChanged || cpuChanged || memChanged else { + // If image, mem and cpu are the same, continue using the existing builder + return + } + // If they changed, stop and delete the existing builder + try await existingContainer.stop() + try await existingContainer.delete() + case .stopped: + // If the builder is stopped and matches our requirements, start it + // Otherwise, delete it and create a new one + guard imageChanged || cpuChanged || memChanged else { + try await existingContainer.startBuildKit(progressUpdate, nil) + return + } + try await existingContainer.delete() + case .stopping: + throw ContainerizationError( + .invalidState, + message: "builder is stopping, please wait until it is fully stopped before proceeding" + ) + case .unknown: + break + } + } + + let shimArguments: [String] = [ + "--debug", + "--vsock", + ] + + let id = "buildkit" + try ContainerClient.Utility.validEntityName(id) + + let processConfig = ProcessConfiguration( + executable: "/usr/local/bin/container-builder-shim", + arguments: shimArguments, + environment: [], + workingDirectory: "/", + terminal: false, + user: .id(uid: 0, gid: 0) + ) + + let resources = try Parser.resources( + cpus: cpus, + memory: memory + ) + + let image = try await ClientImage.fetch( + reference: builderImage, + platform: builderPlatform, + progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate) + ) + // Unpack fetched image before use + await progressUpdate([ + .setDescription("Unpacking BuildKit image"), + .setItemsName("entries"), + ]) + + let unpackTask = await taskManager.startTask() + _ = try await image.getCreateSnapshot( + platform: builderPlatform, + progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate) + ) + let imageConfig = ImageDescription( + reference: builderImage, + descriptor: image.descriptor + ) + + var config = ContainerConfiguration(id: id, image: imageConfig, process: processConfig) + config.resources = resources + config.mounts = [ + .init( + type: .tmpfs, + source: "", + destination: "/run", + options: [] + ), + .init( + type: .virtiofs, + source: exportsMount, + destination: "/var/lib/container-builder-shim/exports", + options: [] + ), + ] + // Enable Rosetta only if the user didn't ask to disable it + config.rosetta = ClientDefaults.getBool(key: .buildRosetta) ?? true + + let network = try await ClientNetwork.get(id: ClientNetwork.defaultNetworkName) + guard case .running(_, let networkStatus) = network else { + throw ContainerizationError(.invalidState, message: "default network is not running") + } + config.networks = [network.id] + let subnet = try CIDRAddress(networkStatus.address) + let nameserver = IPv4Address(fromValue: subnet.lower.value + 1).description + let nameservers = [nameserver] + config.dns = ContainerConfiguration.DNSConfiguration(nameservers: nameservers) + + let kernel = try await { + await progressUpdate([ + .setDescription("Fetching kernel"), + .setItemsName("binary"), + ]) + + let kernel = try await ClientKernel.getDefaultKernel(for: .current) + return kernel + }() + + await progressUpdate([ + .setDescription("Starting BuildKit container") + ]) + + let container = try await ClientContainer.create( + configuration: config, + options: .default, + kernel: kernel + ) + + try await container.startBuildKit(progressUpdate, taskManager) + } + } +} + +// MARK: - ClientContainer Extension for BuildKit + +extension ClientContainer { + /// Starts the BuildKit process within the container + /// This method handles bootstrapping the container and starting the BuildKit process + fileprivate func startBuildKit(_ progress: @escaping ProgressUpdateHandler, _ taskManager: ProgressTaskCoordinator? = nil) async throws { + do { + let io = try ProcessIO.create( + tty: false, + interactive: false, + detach: true + ) + defer { try? io.close() } + let process = try await bootstrap() + _ = try await process.start(io.stdio) + await taskManager?.finish() + try io.closeAfterStart() + log.debug("starting BuildKit and BuildKit-shim") + } catch { + try? await stop() + try? await delete() + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to start BuildKit: \(error)") + } + } +} diff --git a/Sources/ContainerCLI/Builder/BuilderStatus.swift b/Sources/ContainerCLI/Builder/BuilderStatus.swift new file mode 100644 index 000000000..b1210a3dd --- /dev/null +++ b/Sources/ContainerCLI/Builder/BuilderStatus.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct BuilderStatus: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "status" + config._superCommandName = "builder" + config.abstract = "Print builder status" + config.usage = "\n\t builder status [command options]" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Flag(name: .long, help: ArgumentHelp("Display detailed status in json format")) + var json: Bool = false + + func run() async throws { + do { + let container = try await ClientContainer.get(id: "buildkit") + if json { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let jsonData = try encoder.encode(container) + + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw ContainerizationError(.internalError, message: "failed to encode BuildKit container as json") + } + print(jsonString) + return + } + + let image = container.configuration.image.reference + let resources = container.configuration.resources + let cpus = resources.cpus + let memory = resources.memoryInBytes / (1024 * 1024) // bytes to MB + let addr = "" + + print("ID IMAGE STATE ADDR CPUS MEMORY") + print("\(container.id) \(image) \(container.status.rawValue.uppercased()) \(addr) \(cpus) \(memory) MB") + } catch { + if error is ContainerizationError { + if (error as? ContainerizationError)?.code == .notFound { + print("builder is not running") + return + } + } + throw error + } + } + } +} diff --git a/Sources/ContainerCLI/Builder/BuilderStop.swift b/Sources/ContainerCLI/Builder/BuilderStop.swift new file mode 100644 index 000000000..e7484c9c1 --- /dev/null +++ b/Sources/ContainerCLI/Builder/BuilderStop.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct BuilderStop: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "stop" + config._superCommandName = "builder" + config.abstract = "Stop builder" + config.usage = "\n\t builder stop" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + func run() async throws { + do { + let container = try await ClientContainer.get(id: "buildkit") + try await container.stop() + } catch { + if error is ContainerizationError { + if (error as? ContainerizationError)?.code == .notFound { + print("builder is not running") + return + } + } + throw error + } + } + } +} diff --git a/Sources/ContainerCLI/Codable+JSON.swift b/Sources/ContainerCLI/Codable+JSON.swift new file mode 100644 index 000000000..60cbd04d7 --- /dev/null +++ b/Sources/ContainerCLI/Codable+JSON.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension [any Codable] { + func jsonArray() throws -> String { + "[\(try self.map { String(data: try JSONEncoder().encode($0), encoding: .utf8)! }.joined(separator: ","))]" + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Build.swift b/Sources/ContainerCLI/Compose/Codable Structs/Build.swift new file mode 100644 index 000000000..5dc9a7ffa --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Build.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Build.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents the `build` configuration for a service. +struct Build: Codable, Hashable { + /// Path to the build context + let context: String + /// Optional path to the Dockerfile within the context + let dockerfile: String? + /// Build arguments + let args: [String: String]? + + /// Custom initializer to handle `build: .` (string) or `build: { context: . }` (object) + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let contextString = try? container.decode(String.self) { + self.context = contextString + self.dockerfile = nil + self.args = nil + } else { + let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) + self.context = try keyedContainer.decode(String.self, forKey: .context) + self.dockerfile = try keyedContainer.decodeIfPresent(String.self, forKey: .dockerfile) + self.args = try keyedContainer.decodeIfPresent([String: String].self, forKey: .args) + } + } + + enum CodingKeys: String, CodingKey { + case context, dockerfile, args + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Config.swift b/Sources/ContainerCLI/Compose/Codable Structs/Config.swift new file mode 100644 index 000000000..6b982bfdb --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Config.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Config.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level config definition (primarily for Swarm). +struct Config: Codable { + /// Path to the file containing the config content + let file: String? + /// Indicates if the config is external (pre-existing) + let external: ExternalConfig? + /// Explicit name for the config + let name: String? + /// Labels for the config + let labels: [String: String]? + + enum CodingKeys: String, CodingKey { + case file, external, name, labels + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_cfg" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + file = try container.decodeIfPresent(String.self, forKey: .file) + name = try container.decodeIfPresent(String.self, forKey: .name) + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalConfig(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalConfig(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift b/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift new file mode 100644 index 000000000..d30f9ffa8 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift @@ -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. +//===----------------------------------------------------------------------===// + +// +// Deploy.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents the `deploy` configuration for a service (primarily for Swarm orchestration). +struct Deploy: Codable, Hashable { + /// Deployment mode (e.g., 'replicated', 'global') + let mode: String? + /// Number of replicated service tasks + let replicas: Int? + /// Resource constraints (limits, reservations) + let resources: DeployResources? + /// Restart policy for tasks + let restart_policy: DeployRestartPolicy? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift new file mode 100644 index 000000000..370e61a46 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// DeployResources.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Resource constraints for deployment. +struct DeployResources: Codable, Hashable { + /// Hard limits on resources + let limits: ResourceLimits? + /// Guarantees for resources + let reservations: ResourceReservations? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift new file mode 100644 index 000000000..56daa6573 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift @@ -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. +//===----------------------------------------------------------------------===// + +// +// DeployRestartPolicy.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Restart policy for deployed tasks. +struct DeployRestartPolicy: Codable, Hashable { + /// Condition to restart on (e.g., 'on-failure', 'any') + let condition: String? + /// Delay before attempting restart + let delay: String? + /// Maximum number of restart attempts + let max_attempts: Int? + /// Window to evaluate restart policy + let window: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift new file mode 100644 index 000000000..47a58acad --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift @@ -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. +//===----------------------------------------------------------------------===// + +// +// DeviceReservation.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Device reservations for GPUs or other devices. +struct DeviceReservation: Codable, Hashable { + /// Device capabilities + let capabilities: [String]? + /// Device driver + let driver: String? + /// Number of devices + let count: String? + /// Specific device IDs + let device_ids: [String]? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift b/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift new file mode 100644 index 000000000..503d98664 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// DockerCompose.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents the top-level structure of a docker-compose.yml file. +struct DockerCompose: Codable { + /// The Compose file format version (e.g., '3.8') + let version: String? + /// Optional project name + let name: String? + /// Dictionary of service definitions, keyed by service name + let services: [String: Service] + /// Optional top-level volume definitions + let volumes: [String: Volume]? + /// Optional top-level network definitions + let networks: [String: Network]? + /// Optional top-level config definitions (primarily for Swarm) + let configs: [String: Config]? + /// Optional top-level secret definitions (primarily for Swarm) + let secrets: [String: Secret]? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decodeIfPresent(String.self, forKey: .version) + name = try container.decodeIfPresent(String.self, forKey: .name) + services = try container.decode([String: Service].self, forKey: .services) + + if let volumes = try container.decodeIfPresent([String: Optional].self, forKey: .volumes) { + let safeVolumes: [String : Volume] = volumes.mapValues { value in + value ?? Volume() + } + self.volumes = safeVolumes + } else { + self.volumes = nil + } + networks = try container.decodeIfPresent([String: Network].self, forKey: .networks) + configs = try container.decodeIfPresent([String: Config].self, forKey: .configs) + secrets = try container.decodeIfPresent([String: Secret].self, forKey: .secrets) + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift new file mode 100644 index 000000000..d05ccd461 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ExternalConfig.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external config reference. +struct ExternalConfig: Codable { + /// True if the config is external + let isExternal: Bool + /// Optional name of the external config if different from key + let name: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift new file mode 100644 index 000000000..07d6c8ce9 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ExternalNetwork.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external network reference. +struct ExternalNetwork: Codable { + /// True if the network is external + let isExternal: Bool + // Optional name of the external network if different from key + let name: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift new file mode 100644 index 000000000..ce4411362 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ExternalSecret.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external secret reference. +struct ExternalSecret: Codable { + /// True if the secret is external + let isExternal: Bool + /// Optional name of the external secret if different from key + let name: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift new file mode 100644 index 000000000..04cfe4f92 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ExternalVolume.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external volume reference. +struct ExternalVolume: Codable { + /// True if the volume is external + let isExternal: Bool + /// Optional name of the external volume if different from key + let name: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift b/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift new file mode 100644 index 000000000..27f5aa912 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Healthcheck.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Healthcheck configuration for a service. +struct Healthcheck: Codable, Hashable { + /// Command to run to check health + let test: [String]? + /// Grace period for the container to start + let start_period: String? + /// How often to run the check + let interval: String? + /// Number of consecutive failures to consider unhealthy + let retries: Int? + /// Timeout for each check + let timeout: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Network.swift b/Sources/ContainerCLI/Compose/Codable Structs/Network.swift new file mode 100644 index 000000000..44752aecc --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Network.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Network.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level network definition. +struct Network: Codable { + /// Network driver (e.g., 'bridge', 'overlay') + let driver: String? + /// Driver-specific options + let driver_opts: [String: String]? + /// Allow standalone containers to attach to this network + let attachable: Bool? + /// Enable IPv6 networking + let enable_ipv6: Bool? + /// RENAMED: from `internal` to `isInternal` to avoid keyword clash + let isInternal: Bool? + /// Labels for the network + let labels: [String: String]? + /// Explicit name for the network + let name: String? + /// Indicates if the network is external (pre-existing) + let external: ExternalNetwork? + + /// Updated CodingKeys to map 'internal' from YAML to 'isInternal' Swift property + enum CodingKeys: String, CodingKey { + case driver, driver_opts, attachable, enable_ipv6, isInternal = "internal", labels, name, external + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_net" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + driver = try container.decodeIfPresent(String.self, forKey: .driver) + driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts) + attachable = try container.decodeIfPresent(Bool.self, forKey: .attachable) + enable_ipv6 = try container.decodeIfPresent(Bool.self, forKey: .enable_ipv6) + isInternal = try container.decodeIfPresent(Bool.self, forKey: .isInternal) // Use isInternal here + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + name = try container.decodeIfPresent(String.self, forKey: .name) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalNetwork(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalNetwork(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift b/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift new file mode 100644 index 000000000..4643d961b --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ResourceLimits.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// CPU and memory limits. +struct ResourceLimits: Codable, Hashable { + /// CPU limit (e.g., "0.5") + let cpus: String? + /// Memory limit (e.g., "512M") + let memory: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift b/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift new file mode 100644 index 000000000..26052e6b3 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ResourceReservations.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// **FIXED**: Renamed from `ResourceReservables` to `ResourceReservations` and made `Codable`. +/// CPU and memory reservations. +struct ResourceReservations: Codable, Hashable { + /// CPU reservation (e.g., "0.25") + let cpus: String? + /// Memory reservation (e.g., "256M") + let memory: String? + /// Device reservations for GPUs or other devices + let devices: [DeviceReservation]? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift b/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift new file mode 100644 index 000000000..ff464c671 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Secret.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level secret definition (primarily for Swarm). +struct Secret: Codable { + /// Path to the file containing the secret content + let file: String? + /// Environment variable to populate with the secret content + let environment: String? + /// Indicates if the secret is external (pre-existing) + let external: ExternalSecret? + /// Explicit name for the secret + let name: String? + /// Labels for the secret + let labels: [String: String]? + + enum CodingKeys: String, CodingKey { + case file, environment, external, name, labels + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_sec" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + file = try container.decodeIfPresent(String.self, forKey: .file) + environment = try container.decodeIfPresent(String.self, forKey: .environment) + name = try container.decodeIfPresent(String.self, forKey: .name) + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalSecret(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalSecret(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Service.swift b/Sources/ContainerCLI/Compose/Codable Structs/Service.swift new file mode 100644 index 000000000..1c5aeb528 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Service.swift @@ -0,0 +1,203 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Service.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + +import Foundation + + +/// Represents a single service definition within the `services` section. +struct Service: Codable, Hashable { + /// Docker image name + let image: String? + + /// Build configuration if the service is built from a Dockerfile + let build: Build? + + /// Deployment configuration (primarily for Swarm) + let deploy: Deploy? + + /// Restart policy (e.g., 'unless-stopped', 'always') + let restart: String? + + /// Healthcheck configuration + let healthcheck: Healthcheck? + + /// List of volume mounts (e.g., "hostPath:containerPath", "namedVolume:/path") + let volumes: [String]? + + /// Environment variables to set in the container + let environment: [String: String]? + + /// List of .env files to load environment variables from + let env_file: [String]? + + /// Port mappings (e.g., "hostPort:containerPort") + let ports: [String]? + + /// Command to execute in the container, overriding the image's default + let command: [String]? + + /// Services this service depends on (for startup order) + let depends_on: [String]? + + /// User or UID to run the container as + let user: String? + + /// Explicit name for the container instance + let container_name: String? + + /// List of networks the service will connect to + let networks: [String]? + + /// Container hostname + let hostname: String? + + /// Entrypoint to execute in the container, overriding the image's default + let entrypoint: [String]? + + /// Run container in privileged mode + let privileged: Bool? + + /// Mount container's root filesystem as read-only + let read_only: Bool? + + /// Working directory inside the container + let working_dir: String? + + /// Platform architecture for the service + let platform: String? + + /// Service-specific config usage (primarily for Swarm) + let configs: [ServiceConfig]? + + /// Service-specific secret usage (primarily for Swarm) + let secrets: [ServiceSecret]? + + /// Keep STDIN open (-i flag for `container run`) + let stdin_open: Bool? + + /// Allocate a pseudo-TTY (-t flag for `container run`) + let tty: Bool? + + /// Other services that depend on this service + var dependedBy: [String] = [] + + // Defines custom coding keys to map YAML keys to Swift properties + enum CodingKeys: String, CodingKey { + case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, + container_name, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform + } + + /// Custom initializer to handle decoding and basic validation. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + image = try container.decodeIfPresent(String.self, forKey: .image) + build = try container.decodeIfPresent(Build.self, forKey: .build) + deploy = try container.decodeIfPresent(Deploy.self, forKey: .deploy) + + // Ensure that a service has either an image or a build context. + guard image != nil || build != nil else { + throw DecodingError.dataCorruptedError(forKey: .image, in: container, debugDescription: "Service must have either 'image' or 'build' specified.") + } + + restart = try container.decodeIfPresent(String.self, forKey: .restart) + healthcheck = try container.decodeIfPresent(Healthcheck.self, forKey: .healthcheck) + volumes = try container.decodeIfPresent([String].self, forKey: .volumes) + environment = try container.decodeIfPresent([String: String].self, forKey: .environment) + env_file = try container.decodeIfPresent([String].self, forKey: .env_file) + ports = try container.decodeIfPresent([String].self, forKey: .ports) + + // Decode 'command' which can be either a single string or an array of strings. + if let cmdArray = try? container.decodeIfPresent([String].self, forKey: .command) { + command = cmdArray + } else if let cmdString = try? container.decodeIfPresent(String.self, forKey: .command) { + command = [cmdString] + } else { + command = nil + } + + depends_on = try container.decodeIfPresent([String].self, forKey: .depends_on) + user = try container.decodeIfPresent(String.self, forKey: .user) + + container_name = try container.decodeIfPresent(String.self, forKey: .container_name) + networks = try container.decodeIfPresent([String].self, forKey: .networks) + hostname = try container.decodeIfPresent(String.self, forKey: .hostname) + + // Decode 'entrypoint' which can be either a single string or an array of strings. + if let entrypointArray = try? container.decodeIfPresent([String].self, forKey: .entrypoint) { + entrypoint = entrypointArray + } else if let entrypointString = try? container.decodeIfPresent(String.self, forKey: .entrypoint) { + entrypoint = [entrypointString] + } else { + entrypoint = nil + } + + privileged = try container.decodeIfPresent(Bool.self, forKey: .privileged) + read_only = try container.decodeIfPresent(Bool.self, forKey: .read_only) + working_dir = try container.decodeIfPresent(String.self, forKey: .working_dir) + configs = try container.decodeIfPresent([ServiceConfig].self, forKey: .configs) + secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets) + stdin_open = try container.decodeIfPresent(Bool.self, forKey: .stdin_open) + tty = try container.decodeIfPresent(Bool.self, forKey: .tty) + platform = try container.decodeIfPresent(String.self, forKey: .platform) + } + + /// Returns the services in topological order based on `depends_on` relationships. + static func topoSortConfiguredServices( + _ services: [(serviceName: String, service: Service)] + ) throws -> [(serviceName: String, service: Service)] { + + var visited = Set() + var visiting = Set() + var sorted: [(String, Service)] = [] + + func visit(_ name: String, from service: String? = nil) throws { + guard var serviceTuple = services.first(where: { $0.serviceName == name }) else { return } + if let service { + serviceTuple.service.dependedBy.append(service) + } + + if visiting.contains(name) { + throw NSError(domain: "ComposeError", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" + ]) + } + guard !visited.contains(name) else { return } + + visiting.insert(name) + for depName in serviceTuple.service.depends_on ?? [] { + try visit(depName, from: name) + } + visiting.remove(name) + visited.insert(name) + sorted.append(serviceTuple) + } + + for (serviceName, _) in services { + if !visited.contains(serviceName) { + try visit(serviceName) + } + } + + return sorted + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift b/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift new file mode 100644 index 000000000..712d42b7b --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ServiceConfig.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a service's usage of a config. +struct ServiceConfig: Codable, Hashable { + /// Name of the config being used + let source: String + + /// Path in the container where the config will be mounted + let target: String? + + /// User ID for the mounted config file + let uid: String? + + /// Group ID for the mounted config file + let gid: String? + + /// Permissions mode for the mounted config file + let mode: Int? + + /// Custom initializer to handle `config_name` (string) or `{ source: config_name, target: /path }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let sourceName = try? container.decode(String.self) { + self.source = sourceName + self.target = nil + self.uid = nil + self.gid = nil + self.mode = nil + } else { + let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) + self.source = try keyedContainer.decode(String.self, forKey: .source) + self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) + self.uid = try keyedContainer.decodeIfPresent(String.self, forKey: .uid) + self.gid = try keyedContainer.decodeIfPresent(String.self, forKey: .gid) + self.mode = try keyedContainer.decodeIfPresent(Int.self, forKey: .mode) + } + } + + enum CodingKeys: String, CodingKey { + case source, target, uid, gid, mode + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift b/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift new file mode 100644 index 000000000..1849c495c --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ServiceSecret.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a service's usage of a secret. +struct ServiceSecret: Codable, Hashable { + /// Name of the secret being used + let source: String + + /// Path in the container where the secret will be mounted + let target: String? + + /// User ID for the mounted secret file + let uid: String? + + /// Group ID for the mounted secret file + let gid: String? + + /// Permissions mode for the mounted secret file + let mode: Int? + + /// Custom initializer to handle `secret_name` (string) or `{ source: secret_name, target: /path }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let sourceName = try? container.decode(String.self) { + self.source = sourceName + self.target = nil + self.uid = nil + self.gid = nil + self.mode = nil + } else { + let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) + self.source = try keyedContainer.decode(String.self, forKey: .source) + self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) + self.uid = try keyedContainer.decodeIfPresent(String.self, forKey: .uid) + self.gid = try keyedContainer.decodeIfPresent(String.self, forKey: .gid) + self.mode = try keyedContainer.decodeIfPresent(Int.self, forKey: .mode) + } + } + + enum CodingKeys: String, CodingKey { + case source, target, uid, gid, mode + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift b/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift new file mode 100644 index 000000000..b43a1cca5 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Volume.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level volume definition. +struct Volume: Codable { + /// Volume driver (e.g., 'local') + let driver: String? + + /// Driver-specific options + let driver_opts: [String: String]? + + /// Explicit name for the volume + let name: String? + + /// Labels for the volume + let labels: [String: String]? + + /// Indicates if the volume is external (pre-existing) + let external: ExternalVolume? + + enum CodingKeys: String, CodingKey { + case driver, driver_opts, name, labels, external + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_vol" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + driver = try container.decodeIfPresent(String.self, forKey: .driver) + driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts) + name = try container.decodeIfPresent(String.self, forKey: .name) + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalVolume(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalVolume(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } + + init(driver: String? = nil, driver_opts: [String : String]? = nil, name: String? = nil, labels: [String : String]? = nil, external: ExternalVolume? = nil) { + self.driver = driver + self.driver_opts = driver_opts + self.name = name + self.labels = labels + self.external = external + } +} diff --git a/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift b/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift new file mode 100644 index 000000000..8993f8ddb --- /dev/null +++ b/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift @@ -0,0 +1,105 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ComposeDown.swift +// Container-Compose +// +// Created by Morris Richman on 6/19/25. +// + +import ArgumentParser +import ContainerClient +import Foundation +import Yams + +extension Application { + public struct ComposeDown: AsyncParsableCommand { + public init() {} + + public static let configuration: CommandConfiguration = .init( + commandName: "down", + abstract: "Stop containers with compose" + ) + + @Argument(help: "Specify the services to stop") + var services: [String] = [] + + @OptionGroup + var process: Flags.Process + + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + + private var fileManager: FileManager { FileManager.default } + private var projectName: String? + + public mutating func run() async throws { + // Read docker-compose.yml content + guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { + throw YamlError.dockerfileNotFound(dockerComposePath) + } + + // Decode the YAML file into the DockerCompose struct + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + + // Determine project name for container naming + if let name = dockerCompose.name { + projectName = name + print("Info: Docker Compose project name parsed as: \(name)") + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + } else { + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") + } + + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) + services = try Service.topoSortConfiguredServices(services) + + // Filter for specified services + if !self.services.isEmpty { + services = services.filter({ serviceName, service in + self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) + }) + } + + try await stopOldStuff(services.map({ $0.serviceName }), remove: false) + } + + private func stopOldStuff(_ services: [String], remove: Bool) async throws { + guard let projectName else { return } + let containers = services.map { "\(projectName)-\($0)" } + + for container in containers { + print("Stopping container: \(container)") + guard let container = try? await ClientContainer.get(id: container) else { continue } + + do { + try await container.stop() + } catch { + } + if remove { + do { + try await container.delete() + } catch { + } + } + } + } + } +} diff --git a/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift b/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift new file mode 100644 index 000000000..6b1053670 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift @@ -0,0 +1,749 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ComposeUp.swift +// Container-Compose +// +// Created by Morris Richman on 6/19/25. +// + +import ArgumentParser +import ContainerClient +import Foundation +@preconcurrency import Rainbow +import Yams +import ContainerizationExtras + +extension Application { + public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { + public init() {} + + public static let configuration: CommandConfiguration = .init( + commandName: "up", + abstract: "Start containers with compose" + ) + + @Argument(help: "Specify the services to start") + var services: [String] = [] + + @Flag( + name: [.customShort("d"), .customLong("detach")], + help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") + var detatch: Bool = false + + @Flag(name: [.customShort("b"), .customLong("build")]) + var rebuild: Bool = false + + @Flag(name: .long, help: "Do not use cache") + var noCache: Bool = false + + @OptionGroup + var process: Flags.Process + + @OptionGroup + var global: Flags.Global + + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file + // + private var fileManager: FileManager { FileManager.default } + private var projectName: String? + private var environmentVariables: [String: String] = [:] + private var containerIps: [String: String] = [:] + private var containerConsoleColors: [String: NamedColor] = [:] + + private static let availableContainerConsoleColors: Set = [ + .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, + ] + + public mutating func run() async throws { + // Read docker-compose.yml content + guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { + throw YamlError.dockerfileNotFound(dockerComposePath) + } + + // Decode the YAML file into the DockerCompose struct + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + + // Load environment variables from .env file + environmentVariables = loadEnvFile(path: envFilePath) + + // Handle 'version' field + if let version = dockerCompose.version { + print("Info: Docker Compose file version parsed as: \(version)") + print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") + } + + // Determine project name for container naming + if let name = dockerCompose.name { + projectName = name + print("Info: Docker Compose project name parsed as: \(name)") + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + } else { + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") + } + + // Get Services to use + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) + services = try Service.topoSortConfiguredServices(services) + + // Filter for specified services + if !self.services.isEmpty { + services = services.filter({ serviceName, service in + self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) + }) + } + + // Stop Services + try await stopOldStuff(services.map({ $0.serviceName }), remove: true) + + // Process top-level networks + // This creates named networks defined in the docker-compose.yml + if let networks = dockerCompose.networks { + print("\n--- Processing Networks ---") + for (networkName, networkConfig) in networks { + try await setupNetwork(name: networkName, config: networkConfig) + } + print("--- Networks Processed ---\n") + } + + // Process top-level volumes + // This creates named volumes defined in the docker-compose.yml + if let volumes = dockerCompose.volumes { + print("\n--- Processing Volumes ---") + for (volumeName, volumeConfig) in volumes { + await createVolumeHardLink(name: volumeName, config: volumeConfig) + } + print("--- Volumes Processed ---\n") + } + + // Process each service defined in the docker-compose.yml + print("\n--- Processing Services ---") + + print(services.map(\.serviceName)) + for (serviceName, service) in services { + try await configService(service, serviceName: serviceName, from: dockerCompose) + } + + if !detatch { + await waitForever() + } + } + + func waitForever() async -> Never { + for await _ in AsyncStream(unfolding: {}) { + // This will never run + } + fatalError("unreachable") + } + + private func getIPForRunningService(_ serviceName: String) async throws -> String? { + guard let projectName else { return nil } + + let containerName = "\(projectName)-\(serviceName)" + + let container = try await ClientContainer.get(id: containerName) + let ip = container.networks.compactMap { try? CIDRAddress($0.address).address.description }.first + + return ip + } + + /// Repeatedly checks `container list -a` until the given container is listed as `running`. + /// - Parameters: + /// - containerName: The exact name of the container (e.g. "Assignment-Manager-API-db"). + /// - timeout: Max seconds to wait before failing. + /// - interval: How often to poll (in seconds). + /// - Returns: `true` if the container reached "running" state within the timeout. + private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { + guard let projectName else { return } + let containerName = "\(projectName)-\(serviceName)" + + let deadline = Date().addingTimeInterval(timeout) + + while Date() < deadline { + let container = try? await ClientContainer.get(id: containerName) + if container?.status == .running { + return + } + + try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + + throw NSError( + domain: "ContainerWait", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." + ]) + } + + private func stopOldStuff(_ services: [String], remove: Bool) async throws { + guard let projectName else { return } + let containers = services.map { "\(projectName)-\($0)" } + + for container in containers { + print("Stopping container: \(container)") + guard let container = try? await ClientContainer.get(id: container) else { continue } + + do { + try await container.stop() + } catch { + } + if remove { + do { + try await container.delete() + } catch { + } + } + } + } + + // MARK: Compose Top Level Functions + + private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { + let ip = try await getIPForRunningService(serviceName) + self.containerIps[serviceName] = ip + for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { + self.environmentVariables[key] = ip ?? value + } + } + + private func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { + guard let projectName else { return } + let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name + + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") + let volumePath = volumeUrl.path(percentEncoded: false) + + print( + "Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + ) + try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + } + + private func setupNetwork(name networkName: String, config networkConfig: Network) async throws { + let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name + + if let externalNetwork = networkConfig.external, externalNetwork.isExternal { + print("Info: Network '\(networkName)' is declared as external.") + print("This tool assumes external network '\(externalNetwork.name ?? actualNetworkName)' already exists and will not attempt to create it.") + } else { + var networkCreateArgs: [String] = ["network", "create"] + + #warning("Docker Compose Network Options Not Supported") + // Add driver and driver options + if let driver = networkConfig.driver, !driver.isEmpty { +// networkCreateArgs.append("--driver") +// networkCreateArgs.append(driver) + print("Network Driver Detected, But Not Supported") + } + if let driverOpts = networkConfig.driver_opts, !driverOpts.isEmpty { +// for (optKey, optValue) in driverOpts { +// networkCreateArgs.append("--opt") +// networkCreateArgs.append("\(optKey)=\(optValue)") +// } + print("Network Options Detected, But Not Supported") + } + // Add various network flags + if networkConfig.attachable == true { +// networkCreateArgs.append("--attachable") + print("Network Attachable Flag Detected, But Not Supported") + } + if networkConfig.enable_ipv6 == true { +// networkCreateArgs.append("--ipv6") + print("Network IPv6 Flag Detected, But Not Supported") + } + if networkConfig.isInternal == true { +// networkCreateArgs.append("--internal") + print("Network Internal Flag Detected, But Not Supported") + } // CORRECTED: Use isInternal + + // Add labels + if let labels = networkConfig.labels, !labels.isEmpty { + print("Network Labels Detected, But Not Supported") +// for (labelKey, labelValue) in labels { +// networkCreateArgs.append("--label") +// networkCreateArgs.append("\(labelKey)=\(labelValue)") +// } + } + + print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") + print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") + guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else { + print("Network '\(networkName)' already exists") + return + } + var networkCreate = NetworkCreate() + networkCreate.global = global + networkCreate.name = actualNetworkName + + try await networkCreate.run() + print("Network '\(networkName)' created") + } + } + + // MARK: Compose Service Level Functions + private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { + guard let projectName else { throw ComposeError.invalidProjectName } + + var imageToRun: String + + // Handle 'build' configuration + if let buildConfig = service.build { + imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) + } else if let img = service.image { + // Use specified image if no build config + // Pull image if necessary + try await pullImage(img, platform: service.container_name) + imageToRun = img + } else { + // Should not happen due to Service init validation, but as a fallback + throw ComposeError.imageNotFound(serviceName) + } + + // Handle 'deploy' configuration (note that this tool doesn't fully support it) + if service.deploy != nil { + print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") + print( + "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." + ) + print("The service will be run as a single container based on other configurations.") + } + + var runCommandArgs: [String] = [] + + // Add detach flag if specified on the CLI + if detatch { + runCommandArgs.append("-d") + } + + // Determine container name + let containerName: String + if let explicitContainerName = service.container_name { + containerName = explicitContainerName + print("Info: Using explicit container_name: \(containerName)") + } else { + // Default container name based on project and service name + containerName = "\(projectName)-\(serviceName)" + } + runCommandArgs.append("--name") + runCommandArgs.append(containerName) + + // REMOVED: Restart policy is not supported by `container run` + // if let restart = service.restart { + // runCommandArgs.append("--restart") + // runCommandArgs.append(restart) + // } + + // Add user + if let user = service.user { + runCommandArgs.append("--user") + runCommandArgs.append(user) + } + + // Add volume mounts + if let volumes = service.volumes { + for volume in volumes { + let args = try await configVolume(volume) + runCommandArgs.append(contentsOf: args) + } + } + + // Combine environment variables from .env files and service environment + var combinedEnv: [String: String] = environmentVariables + + if let envFiles = service.env_file { + for envFile in envFiles { + let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") + combinedEnv.merge(additionalEnvVars) { (current, _) in current } + } + } + + if let serviceEnv = service.environment { + combinedEnv.merge(serviceEnv) { (old, new) in + guard !new.contains("${") else { + return old + } + return new + } // Service env overrides .env files + } + + // Fill in variables + combinedEnv = combinedEnv.mapValues({ value in + guard value.contains("${") else { return value } + + let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) + return combinedEnv[variableName] ?? value + }) + + // Fill in IPs + combinedEnv = combinedEnv.mapValues({ value in + containerIps[value] ?? value + }) + + // MARK: Spinning Spot + // Add environment variables to run command + for (key, value) in combinedEnv { + runCommandArgs.append("-e") + runCommandArgs.append("\(key)=\(value)") + } + + // REMOVED: Port mappings (-p) are not supported by `container run` + // if let ports = service.ports { + // for port in ports { + // let resolvedPort = resolveVariable(port, with: envVarsFromFile) + // runCommandArgs.append("-p") + // runCommandArgs.append(resolvedPort) + // } + // } + + // Connect to specified networks + if let serviceNetworks = service.networks { + for network in serviceNetworks { + let resolvedNetwork = resolveVariable(network, with: environmentVariables) + // Use the explicit network name from top-level definition if available, otherwise resolved name + let networkToConnect = dockerCompose.networks?[network]?.name ?? resolvedNetwork + runCommandArgs.append("--network") + runCommandArgs.append(networkToConnect) + } + print( + "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml." + ) + print( + "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." + ) + } else { + print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.") + } + + // Add hostname + if let hostname = service.hostname { + let resolvedHostname = resolveVariable(hostname, with: environmentVariables) + runCommandArgs.append("--hostname") + runCommandArgs.append(resolvedHostname) + } + + // Add working directory + if let workingDir = service.working_dir { + let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) + runCommandArgs.append("--workdir") + runCommandArgs.append(resolvedWorkingDir) + } + + // Add privileged flag + if service.privileged == true { + runCommandArgs.append("--privileged") + } + + // Add read-only flag + if service.read_only == true { + runCommandArgs.append("--read-only") + } + + // Handle service-level configs (note: still only parsing/logging, not attaching) + if let serviceConfigs = service.configs { + print( + "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) + print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.") + for serviceConfig in serviceConfigs { + print( + " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" + ) + } + } + // + // Handle service-level secrets (note: still only parsing/logging, not attaching) + if let serviceSecrets = service.secrets { + print( + "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) + print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.") + for serviceSecret in serviceSecrets { + print( + " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" + ) + } + } + + // Add interactive and TTY flags + if service.stdin_open == true { + runCommandArgs.append("-i") // --interactive + } + if service.tty == true { + runCommandArgs.append("-t") // --tty + } + + runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint + + // Add entrypoint or command + if let entrypointParts = service.entrypoint { + runCommandArgs.append("--entrypoint") + runCommandArgs.append(contentsOf: entrypointParts) + } else if let commandParts = service.command { + runCommandArgs.append(contentsOf: commandParts) + } + + var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! + + if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { + while containerConsoleColors.values.contains(serviceColor) { + serviceColor = Self.availableContainerConsoleColors.randomElement()! + } + } + + self.containerConsoleColors[serviceName] = serviceColor + + Task { [self, serviceColor] in + @Sendable + func handleOutput(_ output: String) { + print("\(serviceName): \(output)".applyingColor(serviceColor)) + } + + print("\nStarting service: \(serviceName)") + print("Starting \(serviceName)") + print("----------------------------------------\n") + let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) + } + + do { + try await waitUntilServiceIsRunning(serviceName) + try await updateEnvironmentWithServiceIP(serviceName) + } catch { + print(error) + } + } + + private func pullImage(_ imageName: String, platform: String?) async throws { + let imageList = try await ClientImage.list() + guard !imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageName }) else { + return + } + + print("Pulling Image \(imageName)...") + var registry = Flags.Registry() + registry.scheme = "auto" // Set or SwiftArgumentParser gets mad + + var progress = Flags.Progress() + progress.disableProgressUpdates = false + + var imagePull = ImagePull() + imagePull.progressFlags = progress + imagePull.registry = registry + imagePull.global = global + imagePull.reference = imageName + imagePull.platform = platform + try await imagePull.run() + } + + /// Builds Docker Service + /// + /// - Parameters: + /// - buildConfig: The configuration for the build + /// - service: The service you would like to build + /// - serviceName: The fallback name for the image + /// + /// - Returns: Image Name (`String`) + private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { + // Determine image tag for built image + let imageToRun = service.image ?? "\(serviceName):latest" + let imageList = try await ClientImage.list() + if !rebuild, imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageToRun }) { + return imageToRun + } + + var buildCommand = BuildCommand() + + // Set Build Commands + buildCommand.buildArg = (buildConfig.args ?? [:]).map({ "\($0.key)=\(resolveVariable($0.value, with: environmentVariables))" }) + + // Locate Dockerfile and context + buildCommand.contextDir = "\(self.cwd)/\(buildConfig.context)" + buildCommand.file = "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")" + + // Handle Caching + buildCommand.noCache = noCache + buildCommand.cacheIn = [] + buildCommand.cacheOut = [] + + // Handle OS/Arch + let split = service.platform?.split(separator: "/") + buildCommand.os = [String(split?.first ?? "linux")] + buildCommand.arch = [String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64")] + + // Set Image Name + buildCommand.targetImageName = imageToRun + + // Set CPU & Memory + buildCommand.cpus = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 + buildCommand.memory = service.deploy?.resources?.limits?.memory ?? "2048MB" + + // Set Miscelaneous + buildCommand.label = [] // No Label Equivalent? + buildCommand.progress = "auto" + buildCommand.vsockPort = 8088 + buildCommand.quiet = false + buildCommand.target = "" + buildCommand.output = ["type=oci"] + print("\n----------------------------------------") + print("Building image for service: \(serviceName) (Tag: \(imageToRun))") + try buildCommand.validate() + try await buildCommand.run() + print("Image build for \(serviceName) completed.") + print("----------------------------------------") + + return imageToRun + } + + private func configVolume(_ volume: String) async throws -> [String] { + let resolvedVolume = resolveVariable(volume, with: environmentVariables) + + var runCommandArgs: [String] = [] + + // Parse the volume string: destination[:mode] + let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) + + guard components.count >= 2 else { + print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") + return [] + } + + let source = components[0] + let destination = components[1] + + // Check if the source looks like a host path (contains '/' or starts with '.') + // This heuristic helps distinguish bind mounts from named volume references. + if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { + // This is likely a bind mount (local path to container path) + var isDirectory: ObjCBool = false + // Ensure the path is absolute or relative to the current directory for FileManager + let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) + + if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { + if isDirectory.boolValue { + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + } else { + // Host path exists but is a file + print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") + } + } else { + // Host path does not exist, assume it's meant to be a directory and try to create it. + do { + try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) + print("Info: Created missing host directory for volume: \(fullHostPath)") + runCommandArgs.append("-v") + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + } catch { + print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") + } + } + } else { + guard let projectName else { return [] } + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") + let volumePath = volumeUrl.path(percentEncoded: false) + + let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() + let destinationPath = destinationUrl.path(percentEncoded: false) + + print( + "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + ) + try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument + } + + return runCommandArgs + } + } +} + +// MARK: CommandLine Functions +extension Application.ComposeUp { + + /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. + /// + /// - Parameters: + /// - command: The name of the command to run (e.g., `"container"`). + /// - args: Command-line arguments to pass to the command. + /// - onStdout: Closure called with streamed stdout data. + /// - onStderr: Closure called with streamed stderr data. + /// - Returns: The process's exit code. + /// - Throws: If the process fails to launch. + @discardableResult + func streamCommand( + _ command: String, + args: [String] = [], + onStdout: @escaping (@Sendable (String) -> Void), + onStderr: @escaping (@Sendable (String) -> Void) + ) async throws -> Int32 { + try await withCheckedThrowingContinuation { continuation in + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [command] + args + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + process.environment = ProcessInfo.processInfo.environment.merging([ + "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + ]) { _, new in new } + + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + + stdoutHandle.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let string = String(data: data, encoding: .utf8) { + onStdout(string) + } + } + + stderrHandle.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let string = String(data: data, encoding: .utf8) { + onStderr(string) + } + } + + process.terminationHandler = { proc in + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil + continuation.resume(returning: proc.terminationStatus) + } + + do { + try process.run() + } catch { + continuation.resume(throwing: error) + } + } + } +} diff --git a/Sources/ContainerCLI/Compose/ComposeCommand.swift b/Sources/ContainerCLI/Compose/ComposeCommand.swift new file mode 100644 index 000000000..03e940332 --- /dev/null +++ b/Sources/ContainerCLI/Compose/ComposeCommand.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// File.swift +// Container-Compose +// +// Created by Morris Richman on 6/18/25. +// + +import ArgumentParser +import Foundation +import Rainbow +import Yams + +extension Application { + struct ComposeCommand: AsyncParsableCommand { + static let configuration: CommandConfiguration = .init( + commandName: "compose", + abstract: "Manage containers with Docker Compose files", + subcommands: [ + ComposeUp.self, + ComposeDown.self, + ]) + } +} + +/// A structure representing the result of a command-line process execution. +struct CommandResult { + /// The standard output captured from the process. + let stdout: String + + /// The standard error output captured from the process. + let stderr: String + + /// The exit code returned by the process upon termination. + let exitCode: Int32 +} + +extension NamedColor: Codable { + +} diff --git a/Sources/ContainerCLI/Compose/Errors.swift b/Sources/ContainerCLI/Compose/Errors.swift new file mode 100644 index 000000000..c5b375aa2 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Errors.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Errors.swift +// Container-Compose +// +// Created by Morris Richman on 6/18/25. +// + +import Foundation + +extension Application { + internal enum YamlError: Error, LocalizedError { + case dockerfileNotFound(String) + + var errorDescription: String? { + switch self { + case .dockerfileNotFound(let path): + return "docker-compose.yml not found at \(path)" + } + } + } + + internal enum ComposeError: Error, LocalizedError { + case imageNotFound(String) + case invalidProjectName + + var errorDescription: String? { + switch self { + case .imageNotFound(let name): + return "Service \(name) must define either 'image' or 'build'." + case .invalidProjectName: + return "Could not find project name." + } + } + } + + internal enum TerminalError: Error, LocalizedError { + case commandFailed(String) + + var errorDescription: String? { + "Command failed: \(self)" + } + } + + /// An enum representing streaming output from either `stdout` or `stderr`. + internal enum CommandOutput { + case stdout(String) + case stderr(String) + case exitCode(Int32) + } +} diff --git a/Sources/ContainerCLI/Compose/Helper Functions.swift b/Sources/ContainerCLI/Compose/Helper Functions.swift new file mode 100644 index 000000000..e1068ad94 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Helper Functions.swift @@ -0,0 +1,96 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Helper Functions.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + +import Foundation +import Yams + +extension Application { + /// Loads environment variables from a .env file. + /// - Parameter path: The full path to the .env file. + /// - Returns: A dictionary of key-value pairs representing environment variables. + internal static func loadEnvFile(path: String) -> [String: String] { + var envVars: [String: String] = [:] + let fileURL = URL(fileURLWithPath: path) + do { + let content = try String(contentsOf: fileURL, encoding: .utf8) + let lines = content.split(separator: "\n") + for line in lines { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + // Ignore empty lines and comments + if !trimmedLine.isEmpty && !trimmedLine.starts(with: "#") { + // Parse key=value pairs + if let eqIndex = trimmedLine.firstIndex(of: "=") { + let key = String(trimmedLine[.. String { + var resolvedValue = value + // Regex to find ${VAR}, ${VAR:-default}, ${VAR:?error} + let regex = try! NSRegularExpression(pattern: "\\$\\{([A-Z0-9_]+)(:?-(.*?))?(:\\?(.*?))?\\}", options: []) + + // Combine process environment with loaded .env file variables, prioritizing process environment + let combinedEnv = ProcessInfo.processInfo.environment.merging(envVars) { (current, _) in current } + + // Loop to resolve all occurrences of variables in the string + while let match = regex.firstMatch(in: resolvedValue, options: [], range: NSRange(resolvedValue.startIndex.. 0 && all { + throw ContainerizationError( + .invalidArgument, + message: "explicitly supplied container ID(s) conflict with the --all flag" + ) + } + } + + mutating func run() async throws { + let set = Set(containerIDs) + var containers = [ClientContainer]() + + if all { + containers = try await ClientContainer.list() + } else { + let ctrs = try await ClientContainer.list() + containers = ctrs.filter { c in + set.contains(c.id) + } + // If one of the containers requested isn't present, let's throw. We don't need to do + // this for --all as --all should be perfectly usable with no containers to remove; otherwise, + // it'd be quite clunky. + if containers.count != set.count { + let missing = set.filter { id in + !containers.contains { c in + c.id == id + } + } + throw ContainerizationError( + .notFound, + message: "failed to delete one or more containers: \(missing)" + ) + } + } + + var failed = [String]() + let force = self.force + let all = self.all + try await withThrowingTaskGroup(of: ClientContainer?.self) { group in + for container in containers { + group.addTask { + do { + // First we need to find if the container supports auto-remove + // and if so we need to skip deletion. + if container.status == .running { + if !force { + // We don't want to error if the user just wants all containers deleted. + // It's implied we'll skip containers we can't actually delete. + if all { + return nil + } + throw ContainerizationError(.invalidState, message: "container is running") + } + let stopOpts = ContainerStopOptions( + timeoutInSeconds: 5, + signal: SIGKILL + ) + try await container.stop(opts: stopOpts) + } + try await container.delete() + print(container.id) + return nil + } catch { + log.error("failed to delete container \(container.id): \(error)") + return container + } + } + } + + for try await ctr in group { + guard let ctr else { + continue + } + failed.append(ctr.id) + } + } + + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "delete failed for one or more containers: \(failed)") + } + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerExec.swift b/Sources/ContainerCLI/Container/ContainerExec.swift new file mode 100644 index 000000000..de3969585 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerExec.swift @@ -0,0 +1,96 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import Foundation + +extension Application { + struct ContainerExec: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "exec", + abstract: "Run a new command in a running container") + + @OptionGroup + var processFlags: Flags.Process + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Running containers ID") + var containerID: String + + @Argument(parsing: .captureForPassthrough, help: "New process arguments") + var arguments: [String] + + func run() async throws { + var exitCode: Int32 = 127 + let container = try await ClientContainer.get(id: containerID) + try ensureRunning(container: container) + + let stdin = self.processFlags.interactive + let tty = self.processFlags.tty + + var config = container.configuration.initProcess + config.executable = arguments.first! + config.arguments = [String](self.arguments.dropFirst()) + config.terminal = tty + config.environment.append( + contentsOf: try Parser.allEnv( + imageEnvs: [], + envFiles: self.processFlags.envFile, + envs: self.processFlags.env + )) + + if let cwd = self.processFlags.cwd { + config.workingDirectory = cwd + } + + let defaultUser = config.user + let (user, additionalGroups) = Parser.user( + user: processFlags.user, uid: processFlags.uid, + gid: processFlags.gid, defaultUser: defaultUser) + config.user = user + config.supplementalGroups.append(contentsOf: additionalGroups) + + do { + let io = try ProcessIO.create(tty: tty, interactive: stdin, detach: false) + + if !self.processFlags.tty { + var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) + handler.start { + print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") + Darwin.exit(1) + } + } + + let process = try await container.createProcess( + id: UUID().uuidString.lowercased(), + configuration: config) + + exitCode = try await Application.handleProcess(io: io, process: process) + } catch { + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to exec process \(error)") + } + throw ArgumentParser.ExitCode(exitCode) + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerInspect.swift b/Sources/ContainerCLI/Container/ContainerInspect.swift new file mode 100644 index 000000000..43bda51a1 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerInspect.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import SwiftProtobuf + +extension Application { + struct ContainerInspect: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display information about one or more containers") + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Containers to inspect") + var containers: [String] + + func run() async throws { + let objects: [any Codable] = try await ClientContainer.list().filter { + containers.contains($0.id) + }.map { + PrintableContainer($0) + } + print(try objects.jsonArray()) + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerKill.swift b/Sources/ContainerCLI/Container/ContainerKill.swift new file mode 100644 index 000000000..9b9ef4ed4 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerKill.swift @@ -0,0 +1,79 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import Darwin + +extension Application { + struct ContainerKill: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "kill", + abstract: "Kill one or more running containers") + + @Option(name: .shortAndLong, help: "Signal to send the container(s)") + var signal: String = "KILL" + + @Flag(name: .shortAndLong, help: "Kill all running containers") + var all = false + + @Argument(help: "Container IDs") + var containerIDs: [String] = [] + + @OptionGroup + var global: Flags.Global + + func validate() throws { + if containerIDs.count == 0 && !all { + throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") + } + if containerIDs.count > 0 && all { + throw ContainerizationError(.invalidArgument, message: "explicitly supplied container IDs conflicts with the --all flag") + } + } + + mutating func run() async throws { + let set = Set(containerIDs) + + var containers = try await ClientContainer.list().filter { c in + c.status == .running + } + if !self.all { + containers = containers.filter { c in + set.contains(c.id) + } + } + + let signalNumber = try Signals.parseSignal(signal) + + var failed: [String] = [] + for container in containers { + do { + try await container.kill(signalNumber) + print(container.id) + } catch { + log.error("failed to kill container \(container.id): \(error)") + failed.append(container.id) + } + } + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "kill failed for one or more containers") + } + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerList.swift b/Sources/ContainerCLI/Container/ContainerList.swift new file mode 100644 index 000000000..43e5a4cec --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerList.swift @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkService +import ContainerizationExtras +import Foundation +import SwiftProtobuf + +extension Application { + struct ContainerList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List containers", + aliases: ["ls"]) + + @Flag(name: .shortAndLong, help: "Show stopped containers as well") + var all = false + + @Flag(name: .shortAndLong, help: "Only output the container ID") + 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 containers = try await ClientContainer.list() + try printContainers(containers: containers, format: format) + } + + private func createHeader() -> [[String]] { + [["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR"]] + } + + private func printContainers(containers: [ClientContainer], format: ListFormat) throws { + if format == .json { + let printables = containers.map { + PrintableContainer($0) + } + let data = try JSONEncoder().encode(printables) + print(String(data: data, encoding: .utf8)!) + + return + } + + if self.quiet { + containers.forEach { + if !self.all && $0.status != .running { + return + } + print($0.id) + } + return + } + + var rows = createHeader() + for container in containers { + if !self.all && container.status != .running { + continue + } + rows.append(container.asRow) + } + + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + } +} + +extension ClientContainer { + var asRow: [String] { + [ + self.id, + self.configuration.image.reference, + self.configuration.platform.os, + self.configuration.platform.architecture, + self.status.rawValue, + self.networks.compactMap { try? CIDRAddress($0.address).address.description }.joined(separator: ","), + ] + } +} + +struct PrintableContainer: Codable { + let status: RuntimeStatus + let configuration: ContainerConfiguration + let networks: [Attachment] + + init(_ container: ClientContainer) { + self.status = container.status + self.configuration = container.configuration + self.networks = container.networks + } +} diff --git a/Sources/ContainerCLI/Container/ContainerLogs.swift b/Sources/ContainerCLI/Container/ContainerLogs.swift new file mode 100644 index 000000000..d70e80323 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerLogs.swift @@ -0,0 +1,144 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Dispatch +import Foundation + +extension Application { + struct ContainerLogs: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "logs", + abstract: "Fetch container stdio or boot logs" + ) + + @OptionGroup + var global: Flags.Global + + @Flag(name: .shortAndLong, help: "Follow log output") + var follow: Bool = false + + @Flag(name: .long, help: "Display the boot log for the container instead of stdio") + var boot: Bool = false + + @Option(name: [.customShort("n")], help: "Number of lines to show from the end of the logs. If not provided this will print all of the logs") + var numLines: Int? + + @Argument(help: "Container to fetch logs for") + var container: String + + func run() async throws { + do { + let container = try await ClientContainer.get(id: container) + let fhs = try await container.logs() + let fileHandle = boot ? fhs[1] : fhs[0] + + try await Self.tail( + fh: fileHandle, + n: numLines, + follow: follow + ) + } catch { + throw ContainerizationError( + .invalidArgument, + message: "failed to fetch container logs for \(container): \(error)" + ) + } + } + + private static func tail( + fh: FileHandle, + n: Int?, + follow: Bool + ) async throws { + if let n { + var buffer = Data() + let size = try fh.seekToEnd() + var offset = size + var lines: [String] = [] + + while offset > 0, lines.count < n { + let readSize = min(1024, offset) + offset -= readSize + try fh.seek(toOffset: offset) + + let data = fh.readData(ofLength: Int(readSize)) + buffer.insert(contentsOf: data, at: 0) + + if let chunk = String(data: buffer, encoding: .utf8) { + lines = chunk.components(separatedBy: .newlines) + lines = lines.filter { !$0.isEmpty } + } + } + + lines = Array(lines.suffix(n)) + for line in lines { + print(line) + } + } else { + // Fast path if all they want is the full file. + guard let data = try fh.readToEnd() else { + // Seems you get nil if it's a zero byte read, or you + // try and read from dev/null. + return + } + guard let str = String(data: data, encoding: .utf8) else { + throw ContainerizationError( + .internalError, + message: "failed to convert container logs to utf8" + ) + } + print(str.trimmingCharacters(in: .newlines)) + } + + if follow { + try await Self.followFile(fh: fh) + } + } + + private static func followFile(fh: FileHandle) async throws { + _ = try fh.seekToEnd() + let stream = AsyncStream { cont in + fh.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + // Triggers on container restart - can exit here as well + do { + _ = try fh.seekToEnd() // To continue streaming existing truncated log files + } catch { + fh.readabilityHandler = nil + cont.finish() + return + } + } + if let str = String(data: data, encoding: .utf8), !str.isEmpty { + var lines = str.components(separatedBy: .newlines) + lines = lines.filter { !$0.isEmpty } + for line in lines { + cont.yield(line) + } + } + } + } + + for await line in stream { + print(line) + } + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerStart.swift b/Sources/ContainerCLI/Container/ContainerStart.swift new file mode 100644 index 000000000..a804b9c20 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerStart.swift @@ -0,0 +1,87 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import TerminalProgress + +extension Application { + struct ContainerStart: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Start a container") + + @Flag(name: .shortAndLong, help: "Attach STDOUT/STDERR") + var attach = false + + @Flag(name: .shortAndLong, help: "Attach container's STDIN") + var interactive = false + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Container's ID") + var containerID: String + + func run() async throws { + var exitCode: Int32 = 127 + + let progressConfig = try ProgressConfig( + description: "Starting container" + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + let container = try await ClientContainer.get(id: containerID) + let process = try await container.bootstrap() + + progress.set(description: "Starting init process") + let detach = !self.attach && !self.interactive + do { + let io = try ProcessIO.create( + tty: container.configuration.initProcess.terminal, + interactive: self.interactive, + detach: detach + ) + progress.finish() + if detach { + try await process.start(io.stdio) + defer { + try? io.close() + } + try io.closeAfterStart() + print(self.containerID) + return + } + + exitCode = try await Application.handleProcess(io: io, process: process) + } catch { + try? await container.stop() + + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to start container: \(error)") + } + throw ArgumentParser.ExitCode(exitCode) + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerStop.swift b/Sources/ContainerCLI/Container/ContainerStop.swift new file mode 100644 index 000000000..78f69090e --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerStop.swift @@ -0,0 +1,102 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import Foundation + +extension Application { + struct ContainerStop: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "stop", + abstract: "Stop one or more running containers") + + @Flag(name: .shortAndLong, help: "Stop all running containers") + var all = false + + @Option(name: .shortAndLong, help: "Signal to send the container(s)") + var signal: String = "SIGTERM" + + @Option(name: .shortAndLong, help: "Seconds to wait before killing the container(s)") + var time: Int32 = 5 + + @Argument + var containerIDs: [String] = [] + + @OptionGroup + var global: Flags.Global + + func validate() throws { + if containerIDs.count == 0 && !all { + throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") + } + if containerIDs.count > 0 && all { + throw ContainerizationError( + .invalidArgument, message: "explicitly supplied container IDs conflicts with the --all flag") + } + } + + mutating func run() async throws { + let set = Set(containerIDs) + var containers = [ClientContainer]() + if self.all { + containers = try await ClientContainer.list() + } else { + containers = try await ClientContainer.list().filter { c in + set.contains(c.id) + } + } + + let opts = ContainerStopOptions( + timeoutInSeconds: self.time, + signal: try Signals.parseSignal(self.signal) + ) + let failed = try await Self.stopContainers(containers: containers, stopOptions: opts) + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "stop failed for one or more containers \(failed.joined(separator: ","))") + } + } + + static func stopContainers(containers: [ClientContainer], stopOptions: ContainerStopOptions) async throws -> [String] { + var failed: [String] = [] + try await withThrowingTaskGroup(of: ClientContainer?.self) { group in + for container in containers { + group.addTask { + do { + try await container.stop(opts: stopOptions) + print(container.id) + return nil + } catch { + log.error("failed to stop container \(container.id): \(error)") + return container + } + } + } + + for try await ctr in group { + guard let ctr else { + continue + } + failed.append(ctr.id) + } + } + + return failed + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainersCommand.swift b/Sources/ContainerCLI/Container/ContainersCommand.swift new file mode 100644 index 000000000..ef6aff93e --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainersCommand.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct ContainersCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "containers", + abstract: "Manage containers", + subcommands: [ + ContainerCreate.self, + ContainerDelete.self, + ContainerExec.self, + ContainerInspect.self, + ContainerKill.self, + ContainerList.self, + ContainerLogs.self, + ContainerStart.self, + ContainerStop.self, + ], + aliases: ["container", "c"] + ) + } +} diff --git a/Sources/ContainerCLI/Container/ProcessUtils.swift b/Sources/ContainerCLI/Container/ProcessUtils.swift new file mode 100644 index 000000000..d4dda6a27 --- /dev/null +++ b/Sources/ContainerCLI/Container/ProcessUtils.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerClient +import Containerization +import ContainerizationError +import ContainerizationOS +import Foundation + +extension Application { + static func ensureRunning(container: ClientContainer) throws { + if container.status != .running { + throw ContainerizationError(.invalidState, message: "container \(container.id) is not running") + } + } +} diff --git a/Sources/ContainerCLI/DefaultCommand.swift b/Sources/ContainerCLI/DefaultCommand.swift new file mode 100644 index 000000000..ef88aaaa3 --- /dev/null +++ b/Sources/ContainerCLI/DefaultCommand.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin + +struct DefaultCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: nil, + shouldDisplay: false + ) + + @OptionGroup(visibility: .hidden) + var global: Flags.Global + + @Argument(parsing: .captureForPassthrough) + var remaining: [String] = [] + + func run() async throws { + // See if we have a possible plugin command. + guard let command = remaining.first else { + Application.printModifiedHelpText() + return + } + + // Check for edge cases and unknown options to match the behavior in the absence of plugins. + if command.isEmpty { + throw ValidationError("Unknown argument '\(command)'") + } else if command.starts(with: "-") { + throw ValidationError("Unknown option '\(command)'") + } + + let pluginLoader = Application.pluginLoader + guard let plugin = pluginLoader.findPlugin(name: command), plugin.config.isCLI else { + throw ValidationError("failed to find plugin named container-\(command)") + } + // Exec performs execvp (with no fork). + try plugin.exec(args: remaining) + } +} diff --git a/Sources/ContainerCLI/Image/ImageInspect.swift b/Sources/ContainerCLI/Image/ImageInspect.swift new file mode 100644 index 000000000..cea356867 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageInspect.swift @@ -0,0 +1,53 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation +import SwiftProtobuf + +extension Application { + struct ImageInspect: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display information about one or more images") + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Images to inspect") + var images: [String] + + func run() async throws { + var printable = [any Codable]() + let result = try await ClientImage.get(names: images) + let notFound = result.error + for image in result.images { + guard !Utility.isInfraImage(name: image.reference) else { + continue + } + printable.append(try await image.details()) + } + if printable.count > 0 { + print(try printable.jsonArray()) + } + if notFound.count > 0 { + throw ContainerizationError(.notFound, message: "Images: \(notFound.joined(separator: "\n"))") + } + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageList.swift b/Sources/ContainerCLI/Image/ImageList.swift new file mode 100644 index 000000000..e666feca7 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageList.swift @@ -0,0 +1,175 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationOCI +import Foundation +import SwiftProtobuf + +extension Application { + struct ListImageOptions: ParsableArguments { + @Flag(name: .shortAndLong, help: "Only output the image name") + var quiet = false + + @Flag(name: .shortAndLong, help: "Verbose output") + var verbose = false + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @OptionGroup + var global: Flags.Global + } + + struct ListImageImplementation { + static private func createHeader() -> [[String]] { + [["NAME", "TAG", "DIGEST"]] + } + + static private func createVerboseHeader() -> [[String]] { + [["NAME", "TAG", "INDEX DIGEST", "OS", "ARCH", "VARIANT", "SIZE", "CREATED", "MANIFEST DIGEST"]] + } + + static private func printImagesVerbose(images: [ClientImage]) async throws { + + var rows = createVerboseHeader() + for image in images { + let formatter = ByteCountFormatter() + for descriptor in try await image.index().manifests { + // Don't list attestation manifests + if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], + referenceType == "attestation-manifest" + { + continue + } + + guard let platform = descriptor.platform else { + continue + } + + let os = platform.os + let arch = platform.architecture + let variant = platform.variant ?? "" + + var config: ContainerizationOCI.Image + var manifest: ContainerizationOCI.Manifest + do { + config = try await image.config(for: platform) + manifest = try await image.manifest(for: platform) + } catch { + continue + } + + let created = config.created ?? "" + let size = descriptor.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size }) + let formattedSize = formatter.string(fromByteCount: size) + + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) + let row = [ + reference.name, + reference.tag ?? "", + Utility.trimDigest(digest: image.descriptor.digest), + os, + arch, + variant, + formattedSize, + created, + Utility.trimDigest(digest: descriptor.digest), + ] + rows.append(row) + } + } + + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + + static private func printImages(images: [ClientImage], format: ListFormat, options: ListImageOptions) async throws { + var images = images + images.sort { + $0.reference < $1.reference + } + + if format == .json { + let data = try JSONEncoder().encode(images.map { $0.description }) + print(String(data: data, encoding: .utf8)!) + return + } + + if options.quiet { + try images.forEach { image in + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + print(processedReferenceString) + } + return + } + + if options.verbose { + try await Self.printImagesVerbose(images: images) + return + } + + var rows = createHeader() + for image in images { + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) + rows.append([ + reference.name, + reference.tag ?? "", + Utility.trimDigest(digest: image.descriptor.digest), + ]) + } + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + + static func validate(options: ListImageOptions) throws { + if options.quiet && options.verbose { + throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite and --verbose together") + } + let modifier = options.quiet || options.verbose + if modifier && options.format == .json { + throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite or --verbose along with --format json") + } + } + + static func listImages(options: ListImageOptions) async throws { + let images = try await ClientImage.list().filter { img in + !Utility.isInfraImage(name: img.reference) + } + try await printImages(images: images, format: options.format, options: options) + } + } + + struct ImageList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List images", + aliases: ["ls"]) + + @OptionGroup + var options: ListImageOptions + + mutating func run() async throws { + try ListImageImplementation.validate(options: options) + try await ListImageImplementation.listImages(options: options) + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageLoad.swift b/Sources/ContainerCLI/Image/ImageLoad.swift new file mode 100644 index 000000000..719fd19ec --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageLoad.swift @@ -0,0 +1,76 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import Foundation +import TerminalProgress + +extension Application { + struct ImageLoad: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "load", + abstract: "Load images from an OCI compatible tar archive" + ) + + @OptionGroup + var global: Flags.Global + + @Option( + name: .shortAndLong, help: "Path to the tar archive to load images from", completion: .file(), + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) + }) + var input: String + + func run() async throws { + guard FileManager.default.fileExists(atPath: input) else { + print("File does not exist \(input)") + Application.exit(withError: ArgumentParser.ExitCode(1)) + } + + let progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + totalTasks: 2 + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + progress.set(description: "Loading tar archive") + let loaded = try await ClientImage.load(from: input) + + let taskManager = ProgressTaskCoordinator() + let unpackTask = await taskManager.startTask() + progress.set(description: "Unpacking image") + progress.set(itemsName: "entries") + for image in loaded { + try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) + } + await taskManager.finish() + progress.finish() + print("Loaded images:") + for image in loaded { + print(image.reference) + } + } + } +} diff --git a/Sources/ContainerCLI/Image/ImagePrune.swift b/Sources/ContainerCLI/Image/ImagePrune.swift new file mode 100644 index 000000000..d233247f1 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImagePrune.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension Application { + struct ImagePrune: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "prune", + abstract: "Remove unreferenced and dangling images") + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let (_, size) = try await ClientImage.pruneImages() + let formatter = ByteCountFormatter() + let freed = formatter.string(fromByteCount: Int64(size)) + print("Cleaned unreferenced images and snapshots") + print("Reclaimed \(freed) in disk space") + } + } +} diff --git a/Sources/ContainerCLI/Image/ImagePull.swift b/Sources/ContainerCLI/Image/ImagePull.swift new file mode 100644 index 000000000..58f6dc2c6 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImagePull.swift @@ -0,0 +1,98 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationOCI +import TerminalProgress + +extension Application { + struct ImagePull: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "pull", + abstract: "Pull an image" + ) + + @OptionGroup + var global: Flags.Global + + @OptionGroup + var registry: Flags.Registry + + @OptionGroup + var progressFlags: Flags.Progress + + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? + + @Argument var reference: String + + init() {} + + init(platform: String? = nil, scheme: String = "auto", reference: String, disableProgress: Bool = false) { + self.global = Flags.Global() + self.registry = Flags.Registry(scheme: scheme) + self.progressFlags = Flags.Progress(disableProgressUpdates: disableProgress) + self.platform = platform + self.reference = reference + } + + func run() async throws { + var p: Platform? + if let platform { + p = try Platform(from: platform) + } + + let scheme = try RequestScheme(registry.scheme) + + let processedReference = try ClientImage.normalizeReference(reference) + + var progressConfig: ProgressConfig + if self.progressFlags.disableProgressUpdates { + progressConfig = try ProgressConfig(disableProgressUpdates: self.progressFlags.disableProgressUpdates) + } else { + progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + ignoreSmallSize: true, + totalTasks: 2 + ) + } + + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + progress.set(description: "Fetching image") + progress.set(itemsName: "blobs") + let taskManager = ProgressTaskCoordinator() + let fetchTask = await taskManager.startTask() + let image = try await ClientImage.pull( + reference: processedReference, platform: p, scheme: scheme, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler) + ) + + progress.set(description: "Unpacking image") + progress.set(itemsName: "entries") + let unpackTask = await taskManager.startTask() + try await image.unpack(platform: p, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) + await taskManager.finish() + progress.finish() + } + } +} diff --git a/Sources/ContainerCLI/Image/ImagePush.swift b/Sources/ContainerCLI/Image/ImagePush.swift new file mode 100644 index 000000000..e61d162de --- /dev/null +++ b/Sources/ContainerCLI/Image/ImagePush.swift @@ -0,0 +1,73 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationOCI +import TerminalProgress + +extension Application { + struct ImagePush: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "push", + abstract: "Push an image" + ) + + @OptionGroup + var global: Flags.Global + + @OptionGroup + var registry: Flags.Registry + + @OptionGroup + var progressFlags: Flags.Progress + + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? + + @Argument var reference: String + + func run() async throws { + var p: Platform? + if let platform { + p = try Platform(from: platform) + } + + let scheme = try RequestScheme(registry.scheme) + let image = try await ClientImage.get(reference: reference) + + var progressConfig: ProgressConfig + if progressFlags.disableProgressUpdates { + progressConfig = try ProgressConfig(disableProgressUpdates: progressFlags.disableProgressUpdates) + } else { + progressConfig = try ProgressConfig( + description: "Pushing image \(image.reference)", + itemsName: "blobs", + showItems: true, + showSpeed: false, + ignoreSmallSize: true + ) + } + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + _ = try await image.push(platform: p, scheme: scheme, progressUpdate: progress.handler) + progress.finish() + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageRemove.swift b/Sources/ContainerCLI/Image/ImageRemove.swift new file mode 100644 index 000000000..2f0c86c22 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageRemove.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import Foundation + +extension Application { + struct RemoveImageOptions: ParsableArguments { + @Flag(name: .shortAndLong, help: "Remove all images") + var all: Bool = false + + @Argument + var images: [String] = [] + + @OptionGroup + var global: Flags.Global + } + + struct RemoveImageImplementation { + static func validate(options: RemoveImageOptions) throws { + if options.images.count == 0 && !options.all { + throw ContainerizationError(.invalidArgument, message: "no image specified and --all not supplied") + } + if options.images.count > 0 && options.all { + throw ContainerizationError(.invalidArgument, message: "explicitly supplied images conflict with the --all flag") + } + } + + static func removeImage(options: RemoveImageOptions) async throws { + let (found, notFound) = try await { + if options.all { + let found = try await ClientImage.list() + let notFound: [String] = [] + return (found, notFound) + } + return try await ClientImage.get(names: options.images) + }() + var failures: [String] = notFound + var didDeleteAnyImage = false + for image in found { + guard !Utility.isInfraImage(name: image.reference) else { + continue + } + do { + try await ClientImage.delete(reference: image.reference, garbageCollect: false) + print(image.reference) + didDeleteAnyImage = true + } catch { + log.error("failed to remove \(image.reference): \(error)") + failures.append(image.reference) + } + } + let (_, size) = try await ClientImage.pruneImages() + let formatter = ByteCountFormatter() + let freed = formatter.string(fromByteCount: Int64(size)) + + if didDeleteAnyImage { + print("Reclaimed \(freed) in disk space") + } + if failures.count > 0 { + throw ContainerizationError(.internalError, message: "failed to delete one or more images: \(failures)") + } + } + } + + struct ImageRemove: AsyncParsableCommand { + @OptionGroup + var options: RemoveImageOptions + + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Remove one or more images", + aliases: ["rm"]) + + func validate() throws { + try RemoveImageImplementation.validate(options: options) + } + + mutating func run() async throws { + try await RemoveImageImplementation.removeImage(options: options) + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageSave.swift b/Sources/ContainerCLI/Image/ImageSave.swift new file mode 100644 index 000000000..8c0b6eac4 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageSave.swift @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationOCI +import Foundation +import TerminalProgress + +extension Application { + struct ImageSave: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "save", + abstract: "Save an image as an OCI compatible tar archive" + ) + + @OptionGroup + var global: Flags.Global + + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? + + @Option( + name: .shortAndLong, help: "Path to save the image tar archive", completion: .file(), + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) + }) + var output: String + + @Argument var reference: String + + func run() async throws { + var p: Platform? + if let platform { + p = try Platform(from: platform) + } + + let progressConfig = try ProgressConfig( + description: "Saving image" + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + let image = try await ClientImage.get(reference: reference) + try await image.save(out: output, platform: p) + + progress.finish() + print("Image saved") + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageTag.swift b/Sources/ContainerCLI/Image/ImageTag.swift new file mode 100644 index 000000000..01a76190f --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageTag.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct ImageTag: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "tag", + abstract: "Tag an image") + + @Argument(help: "SOURCE_IMAGE[:TAG]") + var source: String + + @Argument(help: "TARGET_IMAGE[:TAG]") + var target: String + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let existing = try await ClientImage.get(reference: source) + let targetReference = try ClientImage.normalizeReference(target) + try await existing.tag(new: targetReference) + print("Image \(source) tagged as \(target)") + } + } +} diff --git a/Sources/ContainerCLI/Image/ImagesCommand.swift b/Sources/ContainerCLI/Image/ImagesCommand.swift new file mode 100644 index 000000000..968dfd239 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImagesCommand.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct ImagesCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "images", + abstract: "Manage images", + subcommands: [ + ImageInspect.self, + ImageList.self, + ImageLoad.self, + ImagePrune.self, + ImagePull.self, + ImagePush.self, + ImageRemove.self, + ImageSave.self, + ImageTag.self, + ], + aliases: ["image", "i"] + ) + } +} diff --git a/Sources/ContainerCLI/Network/NetworkCommand.swift b/Sources/ContainerCLI/Network/NetworkCommand.swift new file mode 100644 index 000000000..7e502431b --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkCommand.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct NetworkCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "network", + abstract: "Manage container networks", + subcommands: [ + NetworkCreate.self, + NetworkDelete.self, + NetworkList.self, + NetworkInspect.self, + ], + aliases: ["n"] + ) + } +} diff --git a/Sources/ContainerCLI/Network/NetworkCreate.swift b/Sources/ContainerCLI/Network/NetworkCreate.swift new file mode 100644 index 000000000..535e029ed --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkCreate.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkService +import ContainerizationError +import Foundation +import TerminalProgress + +extension Application { + struct NetworkCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a new network") + + @Argument(help: "Network name") + var name: String + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let config = NetworkConfiguration(id: self.name, mode: .nat) + let state = try await ClientNetwork.create(configuration: config) + print(state.id) + } + } +} diff --git a/Sources/ContainerCLI/Network/NetworkDelete.swift b/Sources/ContainerCLI/Network/NetworkDelete.swift new file mode 100644 index 000000000..836d6c8ca --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkDelete.swift @@ -0,0 +1,116 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkService +import ContainerizationError +import Foundation + +extension Application { + struct NetworkDelete: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete one or more networks", + aliases: ["rm"]) + + @Flag(name: .shortAndLong, help: "Remove all networks") + var all = false + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Network names") + var networkNames: [String] = [] + + func validate() throws { + if networkNames.count == 0 && !all { + throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied") + } + if networkNames.count > 0 && all { + throw ContainerizationError( + .invalidArgument, + message: "explicitly supplied network name(s) conflict with the --all flag" + ) + } + } + + mutating func run() async throws { + let uniqueNetworkNames = Set(networkNames) + let networks: [NetworkState] + + if all { + networks = try await ClientNetwork.list() + } else { + networks = try await ClientNetwork.list() + .filter { c in + uniqueNetworkNames.contains(c.id) + } + + // If one of the networks requested isn't present lets throw. We don't need to do + // this for --all as --all should be perfectly usable with no networks to remove, + // otherwise it'd be quite clunky. + if networks.count != uniqueNetworkNames.count { + let missing = uniqueNetworkNames.filter { id in + !networks.contains { n in + n.id == id + } + } + throw ContainerizationError( + .notFound, + message: "failed to delete one or more networks: \(missing)" + ) + } + } + + if uniqueNetworkNames.contains(ClientNetwork.defaultNetworkName) { + throw ContainerizationError( + .invalidArgument, + message: "cannot delete the default network" + ) + } + + var failed = [String]() + try await withThrowingTaskGroup(of: NetworkState?.self) { group in + for network in networks { + group.addTask { + do { + // delete atomically disables the IP allocator, then deletes + // the allocator disable fails if any IPs are still in use + try await ClientNetwork.delete(id: network.id) + print(network.id) + return nil + } catch { + log.error("failed to delete network \(network.id): \(error)") + return network + } + } + } + + for try await network in group { + guard let network else { + continue + } + failed.append(network.id) + } + } + + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "delete failed for one or more networks: \(failed)") + } + } + } +} diff --git a/Sources/ContainerCLI/Network/NetworkInspect.swift b/Sources/ContainerCLI/Network/NetworkInspect.swift new file mode 100644 index 000000000..614c8b111 --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkInspect.swift @@ -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 ContainerNetworkService +import Foundation +import SwiftProtobuf + +extension Application { + struct NetworkInspect: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display information about one or more networks") + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Networks to inspect") + var networks: [String] + + func run() async throws { + let objects: [any Codable] = try await ClientNetwork.list().filter { + networks.contains($0.id) + }.map { + PrintableNetwork($0) + } + print(try objects.jsonArray()) + } + } +} diff --git a/Sources/ContainerCLI/Network/NetworkList.swift b/Sources/ContainerCLI/Network/NetworkList.swift new file mode 100644 index 000000000..9fb44dcb4 --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkList.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkService +import ContainerizationExtras +import Foundation +import SwiftProtobuf + +extension Application { + struct NetworkList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List networks", + 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 networks = try await ClientNetwork.list() + try printNetworks(networks: networks, format: format) + } + + private func createHeader() -> [[String]] { + [["NETWORK", "STATE", "SUBNET"]] + } + + private func printNetworks(networks: [NetworkState], format: ListFormat) throws { + if format == .json { + let printables = networks.map { + PrintableNetwork($0) + } + let data = try JSONEncoder().encode(printables) + print(String(data: data, encoding: .utf8)!) + + return + } + + if self.quiet { + networks.forEach { + print($0.id) + } + return + } + + var rows = createHeader() + for network in networks { + rows.append(network.asRow) + } + + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + } +} + +extension NetworkState { + var asRow: [String] { + switch self { + case .created(_): + return [self.id, self.state, "none"] + case .running(_, let status): + return [self.id, self.state, status.address] + } + } +} + +struct PrintableNetwork: Codable { + let id: String + let state: String + let config: NetworkConfiguration + let status: NetworkStatus? + + init(_ network: NetworkState) { + self.id = network.id + self.state = network.state + switch network { + case .created(let config): + self.config = config + self.status = nil + case .running(let config, let status): + self.config = config + self.status = status + } + } +} diff --git a/Sources/ContainerCLI/Registry/Login.swift b/Sources/ContainerCLI/Registry/Login.swift new file mode 100644 index 000000000..7de7fe7e4 --- /dev/null +++ b/Sources/ContainerCLI/Registry/Login.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationOCI +import Foundation + +extension Application { + struct Login: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Login to a registry" + ) + + @Option(name: .shortAndLong, help: "Username") + var username: String = "" + + @Flag(help: "Take the password from stdin") + var passwordStdin: Bool = false + + @Argument(help: "Registry server name") + var server: String + + @OptionGroup + var registry: Flags.Registry + + func run() async throws { + var username = self.username + var password = "" + if passwordStdin { + if username == "" { + throw ContainerizationError( + .invalidArgument, message: "must provide --username with --password-stdin") + } + guard let passwordData = try FileHandle.standardInput.readToEnd() else { + throw ContainerizationError(.invalidArgument, message: "failed to read password from stdin") + } + password = String(decoding: passwordData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + } + let keychain = KeychainHelper(id: Constants.keychainID) + if username == "" { + username = try keychain.userPrompt(domain: server) + } + if password == "" { + password = try keychain.passwordPrompt() + print() + } + + let server = Reference.resolveDomain(domain: server) + let scheme = try RequestScheme(registry.scheme).schemeFor(host: server) + let _url = "\(scheme)://\(server)" + guard let url = URL(string: _url) else { + throw ContainerizationError(.invalidArgument, message: "Cannot convert \(_url) to URL") + } + guard let host = url.host else { + throw ContainerizationError(.invalidArgument, message: "Invalid host \(server)") + } + + let client = RegistryClient( + host: host, + scheme: scheme.rawValue, + port: url.port, + authentication: BasicAuthentication(username: username, password: password), + retryOptions: .init( + maxRetries: 10, + retryInterval: 300_000_000, + shouldRetry: ({ response in + response.status.code >= 500 + }) + ) + ) + try await client.ping() + try keychain.save(domain: server, username: username, password: password) + print("Login succeeded") + } + } +} diff --git a/Sources/ContainerCLI/Registry/Logout.swift b/Sources/ContainerCLI/Registry/Logout.swift new file mode 100644 index 000000000..a24996e12 --- /dev/null +++ b/Sources/ContainerCLI/Registry/Logout.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationOCI + +extension Application { + struct Logout: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Log out from a registry") + + @Argument(help: "Registry server name") + var registry: String + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let keychain = KeychainHelper(id: Constants.keychainID) + let r = Reference.resolveDomain(domain: registry) + try keychain.delete(domain: r) + } + } +} diff --git a/Sources/ContainerCLI/Registry/RegistryCommand.swift b/Sources/ContainerCLI/Registry/RegistryCommand.swift new file mode 100644 index 000000000..c160c9469 --- /dev/null +++ b/Sources/ContainerCLI/Registry/RegistryCommand.swift @@ -0,0 +1,32 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct RegistryCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "registry", + abstract: "Manage registry configurations", + subcommands: [ + Login.self, + Logout.self, + RegistryDefault.self, + ], + aliases: ["r"] + ) + } +} diff --git a/Sources/ContainerCLI/Registry/RegistryDefault.swift b/Sources/ContainerCLI/Registry/RegistryDefault.swift new file mode 100644 index 000000000..593d41e27 --- /dev/null +++ b/Sources/ContainerCLI/Registry/RegistryDefault.swift @@ -0,0 +1,98 @@ +//===----------------------------------------------------------------------===// +// 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 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 + } + } + ClientDefaults.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 { + ClientDefaults.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(ClientDefaults.get(key: .defaultRegistryDomain)) + } + } +} diff --git a/Sources/ContainerCLI/RunCommand.swift b/Sources/ContainerCLI/RunCommand.swift new file mode 100644 index 000000000..3a818e939 --- /dev/null +++ b/Sources/ContainerCLI/RunCommand.swift @@ -0,0 +1,317 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOS +import Foundation +import NIOCore +import NIOPosix +import TerminalProgress + +extension Application { + struct ContainerRunCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "run", + abstract: "Run a container") + + @OptionGroup + var processFlags: Flags.Process + + @OptionGroup + var resourceFlags: Flags.Resource + + @OptionGroup + var managementFlags: Flags.Management + + @OptionGroup + var registryFlags: Flags.Registry + + @OptionGroup + var global: Flags.Global + + @OptionGroup + var progressFlags: Flags.Progress + + @Argument(help: "Image name") + var image: String + + @Argument(parsing: .captureForPassthrough, help: "Container init process arguments") + var arguments: [String] = [] + + func run() async throws { + var exitCode: Int32 = 127 + let id = Utility.createContainerID(name: self.managementFlags.name) + + var progressConfig: ProgressConfig + if progressFlags.disableProgressUpdates { + progressConfig = try ProgressConfig(disableProgressUpdates: progressFlags.disableProgressUpdates) + } else { + progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + ignoreSmallSize: true, + totalTasks: 6 + ) + } + + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + try Utility.validEntityName(id) + + // Check if container with id already exists. + let existing = try? await ClientContainer.get(id: id) + guard existing == nil else { + throw ContainerizationError( + .exists, + message: "container with id \(id) already exists" + ) + } + + let ck = try await Utility.containerConfigFromFlags( + id: id, + image: image, + arguments: arguments, + process: processFlags, + management: managementFlags, + resource: resourceFlags, + registry: registryFlags, + progressUpdate: progress.handler + ) + + progress.set(description: "Starting container") + + let options = ContainerCreateOptions(autoRemove: managementFlags.remove) + let container = try await ClientContainer.create( + configuration: ck.0, + options: options, + kernel: ck.1 + ) + + let detach = self.managementFlags.detach + + let process = try await container.bootstrap() + progress.finish() + + do { + let io = try ProcessIO.create( + tty: self.processFlags.tty, + interactive: self.processFlags.interactive, + detach: detach + ) + + if !self.managementFlags.cidfile.isEmpty { + let path = self.managementFlags.cidfile + let data = id.data(using: .utf8) + var attributes = [FileAttributeKey: Any]() + attributes[.posixPermissions] = 0o644 + let success = FileManager.default.createFile( + atPath: path, + contents: data, + attributes: attributes + ) + guard success else { + throw ContainerizationError( + .internalError, message: "failed to create cidfile at \(path): \(errno)") + } + } + + if detach { + try await process.start(io.stdio) + defer { + try? io.close() + } + try io.closeAfterStart() + print(id) + return + } + + if !self.processFlags.tty { + var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) + handler.start { + print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") + Darwin.exit(1) + } + } + + exitCode = try await Application.handleProcess(io: io, process: process) + } catch { + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to run container: \(error)") + } + throw ArgumentParser.ExitCode(exitCode) + } + } +} + +struct ProcessIO { + let stdin: Pipe? + let stdout: Pipe? + let stderr: Pipe? + var ioTracker: IoTracker? + + struct IoTracker { + let stream: AsyncStream + let cont: AsyncStream.Continuation + let configuredStreams: Int + } + + let stdio: [FileHandle?] + + let console: Terminal? + + func closeAfterStart() throws { + try stdin?.fileHandleForReading.close() + try stdout?.fileHandleForWriting.close() + try stderr?.fileHandleForWriting.close() + } + + func close() throws { + try console?.reset() + } + + static func create(tty: Bool, interactive: Bool, detach: Bool) throws -> ProcessIO { + let current: Terminal? = try { + if !tty { + return nil + } + let current = try Terminal.current + try current.setraw() + return current + }() + + var stdio = [FileHandle?](repeating: nil, count: 3) + + let stdin: Pipe? = { + if !interactive && !tty { + return nil + } + return Pipe() + }() + + if let stdin { + if interactive { + let pin = FileHandle.standardInput + pin.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + pin.readabilityHandler = nil + return + } + try! stdin.fileHandleForWriting.write(contentsOf: data) + } + } + stdio[0] = stdin.fileHandleForReading + } + + let stdout: Pipe? = { + if detach { + return nil + } + return Pipe() + }() + + var configuredStreams = 0 + let (stream, cc) = AsyncStream.makeStream() + if let stdout { + configuredStreams += 1 + let pout: FileHandle = { + if let current { + return current.handle + } + return .standardOutput + }() + + let rout = stdout.fileHandleForReading + rout.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + rout.readabilityHandler = nil + cc.yield() + return + } + try! pout.write(contentsOf: data) + } + stdio[1] = stdout.fileHandleForWriting + } + + let stderr: Pipe? = { + if detach || tty { + return nil + } + return Pipe() + }() + if let stderr { + configuredStreams += 1 + let perr: FileHandle = .standardError + let rerr = stderr.fileHandleForReading + rerr.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + rerr.readabilityHandler = nil + cc.yield() + return + } + try! perr.write(contentsOf: data) + } + stdio[2] = stderr.fileHandleForWriting + } + + var ioTracker: IoTracker? = nil + if configuredStreams > 0 { + ioTracker = .init(stream: stream, cont: cc, configuredStreams: configuredStreams) + } + + return .init( + stdin: stdin, + stdout: stdout, + stderr: stderr, + ioTracker: ioTracker, + stdio: stdio, + console: current + ) + } + + public func wait() async throws { + guard let ioTracker = self.ioTracker else { + return + } + do { + try await Timeout.run(seconds: 3) { + var counter = ioTracker.configuredStreams + for await _ in ioTracker.stream { + counter -= 1 + if counter == 0 { + ioTracker.cont.finish() + break + } + } + } + } catch { + log.error("Timeout waiting for IO to complete : \(error)") + throw error + } + } +} diff --git a/Sources/ContainerCLI/System/DNS/DNSCreate.swift b/Sources/ContainerCLI/System/DNS/DNSCreate.swift new file mode 100644 index 000000000..2dbe2d8ac --- /dev/null +++ b/Sources/ContainerCLI/System/DNS/DNSCreate.swift @@ -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 ContainerizationError +import ContainerizationExtras +import Foundation + +extension Application { + struct DNSCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a local DNS domain for containers (must run as an administrator)" + ) + + @Argument(help: "the local domain name") + var domainName: String + + func run() async throws { + let resolver: HostDNSResolver = HostDNSResolver() + do { + try resolver.createDomain(name: domainName) + print(domainName) + } catch let error as ContainerizationError { + throw error + } catch { + throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") + } + + do { + try HostDNSResolver.reinitialize() + } catch { + throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") + } + } + } +} diff --git a/Sources/ContainerCLI/System/DNS/DNSDefault.swift b/Sources/ContainerCLI/System/DNS/DNSDefault.swift new file mode 100644 index 000000000..5a746eab5 --- /dev/null +++ b/Sources/ContainerCLI/System/DNS/DNSDefault.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// 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 + +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 { + ClientDefaults.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 { + ClientDefaults.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(ClientDefaults.getOptional(key: .defaultDNSDomain) ?? "") + } + } + } +} diff --git a/Sources/ContainerCLI/System/DNS/DNSDelete.swift b/Sources/ContainerCLI/System/DNS/DNSDelete.swift new file mode 100644 index 000000000..b3360bb57 --- /dev/null +++ b/Sources/ContainerCLI/System/DNS/DNSDelete.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct DNSDelete: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete a local DNS domain (must run as an administrator)", + aliases: ["rm"] + ) + + @Argument(help: "the local domain name") + var domainName: String + + func run() async throws { + let resolver = HostDNSResolver() + do { + try resolver.deleteDomain(name: domainName) + print(domainName) + } catch { + throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") + } + + do { + try HostDNSResolver.reinitialize() + } catch { + throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") + } + } + } +} diff --git a/Sources/ContainerCLI/System/DNS/DNSList.swift b/Sources/ContainerCLI/System/DNS/DNSList.swift new file mode 100644 index 000000000..616415775 --- /dev/null +++ b/Sources/ContainerCLI/System/DNS/DNSList.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension Application { + struct DNSList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List local DNS domains", + aliases: ["ls"] + ) + + func run() async throws { + let resolver: HostDNSResolver = HostDNSResolver() + let domains = resolver.listDomains() + print(domains.joined(separator: "\n")) + } + + } +} diff --git a/Sources/ContainerCLI/System/Kernel/KernelSet.swift b/Sources/ContainerCLI/System/Kernel/KernelSet.swift new file mode 100644 index 000000000..6a1ac1790 --- /dev/null +++ b/Sources/ContainerCLI/System/Kernel/KernelSet.swift @@ -0,0 +1,114 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Foundation +import TerminalProgress + +extension Application { + struct KernelSet: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "set", + abstract: "Set the default kernel" + ) + + @Option(name: .customLong("binary"), help: "Path to the binary to set as the default kernel. If used with --tar, this points to a location inside the tar") + var binaryPath: String? = nil + + @Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar ball that contains the kernel to use") + var tarPath: String? = nil + + @Option(name: .customLong("arch"), help: "The architecture of the kernel binary. One of (amd64, arm64)") + var architecture: String = ContainerizationOCI.Platform.current.architecture.description + + @Flag(name: .customLong("recommended"), help: "Download and install the recommended kernel as the default. This flag ignores any other arguments") + var recommended: Bool = false + + func run() async throws { + if recommended { + let url = ClientDefaults.get(key: .defaultKernelURL) + let path = ClientDefaults.get(key: .defaultKernelBinaryPath) + print("Installing the recommended kernel from \(url)...") + try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path) + return + } + guard tarPath != nil else { + return try await self.setKernelFromBinary() + } + try await self.setKernelFromTar() + } + + private func setKernelFromBinary() async throws { + guard let binaryPath else { + throw ArgumentParser.ValidationError("Missing argument '--binary'") + } + let absolutePath = URL(fileURLWithPath: binaryPath, relativeTo: .currentDirectory()).absoluteURL.absoluteString + let platform = try getSystemPlatform() + try await ClientKernel.installKernel(kernelFilePath: absolutePath, platform: platform) + } + + private func setKernelFromTar() async throws { + guard let binaryPath else { + throw ArgumentParser.ValidationError("Missing argument '--binary'") + } + guard let tarPath else { + throw ArgumentParser.ValidationError("Missing argument '--tar") + } + let platform = try getSystemPlatform() + let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).absoluteString + let fm = FileManager.default + if fm.fileExists(atPath: localTarPath) { + try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform) + return + } + guard let remoteURL = URL(string: tarPath) else { + throw ContainerizationError(.invalidArgument, message: "Invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?") + } + try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL.absoluteString, kernelFilePath: binaryPath, platform: platform) + } + + private func getSystemPlatform() throws -> SystemPlatform { + switch architecture { + case "arm64": + return .linuxArm + case "amd64": + return .linuxAmd + default: + throw ContainerizationError(.unsupported, message: "Unsupported architecture \(architecture)") + } + } + + public static func downloadAndInstallWithProgressBar(tarRemoteURL: String, kernelFilePath: String, platform: SystemPlatform = .current) async throws { + let progressConfig = try ProgressConfig( + showTasks: true, + totalTasks: 2 + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + try await ClientKernel.installKernelFromTar(tarFile: tarRemoteURL, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler) + progress.finish() + } + + } +} diff --git a/Sources/ContainerCLI/System/SystemCommand.swift b/Sources/ContainerCLI/System/SystemCommand.swift new file mode 100644 index 000000000..3a92bfb92 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemCommand.swift @@ -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 + +extension Application { + struct SystemCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "system", + abstract: "Manage system components", + subcommands: [ + SystemDNS.self, + SystemLogs.self, + SystemStart.self, + SystemStop.self, + SystemStatus.self, + SystemKernel.self, + ], + aliases: ["s"] + ) + } +} diff --git a/Sources/ContainerCLI/System/SystemDNS.swift b/Sources/ContainerCLI/System/SystemDNS.swift new file mode 100644 index 000000000..4f9b3e3b3 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemDNS.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct SystemDNS: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "dns", + abstract: "Manage local DNS domains", + subcommands: [ + DNSCreate.self, + DNSDelete.self, + DNSList.self, + DNSDefault.self, + ] + ) + } +} diff --git a/Sources/ContainerCLI/System/SystemKernel.swift b/Sources/ContainerCLI/System/SystemKernel.swift new file mode 100644 index 000000000..942bd6965 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemKernel.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct SystemKernel: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "kernel", + abstract: "Manage the default kernel configuration", + subcommands: [ + KernelSet.self + ] + ) + } +} diff --git a/Sources/ContainerCLI/System/SystemLogs.swift b/Sources/ContainerCLI/System/SystemLogs.swift new file mode 100644 index 000000000..e2b87ffb9 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemLogs.swift @@ -0,0 +1,82 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import Foundation +import OSLog + +extension Application { + struct SystemLogs: AsyncParsableCommand { + static let subsystem = "com.apple.container" + + static let configuration = CommandConfiguration( + commandName: "logs", + abstract: "Fetch system logs for `container` services" + ) + + @OptionGroup + var global: Flags.Global + + @Option( + name: .long, + help: "Fetch logs starting from the specified time period (minus the current time); supported formats: m, h, d" + ) + var last: String = "5m" + + @Flag(name: .shortAndLong, help: "Follow log output") + var follow: Bool = false + + func run() async throws { + let process = Process() + let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM]) + + Task { + for await _ in sigHandler.signals { + process.terminate() + Darwin.exit(0) + } + } + + do { + var args = ["log"] + args.append(self.follow ? "stream" : "show") + args.append(contentsOf: ["--info", "--debug"]) + if !self.follow { + args.append(contentsOf: ["--last", last]) + } + args.append(contentsOf: ["--predicate", "subsystem = 'com.apple.container'"]) + + process.launchPath = "/usr/bin/env" + process.arguments = args + + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + + try process.run() + process.waitUntilExit() + } catch { + throw ContainerizationError( + .invalidArgument, + message: "failed to system logs: \(error)" + ) + } + throw ArgumentParser.ExitCode(process.terminationStatus) + } + } +} diff --git a/Sources/ContainerCLI/System/SystemStart.swift b/Sources/ContainerCLI/System/SystemStart.swift new file mode 100644 index 000000000..acce91391 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemStart.swift @@ -0,0 +1,170 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin +import ContainerizationError +import Foundation +import TerminalProgress + +extension Application { + struct SystemStart: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Start `container` services" + ) + + @Option(name: .shortAndLong, help: "Path to the `container-apiserver` binary") + var path: String = Bundle.main.executablePath ?? "" + + @Flag(name: .long, help: "Enable debug logging for the runtime daemon.") + var debug = false + + @Flag( + name: .long, inversion: .prefixedEnableDisable, + help: "Specify whether the default kernel should be installed or not. The default behavior is to prompt the user for a response.") + var kernelInstall: Bool? + + func run() async throws { + // Without the true path to the binary in the plist, `container-apiserver` won't launch properly. + let executableUrl = URL(filePath: path) + .resolvingSymlinksInPath() + .deletingLastPathComponent() + .appendingPathComponent("container-apiserver") + + var args = [executableUrl.absolutePath()] + if debug { + args.append("--debug") + } + + let apiServerDataUrl = appRoot.appending(path: "apiserver") + try! FileManager.default.createDirectory(at: apiServerDataUrl, withIntermediateDirectories: true) + let env = ProcessInfo.processInfo.environment.filter { key, _ in + key.hasPrefix("CONTAINER_") + } + + let logURL = apiServerDataUrl.appending(path: "apiserver.log") + let plist = LaunchPlist( + label: "com.apple.container.apiserver", + arguments: args, + environment: env, + limitLoadToSessionType: [.Aqua, .Background, .System], + runAtLoad: true, + stdout: logURL.path, + stderr: logURL.path, + machServices: ["com.apple.container.apiserver"] + ) + + let plistURL = apiServerDataUrl.appending(path: "apiserver.plist") + let data = try plist.encode() + try data.write(to: plistURL) + + try ServiceManager.register(plistPath: plistURL.path) + + // Now ping our friendly daemon. Fail if we don't get a response. + do { + print("Verifying apiserver is running...") + try await ClientHealthCheck.ping(timeout: .seconds(10)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get a response from apiserver: \(error)" + ) + } + + if await !initImageExists() { + try? await installInitialFilesystem() + } + + guard await !kernelExists() else { + return + } + try await installDefaultKernel() + } + + private func installInitialFilesystem() async throws { + let dep = Dependencies.initFs + let pullCommand = ImagePull(reference: dep.source) + print("Installing base container filesystem...") + do { + try await pullCommand.run() + } catch { + log.error("Failed to install base container filesystem: \(error)") + } + } + + private func installDefaultKernel() async throws { + let kernelDependency = Dependencies.kernel + let defaultKernelURL = kernelDependency.source + let defaultKernelBinaryPath = ClientDefaults.get(key: .defaultKernelBinaryPath) + + var shouldInstallKernel = false + if kernelInstall == nil { + print("No default kernel configured.") + print("Install the recommended default kernel from [\(kernelDependency.source)]? [Y/n]: ", terminator: "") + guard let read = readLine(strippingNewline: true) else { + throw ContainerizationError(.internalError, message: "Failed to read user input") + } + guard read.lowercased() == "y" || read.count == 0 else { + print("Please use the `container system kernel set --recommended` command to configure the default kernel") + return + } + shouldInstallKernel = true + } else { + shouldInstallKernel = kernelInstall ?? false + } + guard shouldInstallKernel else { + return + } + print("Installing kernel...") + try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: defaultKernelURL, kernelFilePath: defaultKernelBinaryPath) + } + + private func initImageExists() async -> Bool { + do { + let img = try await ClientImage.get(reference: Dependencies.initFs.source) + let _ = try await img.getSnapshot(platform: .current) + return true + } catch { + return false + } + } + + private func kernelExists() async -> Bool { + do { + try await ClientKernel.getDefaultKernel(for: .current) + return true + } catch { + return false + } + } + } + + private enum Dependencies: String { + case kernel + case initFs + + var source: String { + switch self { + case .initFs: + return ClientDefaults.get(key: .defaultInitImage) + case .kernel: + return ClientDefaults.get(key: .defaultKernelURL) + } + } + } +} diff --git a/Sources/ContainerCLI/System/SystemStatus.swift b/Sources/ContainerCLI/System/SystemStatus.swift new file mode 100644 index 000000000..132607681 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemStatus.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin +import ContainerizationError +import Foundation +import Logging + +extension Application { + struct SystemStatus: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "status", + abstract: "Show the status of `container` services" + ) + + @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") + var prefix: String = "com.apple.container." + + func run() async throws { + let isRegistered = try ServiceManager.isRegistered(fullServiceLabel: "\(prefix)apiserver") + if !isRegistered { + print("apiserver is not running and not registered with launchd") + Application.exit(withError: ExitCode(1)) + } + + // Now ping our friendly daemon. Fail after 10 seconds with no response. + do { + print("Verifying apiserver is running...") + try await ClientHealthCheck.ping(timeout: .seconds(10)) + print("apiserver is running") + } catch { + print("apiserver is not running") + Application.exit(withError: ExitCode(1)) + } + } + } +} diff --git a/Sources/ContainerCLI/System/SystemStop.swift b/Sources/ContainerCLI/System/SystemStop.swift new file mode 100644 index 000000000..32824dd0c --- /dev/null +++ b/Sources/ContainerCLI/System/SystemStop.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin +import ContainerizationOS +import Foundation +import Logging + +extension Application { + struct SystemStop: AsyncParsableCommand { + private static let stopTimeoutSeconds: Int32 = 5 + private static let shutdownTimeoutSeconds: Int32 = 20 + + static let configuration = CommandConfiguration( + commandName: "stop", + abstract: "Stop all `container` services" + ) + + @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") + var prefix: String = "com.apple.container." + + func run() async throws { + let log = Logger( + label: "com.apple.container.cli", + factory: { label in + StreamLogHandler.standardOutput(label: label) + } + ) + + let launchdDomainString = try ServiceManager.getDomainString() + let fullLabel = "\(launchdDomainString)/\(prefix)apiserver" + + log.info("stopping containers", metadata: ["stopTimeoutSeconds": "\(Self.stopTimeoutSeconds)"]) + do { + let containers = try await ClientContainer.list() + let signal = try Signals.parseSignal("SIGTERM") + let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: signal) + let failed = try await ContainerStop.stopContainers(containers: containers, stopOptions: opts) + if !failed.isEmpty { + log.warning("some containers could not be stopped gracefully", metadata: ["ids": "\(failed)"]) + } + + } catch { + log.warning("failed to stop all containers", metadata: ["error": "\(error)"]) + } + + log.info("waiting for containers to exit") + do { + for _ in 0.. Date: Tue, 15 Jul 2025 21:00:10 -0600 Subject: [PATCH 43/80] Revert "Update Package.swift" This reverts commit e21bdf9cead66115f6bd94977defccc47bbab538. --- Package.swift | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/Package.swift b/Package.swift index a6be60538..e81e38005 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,7 @@ let package = Package( .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService"]), .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerClient", targets: ["ContainerClient"]), -// .library(name: "ContainerCLI", targets: ["ContainerCLI"]), + .library(name: "ContainerCLI", targets: ["ContainerCLI"]), .library(name: "ContainerBuild", targets: ["ContainerBuild"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), @@ -76,26 +76,26 @@ let package = Package( ], path: "Sources/CLI" ), -// .target( -// name: "ContainerCLI", -// dependencies: [ -// .product(name: "ArgumentParser", package: "swift-argument-parser"), -// .product(name: "Logging", package: "swift-log"), -// .product(name: "SwiftProtobuf", package: "swift-protobuf"), -// .product(name: "Containerization", package: "containerization"), -// .product(name: "ContainerizationOCI", package: "containerization"), -// .product(name: "ContainerizationOS", package: "containerization"), -// "CVersion", -// "TerminalProgress", -// "ContainerBuild", -// "ContainerClient", -// "ContainerPlugin", -// "ContainerLog", -// "Yams", -// "Rainbow", -// ], -// path: "Sources/ContainerCLI" -// ), + .target( + name: "ContainerCLI", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "SwiftProtobuf", package: "swift-protobuf"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + "CVersion", + "TerminalProgress", + "ContainerBuild", + "ContainerClient", + "ContainerPlugin", + "ContainerLog", + "Yams", + "Rainbow", + ], + path: "Sources/ContainerCLI" + ), .executableTarget( name: "container-apiserver", dependencies: [ From 59ec9ddaf9107c71c9a365e57f353898aa4466fd Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:00:13 -0600 Subject: [PATCH 44/80] Revert "copy cli to container cli" This reverts commit 1d9704fb709988053aa2250af229fedb6b12c875. --- Sources/ContainerCLI | Bin 0 -> 916 bytes Sources/ContainerCLI/Application.swift | 335 -------- Sources/ContainerCLI/BuildCommand.swift | 313 -------- Sources/ContainerCLI/Builder/Builder.swift | 31 - .../ContainerCLI/Builder/BuilderDelete.swift | 57 -- .../ContainerCLI/Builder/BuilderStart.swift | 262 ------ .../ContainerCLI/Builder/BuilderStatus.swift | 71 -- .../ContainerCLI/Builder/BuilderStop.swift | 49 -- Sources/ContainerCLI/Codable+JSON.swift | 25 - .../Compose/Codable Structs/Build.swift | 52 -- .../Compose/Codable Structs/Config.swift | 55 -- .../Compose/Codable Structs/Deploy.swift | 35 - .../Codable Structs/DeployResources.swift | 31 - .../Codable Structs/DeployRestartPolicy.swift | 35 - .../Codable Structs/DeviceReservation.swift | 35 - .../Codable Structs/DockerCompose.swift | 60 -- .../Codable Structs/ExternalConfig.swift | 31 - .../Codable Structs/ExternalNetwork.swift | 31 - .../Codable Structs/ExternalSecret.swift | 31 - .../Codable Structs/ExternalVolume.swift | 31 - .../Compose/Codable Structs/Healthcheck.swift | 37 - .../Compose/Codable Structs/Network.swift | 68 -- .../Codable Structs/ResourceLimits.swift | 31 - .../ResourceReservations.swift | 34 - .../Compose/Codable Structs/Secret.swift | 58 -- .../Compose/Codable Structs/Service.swift | 203 ----- .../Codable Structs/ServiceConfig.swift | 64 -- .../Codable Structs/ServiceSecret.swift | 64 -- .../Compose/Codable Structs/Volume.swift | 70 -- .../Compose/Commands/ComposeDown.swift | 105 --- .../Compose/Commands/ComposeUp.swift | 749 ------------------ .../ContainerCLI/Compose/ComposeCommand.swift | 55 -- Sources/ContainerCLI/Compose/Errors.swift | 66 -- .../Compose/Helper Functions.swift | 96 --- .../Container/ContainerCreate.swift | 100 --- .../Container/ContainerDelete.swift | 127 --- .../Container/ContainerExec.swift | 96 --- .../Container/ContainerInspect.swift | 43 - .../Container/ContainerKill.swift | 79 -- .../Container/ContainerList.swift | 110 --- .../Container/ContainerLogs.swift | 144 ---- .../Container/ContainerStart.swift | 87 -- .../Container/ContainerStop.swift | 102 --- .../Container/ContainersCommand.swift | 38 - .../ContainerCLI/Container/ProcessUtils.swift | 31 - Sources/ContainerCLI/DefaultCommand.swift | 54 -- Sources/ContainerCLI/Image/ImageInspect.swift | 53 -- Sources/ContainerCLI/Image/ImageList.swift | 175 ---- Sources/ContainerCLI/Image/ImageLoad.swift | 76 -- Sources/ContainerCLI/Image/ImagePrune.swift | 38 - Sources/ContainerCLI/Image/ImagePull.swift | 98 --- Sources/ContainerCLI/Image/ImagePush.swift | 73 -- Sources/ContainerCLI/Image/ImageRemove.swift | 99 --- Sources/ContainerCLI/Image/ImageSave.swift | 67 -- Sources/ContainerCLI/Image/ImageTag.swift | 42 - .../ContainerCLI/Image/ImagesCommand.swift | 38 - .../ContainerCLI/Network/NetworkCommand.swift | 33 - .../ContainerCLI/Network/NetworkCreate.swift | 42 - .../ContainerCLI/Network/NetworkDelete.swift | 116 --- .../ContainerCLI/Network/NetworkInspect.swift | 44 - .../ContainerCLI/Network/NetworkList.swift | 107 --- Sources/ContainerCLI/Registry/Login.swift | 92 --- Sources/ContainerCLI/Registry/Logout.swift | 39 - .../Registry/RegistryCommand.swift | 32 - .../Registry/RegistryDefault.swift | 98 --- Sources/ContainerCLI/RunCommand.swift | 317 -------- .../ContainerCLI/System/DNS/DNSCreate.swift | 51 -- .../ContainerCLI/System/DNS/DNSDefault.swift | 72 -- .../ContainerCLI/System/DNS/DNSDelete.swift | 49 -- Sources/ContainerCLI/System/DNS/DNSList.swift | 36 - .../System/Kernel/KernelSet.swift | 114 --- .../ContainerCLI/System/SystemCommand.swift | 35 - Sources/ContainerCLI/System/SystemDNS.swift | 34 - .../ContainerCLI/System/SystemKernel.swift | 29 - Sources/ContainerCLI/System/SystemLogs.swift | 82 -- Sources/ContainerCLI/System/SystemStart.swift | 170 ---- .../ContainerCLI/System/SystemStatus.swift | 52 -- Sources/ContainerCLI/System/SystemStop.swift | 91 --- 78 files changed, 6775 deletions(-) create mode 100644 Sources/ContainerCLI delete mode 100644 Sources/ContainerCLI/Application.swift delete mode 100644 Sources/ContainerCLI/BuildCommand.swift delete mode 100644 Sources/ContainerCLI/Builder/Builder.swift delete mode 100644 Sources/ContainerCLI/Builder/BuilderDelete.swift delete mode 100644 Sources/ContainerCLI/Builder/BuilderStart.swift delete mode 100644 Sources/ContainerCLI/Builder/BuilderStatus.swift delete mode 100644 Sources/ContainerCLI/Builder/BuilderStop.swift delete mode 100644 Sources/ContainerCLI/Codable+JSON.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Build.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Config.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Network.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Secret.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Service.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Volume.swift delete mode 100644 Sources/ContainerCLI/Compose/Commands/ComposeDown.swift delete mode 100644 Sources/ContainerCLI/Compose/Commands/ComposeUp.swift delete mode 100644 Sources/ContainerCLI/Compose/ComposeCommand.swift delete mode 100644 Sources/ContainerCLI/Compose/Errors.swift delete mode 100644 Sources/ContainerCLI/Compose/Helper Functions.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerCreate.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerDelete.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerExec.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerInspect.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerKill.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerList.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerLogs.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerStart.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerStop.swift delete mode 100644 Sources/ContainerCLI/Container/ContainersCommand.swift delete mode 100644 Sources/ContainerCLI/Container/ProcessUtils.swift delete mode 100644 Sources/ContainerCLI/DefaultCommand.swift delete mode 100644 Sources/ContainerCLI/Image/ImageInspect.swift delete mode 100644 Sources/ContainerCLI/Image/ImageList.swift delete mode 100644 Sources/ContainerCLI/Image/ImageLoad.swift delete mode 100644 Sources/ContainerCLI/Image/ImagePrune.swift delete mode 100644 Sources/ContainerCLI/Image/ImagePull.swift delete mode 100644 Sources/ContainerCLI/Image/ImagePush.swift delete mode 100644 Sources/ContainerCLI/Image/ImageRemove.swift delete mode 100644 Sources/ContainerCLI/Image/ImageSave.swift delete mode 100644 Sources/ContainerCLI/Image/ImageTag.swift delete mode 100644 Sources/ContainerCLI/Image/ImagesCommand.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkCommand.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkCreate.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkDelete.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkInspect.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkList.swift delete mode 100644 Sources/ContainerCLI/Registry/Login.swift delete mode 100644 Sources/ContainerCLI/Registry/Logout.swift delete mode 100644 Sources/ContainerCLI/Registry/RegistryCommand.swift delete mode 100644 Sources/ContainerCLI/Registry/RegistryDefault.swift delete mode 100644 Sources/ContainerCLI/RunCommand.swift delete mode 100644 Sources/ContainerCLI/System/DNS/DNSCreate.swift delete mode 100644 Sources/ContainerCLI/System/DNS/DNSDefault.swift delete mode 100644 Sources/ContainerCLI/System/DNS/DNSDelete.swift delete mode 100644 Sources/ContainerCLI/System/DNS/DNSList.swift delete mode 100644 Sources/ContainerCLI/System/Kernel/KernelSet.swift delete mode 100644 Sources/ContainerCLI/System/SystemCommand.swift delete mode 100644 Sources/ContainerCLI/System/SystemDNS.swift delete mode 100644 Sources/ContainerCLI/System/SystemKernel.swift delete mode 100644 Sources/ContainerCLI/System/SystemLogs.swift delete mode 100644 Sources/ContainerCLI/System/SystemStart.swift delete mode 100644 Sources/ContainerCLI/System/SystemStatus.swift delete mode 100644 Sources/ContainerCLI/System/SystemStop.swift diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI new file mode 100644 index 0000000000000000000000000000000000000000..ce9b7e01c3c1838dc5198f48c2a927a02ac3d673 GIT binary patch literal 916 zcmZvayG{a85Qfis#YR^#YUjq-5|G=16&H;fO)L~bVS~#OiNKOYV`GfI0TyN?bbnot|+K=eGCL%gLVKhi6YPCaIXpJU4ma;2bxp~)HIHT!3#7@5p{B1!!f z3@u+Mn(JP#*YOcAWsUs<%^#NUE5uXa0=50~ynH?1!C$5Qs80lfn+cz;dxCm2=n2O4 zkSCh-M?Hz8KN1eDg*L(oU7r1h_CRm$;gw?4%Zru1gMxR9ZIv)~5v z9Jt2p2G^N2@GG+qEc+`>+(C}df{}7;u8Em&Txk#LRj~ZNi|@U=OB+_eE*{P=_%T-2 literal 0 HcmV?d00001 diff --git a/Sources/ContainerCLI/Application.swift b/Sources/ContainerCLI/Application.swift deleted file mode 100644 index ba002a74c..000000000 --- a/Sources/ContainerCLI/Application.swift +++ /dev/null @@ -1,335 +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 CVersion -import ContainerClient -import ContainerLog -import ContainerPlugin -import ContainerizationError -import ContainerizationOS -import Foundation -import Logging -import TerminalProgress - -// `log` is updated only once in the `validate()` method. -nonisolated(unsafe) var log = { - LoggingSystem.bootstrap { label in - OSLogHandler( - label: label, - category: "CLI" - ) - } - var log = Logger(label: "com.apple.container") - log.logLevel = .debug - return log -}() - -@main -public struct Application: AsyncParsableCommand { - public init() {} - - @OptionGroup - var global: Flags.Global - - public static let configuration = CommandConfiguration( - commandName: "container", - abstract: "A container platform for macOS", - version: releaseVersion(), - subcommands: [ - DefaultCommand.self - ], - groupedSubcommands: [ - CommandGroup( - name: "Container", - subcommands: [ - ComposeCommand.self, - ContainerCreate.self, - ContainerDelete.self, - ContainerExec.self, - ContainerInspect.self, - ContainerKill.self, - ContainerList.self, - ContainerLogs.self, - ContainerRunCommand.self, - ContainerStart.self, - ContainerStop.self, - ] - ), - CommandGroup( - name: "Image", - subcommands: [ - BuildCommand.self, - ImagesCommand.self, - RegistryCommand.self, - ] - ), - CommandGroup( - name: "Other", - subcommands: Self.otherCommands() - ), - ], - // Hidden command to handle plugins on unrecognized input. - defaultSubcommand: DefaultCommand.self - ) - - static let appRoot: URL = { - FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first! - .appendingPathComponent("com.apple.container") - }() - - static let pluginLoader: PluginLoader = { - // create user-installed plugins directory if it doesn't exist - let pluginsURL = PluginLoader.userPluginsDir(root: Self.appRoot) - try! FileManager.default.createDirectory(at: pluginsURL, withIntermediateDirectories: true) - let pluginDirectories = [ - pluginsURL - ] - let pluginFactories = [ - DefaultPluginFactory() - ] - - let statePath = PluginLoader.defaultPluginResourcePath(root: Self.appRoot) - try! FileManager.default.createDirectory(at: statePath, withIntermediateDirectories: true) - return PluginLoader(pluginDirectories: pluginDirectories, pluginFactories: pluginFactories, defaultResourcePath: statePath, log: log) - }() - - public static func main() async throws { - restoreCursorAtExit() - - #if DEBUG - let warning = "Running debug build. Performance may be degraded." - let formattedWarning = "\u{001B}[33mWarning!\u{001B}[0m \(warning)\n" - let warningData = Data(formattedWarning.utf8) - FileHandle.standardError.write(warningData) - #endif - - let fullArgs = CommandLine.arguments - let args = Array(fullArgs.dropFirst()) - - do { - // container -> defaultHelpCommand - var command = try Application.parseAsRoot(args) - if var asyncCommand = command as? AsyncParsableCommand { - try await asyncCommand.run() - } else { - try command.run() - } - } catch { - // Regular ol `command` with no args will get caught by DefaultCommand. --help - // on the root command will land here. - let containsHelp = fullArgs.contains("-h") || fullArgs.contains("--help") - if fullArgs.count <= 2 && containsHelp { - Self.printModifiedHelpText() - return - } - let errorAsString: String = String(describing: error) - if errorAsString.contains("XPC connection error") { - let modifiedError = ContainerizationError(.interrupted, message: "\(error)\nEnsure container system service has been started with `container system start`.") - Application.exit(withError: modifiedError) - } else { - Application.exit(withError: error) - } - } - } - - static func handleProcess(io: ProcessIO, process: ClientProcess) async throws -> Int32 { - let signals = AsyncSignalHandler.create(notify: Application.signalSet) - return try await withThrowingTaskGroup(of: Int32?.self, returning: Int32.self) { group in - let waitAdded = group.addTaskUnlessCancelled { - let code = try await process.wait() - try await io.wait() - return code - } - - guard waitAdded else { - group.cancelAll() - return -1 - } - - try await process.start(io.stdio) - defer { - try? io.close() - } - try io.closeAfterStart() - - if let current = io.console { - let size = try current.size - // It's supremely possible the process could've exited already. We shouldn't treat - // this as fatal. - try? await process.resize(size) - _ = group.addTaskUnlessCancelled { - let winchHandler = AsyncSignalHandler.create(notify: [SIGWINCH]) - for await _ in winchHandler.signals { - do { - try await process.resize(try current.size) - } catch { - log.error( - "failed to send terminal resize event", - metadata: [ - "error": "\(error)" - ] - ) - } - } - return nil - } - } else { - _ = group.addTaskUnlessCancelled { - for await sig in signals.signals { - do { - try await process.kill(sig) - } catch { - log.error( - "failed to send signal", - metadata: [ - "signal": "\(sig)", - "error": "\(error)", - ] - ) - } - } - return nil - } - } - - while true { - let result = try await group.next() - if result == nil { - return -1 - } - let status = result! - if let status { - group.cancelAll() - return status - } - } - return -1 - } - } - - public func validate() throws { - // Not really a "validation", but a cheat to run this before - // any of the commands do their business. - let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"] - if self.global.debug || debugEnvVar != nil { - log.logLevel = .debug - } - // Ensure we're not running under Rosetta. - if try isTranslated() { - throw ValidationError( - """ - `container` is currently running under Rosetta Translation, which could be - caused by your terminal application. Please ensure this is turned off. - """ - ) - } - } - - private static func otherCommands() -> [any ParsableCommand.Type] { - guard #available(macOS 26, *) else { - return [ - BuilderCommand.self, - SystemCommand.self, - ] - } - - return [ - BuilderCommand.self, - NetworkCommand.self, - SystemCommand.self, - ] - } - - private static func restoreCursorAtExit() { - let signalHandler: @convention(c) (Int32) -> Void = { signal in - let exitCode = ExitCode(signal + 128) - Application.exit(withError: exitCode) - } - // Termination by Ctrl+C. - signal(SIGINT, signalHandler) - // Termination using `kill`. - signal(SIGTERM, signalHandler) - // Normal and explicit exit. - atexit { - if let progressConfig = try? ProgressConfig() { - let progressBar = ProgressBar(config: progressConfig) - progressBar.resetCursor() - } - } - } -} - -extension Application { - // Because we support plugins, we need to modify the help text to display - // any if we found some. - static func printModifiedHelpText() { - let altered = Self.pluginLoader.alterCLIHelpText( - original: Application.helpMessage(for: Application.self) - ) - print(altered) - } - - enum ListFormat: String, CaseIterable, ExpressibleByArgument { - case json - case table - } - - static let signalSet: [Int32] = [ - SIGTERM, - SIGINT, - SIGUSR1, - SIGUSR2, - SIGWINCH, - ] - - func isTranslated() throws -> Bool { - do { - return try Sysctl.byName("sysctl.proc_translated") == 1 - } catch let posixErr as POSIXError { - if posixErr.code == .ENOENT { - return false - } - throw posixErr - } - } - - private static func releaseVersion() -> String { - var versionDetails: [String: String] = ["build": "release"] - #if DEBUG - versionDetails["build"] = "debug" - #endif - let gitCommit = { - let sha = get_git_commit().map { String(cString: $0) } - guard let sha else { - return "unspecified" - } - return String(sha.prefix(7)) - }() - versionDetails["commit"] = gitCommit - let extras: String = versionDetails.map { "\($0): \($1)" }.sorted().joined(separator: ", ") - - let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) - let releaseVersion = bundleVersion ?? get_release_version().map { String(cString: $0) } ?? "0.0.0" - - return "container CLI version \(releaseVersion) (\(extras))" - } -} diff --git a/Sources/ContainerCLI/BuildCommand.swift b/Sources/ContainerCLI/BuildCommand.swift deleted file mode 100644 index a1e84c258..000000000 --- a/Sources/ContainerCLI/BuildCommand.swift +++ /dev/null @@ -1,313 +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 ContainerBuild -import ContainerClient -import ContainerImagesServiceClient -import Containerization -import ContainerizationError -import ContainerizationOCI -import ContainerizationOS -import Foundation -import NIO -import TerminalProgress - -extension Application { - struct BuildCommand: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "build" - config.abstract = "Build an image from a Dockerfile" - config._superCommandName = "container" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") - public var cpus: Int64 = 2 - - @Option( - name: [.customLong("memory"), .customShort("m")], - help: - "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" - ) - var memory: String = "2048MB" - - @Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val")) - var buildArg: [String] = [] - - @Argument(help: "Build directory") - var contextDir: String = "." - - @Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path")) - var file: String = "Dockerfile" - - @Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val")) - var label: [String] = [] - - @Flag(name: .long, help: "Do not use cache") - var noCache: Bool = false - - @Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build", valueName: "value")) - var output: [String] = { - ["type=oci"] - }() - - @Option(name: .long, help: ArgumentHelp("Cache imports for the build", valueName: "value", visibility: .hidden)) - var cacheIn: [String] = { - [] - }() - - @Option(name: .long, help: ArgumentHelp("Cache exports for the build", valueName: "value", visibility: .hidden)) - var cacheOut: [String] = { - [] - }() - - @Option(name: .long, help: ArgumentHelp("set the build architecture", valueName: "value")) - var arch: [String] = { - ["arm64"] - }() - - @Option(name: .long, help: ArgumentHelp("set the build os", valueName: "value")) - var os: [String] = { - ["linux"] - }() - - @Option(name: .long, help: ArgumentHelp("Progress type - one of [auto|plain|tty]", valueName: "type")) - var progress: String = "auto" - - @Option(name: .long, help: ArgumentHelp("Builder-shim vsock port", valueName: "port")) - var vsockPort: UInt32 = 8088 - - @Option(name: [.customShort("t"), .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) - var targetImageName: String = UUID().uuidString.lowercased() - - @Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage")) - var target: String = "" - - @Flag(name: .shortAndLong, help: "Suppress build output") - var quiet: Bool = false - - func run() async throws { - do { - let timeout: Duration = .seconds(300) - let progressConfig = try ProgressConfig( - showTasks: true, - showItems: true - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - progress.set(description: "Dialing builder") - - let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { group in - defer { - group.cancelAll() - } - - group.addTask { - while true { - do { - let container = try await ClientContainer.get(id: "buildkit") - let fh = try await container.dial(self.vsockPort) - - let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let b = try Builder(socket: fh, group: threadGroup) - - // If this call succeeds, then BuildKit is running. - let _ = try await b.info() - return b - } catch { - // If we get here, "Dialing builder" is shown for such a short period - // of time that it's invisible to the user. - progress.set(tasks: 0) - progress.set(totalTasks: 3) - - try await BuilderStart.start( - cpus: self.cpus, - memory: self.memory, - progressUpdate: progress.handler - ) - - // wait (seconds) for builder to start listening on vsock - try await Task.sleep(for: .seconds(5)) - continue - } - } - } - - group.addTask { - try await Task.sleep(for: timeout) - throw ValidationError( - """ - Timeout waiting for connection to builder - """ - ) - } - - return try await group.next() - } - - guard let builder else { - throw ValidationError("builder is not running") - } - - let dockerfile = try Data(contentsOf: URL(filePath: file)) - let exportPath = Application.appRoot.appendingPathComponent(".build") - - let buildID = UUID().uuidString - let tempURL = exportPath.appendingPathComponent(buildID) - try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil) - defer { - try? FileManager.default.removeItem(at: tempURL) - } - - let imageName: String = try { - let parsedReference = try Reference.parse(targetImageName) - parsedReference.normalize() - return parsedReference.description - }() - - var terminal: Terminal? - switch self.progress { - case "tty": - terminal = try Terminal(descriptor: STDERR_FILENO) - case "auto": - terminal = try? Terminal(descriptor: STDERR_FILENO) - case "plain": - terminal = nil - default: - throw ContainerizationError(.invalidArgument, message: "invalid progress mode \(self.progress)") - } - - defer { terminal?.tryReset() } - - let exports: [Builder.BuildExport] = try output.map { output in - var exp = try Builder.BuildExport(from: output) - if exp.destination == nil { - exp.destination = tempURL.appendingPathComponent("out.tar") - } - return exp - } - - try await withThrowingTaskGroup(of: Void.self) { [terminal] group in - defer { - group.cancelAll() - } - group.addTask { - let handler = AsyncSignalHandler.create(notify: [SIGTERM, SIGINT, SIGUSR1, SIGUSR2]) - for await sig in handler.signals { - throw ContainerizationError(.interrupted, message: "exiting on signal \(sig)") - } - } - let platforms: [Platform] = try { - var results: [Platform] = [] - for o in self.os { - for a in self.arch { - guard let platform = try? Platform(from: "\(o)/\(a)") else { - throw ValidationError("invalid os/architecture combination \(o)/\(a)") - } - results.append(platform) - } - } - return results - }() - group.addTask { [terminal] in - let config = ContainerBuild.Builder.BuildConfig( - buildID: buildID, - contentStore: RemoteContentStoreClient(), - buildArgs: buildArg, - contextDir: contextDir, - dockerfile: dockerfile, - labels: label, - noCache: noCache, - platforms: platforms, - terminal: terminal, - tag: imageName, - target: target, - quiet: quiet, - exports: exports, - cacheIn: cacheIn, - cacheOut: cacheOut - ) - progress.finish() - - try await builder.build(config) - } - - try await group.next() - } - - let unpackProgressConfig = try ProgressConfig( - description: "Unpacking built image", - itemsName: "entries", - showTasks: exports.count > 1, - totalTasks: exports.count - ) - let unpackProgress = ProgressBar(config: unpackProgressConfig) - defer { - unpackProgress.finish() - } - unpackProgress.start() - - let taskManager = ProgressTaskCoordinator() - // Currently, only a single export can be specified. - for exp in exports { - unpackProgress.add(tasks: 1) - let unpackTask = await taskManager.startTask() - switch exp.type { - case "oci": - try Task.checkCancellation() - guard let dest = exp.destination else { - throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)") - } - let loaded = try await ClientImage.load(from: dest.absolutePath()) - - for image in loaded { - try Task.checkCancellation() - try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler)) - } - case "tar": - break - default: - throw ContainerizationError(.invalidArgument, message: "invalid exporter \(exp.rawValue)") - } - } - await taskManager.finish() - unpackProgress.finish() - print("Successfully built \(imageName)") - } catch { - throw NSError(domain: "Build", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(error)"]) - } - } - - func validate() throws { - guard FileManager.default.fileExists(atPath: file) else { - throw ValidationError("Dockerfile does not exist at path: \(file)") - } - guard FileManager.default.fileExists(atPath: contextDir) else { - throw ValidationError("context dir does not exist \(contextDir)") - } - guard let _ = try? Reference.parse(targetImageName) else { - throw ValidationError("invalid reference \(targetImageName)") - } - } - } -} diff --git a/Sources/ContainerCLI/Builder/Builder.swift b/Sources/ContainerCLI/Builder/Builder.swift deleted file mode 100644 index ad9eb6c97..000000000 --- a/Sources/ContainerCLI/Builder/Builder.swift +++ /dev/null @@ -1,31 +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 - -extension Application { - struct BuilderCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "builder", - abstract: "Manage an image builder instance", - subcommands: [ - BuilderStart.self, - BuilderStatus.self, - BuilderStop.self, - BuilderDelete.self, - ]) - } -} diff --git a/Sources/ContainerCLI/Builder/BuilderDelete.swift b/Sources/ContainerCLI/Builder/BuilderDelete.swift deleted file mode 100644 index e848da95e..000000000 --- a/Sources/ContainerCLI/Builder/BuilderDelete.swift +++ /dev/null @@ -1,57 +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 ContainerizationError -import Foundation - -extension Application { - struct BuilderDelete: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "delete" - config._superCommandName = "builder" - config.abstract = "Delete builder" - config.usage = "\n\t builder delete [command options]" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - @Flag(name: .shortAndLong, help: "Force delete builder even if it is running") - var force = false - - func run() async throws { - do { - let container = try await ClientContainer.get(id: "buildkit") - if container.status != .stopped { - guard force else { - throw ContainerizationError(.invalidState, message: "BuildKit container is not stopped, use --force to override") - } - try await container.stop() - } - try await container.delete() - } catch { - if error is ContainerizationError { - if (error as? ContainerizationError)?.code == .notFound { - return - } - } - throw error - } - } - } -} diff --git a/Sources/ContainerCLI/Builder/BuilderStart.swift b/Sources/ContainerCLI/Builder/BuilderStart.swift deleted file mode 100644 index 5800b712e..000000000 --- a/Sources/ContainerCLI/Builder/BuilderStart.swift +++ /dev/null @@ -1,262 +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 ContainerBuild -import ContainerClient -import ContainerNetworkService -import Containerization -import ContainerizationError -import ContainerizationExtras -import ContainerizationOCI -import Foundation -import TerminalProgress - -extension Application { - struct BuilderStart: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "start" - config._superCommandName = "builder" - config.abstract = "Start builder" - config.usage = "\nbuilder start [command options]" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") - public var cpus: Int64 = 2 - - @Option( - name: [.customLong("memory"), .customShort("m")], - help: - "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" - ) - public var memory: String = "2048MB" - - func run() async throws { - let progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - totalTasks: 4 - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - try await Self.start(cpus: self.cpus, memory: self.memory, progressUpdate: progress.handler) - progress.finish() - } - - static func start(cpus: Int64?, memory: String?, progressUpdate: @escaping ProgressUpdateHandler) async throws { - await progressUpdate([ - .setDescription("Fetching BuildKit image"), - .setItemsName("blobs"), - ]) - let taskManager = ProgressTaskCoordinator() - let fetchTask = await taskManager.startTask() - - let builderImage: String = ClientDefaults.get(key: .defaultBuilderImage) - let exportsMount: String = Application.appRoot.appendingPathComponent(".build").absolutePath() - - if !FileManager.default.fileExists(atPath: exportsMount) { - try FileManager.default.createDirectory( - atPath: exportsMount, - withIntermediateDirectories: true, - attributes: nil - ) - } - - let builderPlatform = ContainerizationOCI.Platform(arch: "arm64", os: "linux", variant: "v8") - - let existingContainer = try? await ClientContainer.get(id: "buildkit") - if let existingContainer { - let existingImage = existingContainer.configuration.image.reference - let existingResources = existingContainer.configuration.resources - - // Check if we need to recreate the builder due to different image - let imageChanged = existingImage != builderImage - let cpuChanged = { - if let cpus { - if existingResources.cpus != cpus { - return true - } - } - return false - }() - let memChanged = try { - if let memory { - let memoryInBytes = try Parser.resources(cpus: nil, memory: memory).memoryInBytes - if existingResources.memoryInBytes != memoryInBytes { - return true - } - } - return false - }() - - switch existingContainer.status { - case .running: - guard imageChanged || cpuChanged || memChanged else { - // If image, mem and cpu are the same, continue using the existing builder - return - } - // If they changed, stop and delete the existing builder - try await existingContainer.stop() - try await existingContainer.delete() - case .stopped: - // If the builder is stopped and matches our requirements, start it - // Otherwise, delete it and create a new one - guard imageChanged || cpuChanged || memChanged else { - try await existingContainer.startBuildKit(progressUpdate, nil) - return - } - try await existingContainer.delete() - case .stopping: - throw ContainerizationError( - .invalidState, - message: "builder is stopping, please wait until it is fully stopped before proceeding" - ) - case .unknown: - break - } - } - - let shimArguments: [String] = [ - "--debug", - "--vsock", - ] - - let id = "buildkit" - try ContainerClient.Utility.validEntityName(id) - - let processConfig = ProcessConfiguration( - executable: "/usr/local/bin/container-builder-shim", - arguments: shimArguments, - environment: [], - workingDirectory: "/", - terminal: false, - user: .id(uid: 0, gid: 0) - ) - - let resources = try Parser.resources( - cpus: cpus, - memory: memory - ) - - let image = try await ClientImage.fetch( - reference: builderImage, - platform: builderPlatform, - progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate) - ) - // Unpack fetched image before use - await progressUpdate([ - .setDescription("Unpacking BuildKit image"), - .setItemsName("entries"), - ]) - - let unpackTask = await taskManager.startTask() - _ = try await image.getCreateSnapshot( - platform: builderPlatform, - progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate) - ) - let imageConfig = ImageDescription( - reference: builderImage, - descriptor: image.descriptor - ) - - var config = ContainerConfiguration(id: id, image: imageConfig, process: processConfig) - config.resources = resources - config.mounts = [ - .init( - type: .tmpfs, - source: "", - destination: "/run", - options: [] - ), - .init( - type: .virtiofs, - source: exportsMount, - destination: "/var/lib/container-builder-shim/exports", - options: [] - ), - ] - // Enable Rosetta only if the user didn't ask to disable it - config.rosetta = ClientDefaults.getBool(key: .buildRosetta) ?? true - - let network = try await ClientNetwork.get(id: ClientNetwork.defaultNetworkName) - guard case .running(_, let networkStatus) = network else { - throw ContainerizationError(.invalidState, message: "default network is not running") - } - config.networks = [network.id] - let subnet = try CIDRAddress(networkStatus.address) - let nameserver = IPv4Address(fromValue: subnet.lower.value + 1).description - let nameservers = [nameserver] - config.dns = ContainerConfiguration.DNSConfiguration(nameservers: nameservers) - - let kernel = try await { - await progressUpdate([ - .setDescription("Fetching kernel"), - .setItemsName("binary"), - ]) - - let kernel = try await ClientKernel.getDefaultKernel(for: .current) - return kernel - }() - - await progressUpdate([ - .setDescription("Starting BuildKit container") - ]) - - let container = try await ClientContainer.create( - configuration: config, - options: .default, - kernel: kernel - ) - - try await container.startBuildKit(progressUpdate, taskManager) - } - } -} - -// MARK: - ClientContainer Extension for BuildKit - -extension ClientContainer { - /// Starts the BuildKit process within the container - /// This method handles bootstrapping the container and starting the BuildKit process - fileprivate func startBuildKit(_ progress: @escaping ProgressUpdateHandler, _ taskManager: ProgressTaskCoordinator? = nil) async throws { - do { - let io = try ProcessIO.create( - tty: false, - interactive: false, - detach: true - ) - defer { try? io.close() } - let process = try await bootstrap() - _ = try await process.start(io.stdio) - await taskManager?.finish() - try io.closeAfterStart() - log.debug("starting BuildKit and BuildKit-shim") - } catch { - try? await stop() - try? await delete() - if error is ContainerizationError { - throw error - } - throw ContainerizationError(.internalError, message: "failed to start BuildKit: \(error)") - } - } -} diff --git a/Sources/ContainerCLI/Builder/BuilderStatus.swift b/Sources/ContainerCLI/Builder/BuilderStatus.swift deleted file mode 100644 index b1210a3dd..000000000 --- a/Sources/ContainerCLI/Builder/BuilderStatus.swift +++ /dev/null @@ -1,71 +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 ContainerizationError -import Foundation - -extension Application { - struct BuilderStatus: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "status" - config._superCommandName = "builder" - config.abstract = "Print builder status" - config.usage = "\n\t builder status [command options]" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - @Flag(name: .long, help: ArgumentHelp("Display detailed status in json format")) - var json: Bool = false - - func run() async throws { - do { - let container = try await ClientContainer.get(id: "buildkit") - if json { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let jsonData = try encoder.encode(container) - - guard let jsonString = String(data: jsonData, encoding: .utf8) else { - throw ContainerizationError(.internalError, message: "failed to encode BuildKit container as json") - } - print(jsonString) - return - } - - let image = container.configuration.image.reference - let resources = container.configuration.resources - let cpus = resources.cpus - let memory = resources.memoryInBytes / (1024 * 1024) // bytes to MB - let addr = "" - - print("ID IMAGE STATE ADDR CPUS MEMORY") - print("\(container.id) \(image) \(container.status.rawValue.uppercased()) \(addr) \(cpus) \(memory) MB") - } catch { - if error is ContainerizationError { - if (error as? ContainerizationError)?.code == .notFound { - print("builder is not running") - return - } - } - throw error - } - } - } -} diff --git a/Sources/ContainerCLI/Builder/BuilderStop.swift b/Sources/ContainerCLI/Builder/BuilderStop.swift deleted file mode 100644 index e7484c9c1..000000000 --- a/Sources/ContainerCLI/Builder/BuilderStop.swift +++ /dev/null @@ -1,49 +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 ContainerizationError -import Foundation - -extension Application { - struct BuilderStop: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "stop" - config._superCommandName = "builder" - config.abstract = "Stop builder" - config.usage = "\n\t builder stop" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - func run() async throws { - do { - let container = try await ClientContainer.get(id: "buildkit") - try await container.stop() - } catch { - if error is ContainerizationError { - if (error as? ContainerizationError)?.code == .notFound { - print("builder is not running") - return - } - } - throw error - } - } - } -} diff --git a/Sources/ContainerCLI/Codable+JSON.swift b/Sources/ContainerCLI/Codable+JSON.swift deleted file mode 100644 index 60cbd04d7..000000000 --- a/Sources/ContainerCLI/Codable+JSON.swift +++ /dev/null @@ -1,25 +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 Foundation - -extension [any Codable] { - func jsonArray() throws -> String { - "[\(try self.map { String(data: try JSONEncoder().encode($0), encoding: .utf8)! }.joined(separator: ","))]" - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Build.swift b/Sources/ContainerCLI/Compose/Codable Structs/Build.swift deleted file mode 100644 index 5dc9a7ffa..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Build.swift +++ /dev/null @@ -1,52 +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. -//===----------------------------------------------------------------------===// - -// -// Build.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents the `build` configuration for a service. -struct Build: Codable, Hashable { - /// Path to the build context - let context: String - /// Optional path to the Dockerfile within the context - let dockerfile: String? - /// Build arguments - let args: [String: String]? - - /// Custom initializer to handle `build: .` (string) or `build: { context: . }` (object) - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let contextString = try? container.decode(String.self) { - self.context = contextString - self.dockerfile = nil - self.args = nil - } else { - let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) - self.context = try keyedContainer.decode(String.self, forKey: .context) - self.dockerfile = try keyedContainer.decodeIfPresent(String.self, forKey: .dockerfile) - self.args = try keyedContainer.decodeIfPresent([String: String].self, forKey: .args) - } - } - - enum CodingKeys: String, CodingKey { - case context, dockerfile, args - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Config.swift b/Sources/ContainerCLI/Compose/Codable Structs/Config.swift deleted file mode 100644 index 6b982bfdb..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Config.swift +++ /dev/null @@ -1,55 +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. -//===----------------------------------------------------------------------===// - -// -// Config.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a top-level config definition (primarily for Swarm). -struct Config: Codable { - /// Path to the file containing the config content - let file: String? - /// Indicates if the config is external (pre-existing) - let external: ExternalConfig? - /// Explicit name for the config - let name: String? - /// Labels for the config - let labels: [String: String]? - - enum CodingKeys: String, CodingKey { - case file, external, name, labels - } - - /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_cfg" }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - file = try container.decodeIfPresent(String.self, forKey: .file) - name = try container.decodeIfPresent(String.self, forKey: .name) - labels = try container.decodeIfPresent([String: String].self, forKey: .labels) - - if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { - external = ExternalConfig(isExternal: externalBool, name: nil) - } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { - external = ExternalConfig(isExternal: true, name: externalDict["name"]) - } else { - external = nil - } - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift b/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift deleted file mode 100644 index d30f9ffa8..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift +++ /dev/null @@ -1,35 +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. -//===----------------------------------------------------------------------===// - -// -// Deploy.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents the `deploy` configuration for a service (primarily for Swarm orchestration). -struct Deploy: Codable, Hashable { - /// Deployment mode (e.g., 'replicated', 'global') - let mode: String? - /// Number of replicated service tasks - let replicas: Int? - /// Resource constraints (limits, reservations) - let resources: DeployResources? - /// Restart policy for tasks - let restart_policy: DeployRestartPolicy? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift deleted file mode 100644 index 370e61a46..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// DeployResources.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Resource constraints for deployment. -struct DeployResources: Codable, Hashable { - /// Hard limits on resources - let limits: ResourceLimits? - /// Guarantees for resources - let reservations: ResourceReservations? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift deleted file mode 100644 index 56daa6573..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift +++ /dev/null @@ -1,35 +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. -//===----------------------------------------------------------------------===// - -// -// DeployRestartPolicy.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Restart policy for deployed tasks. -struct DeployRestartPolicy: Codable, Hashable { - /// Condition to restart on (e.g., 'on-failure', 'any') - let condition: String? - /// Delay before attempting restart - let delay: String? - /// Maximum number of restart attempts - let max_attempts: Int? - /// Window to evaluate restart policy - let window: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift deleted file mode 100644 index 47a58acad..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift +++ /dev/null @@ -1,35 +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. -//===----------------------------------------------------------------------===// - -// -// DeviceReservation.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Device reservations for GPUs or other devices. -struct DeviceReservation: Codable, Hashable { - /// Device capabilities - let capabilities: [String]? - /// Device driver - let driver: String? - /// Number of devices - let count: String? - /// Specific device IDs - let device_ids: [String]? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift b/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift deleted file mode 100644 index 503d98664..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift +++ /dev/null @@ -1,60 +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. -//===----------------------------------------------------------------------===// - -// -// DockerCompose.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents the top-level structure of a docker-compose.yml file. -struct DockerCompose: Codable { - /// The Compose file format version (e.g., '3.8') - let version: String? - /// Optional project name - let name: String? - /// Dictionary of service definitions, keyed by service name - let services: [String: Service] - /// Optional top-level volume definitions - let volumes: [String: Volume]? - /// Optional top-level network definitions - let networks: [String: Network]? - /// Optional top-level config definitions (primarily for Swarm) - let configs: [String: Config]? - /// Optional top-level secret definitions (primarily for Swarm) - let secrets: [String: Secret]? - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - version = try container.decodeIfPresent(String.self, forKey: .version) - name = try container.decodeIfPresent(String.self, forKey: .name) - services = try container.decode([String: Service].self, forKey: .services) - - if let volumes = try container.decodeIfPresent([String: Optional].self, forKey: .volumes) { - let safeVolumes: [String : Volume] = volumes.mapValues { value in - value ?? Volume() - } - self.volumes = safeVolumes - } else { - self.volumes = nil - } - networks = try container.decodeIfPresent([String: Network].self, forKey: .networks) - configs = try container.decodeIfPresent([String: Config].self, forKey: .configs) - secrets = try container.decodeIfPresent([String: Secret].self, forKey: .secrets) - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift deleted file mode 100644 index d05ccd461..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ExternalConfig.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents an external config reference. -struct ExternalConfig: Codable { - /// True if the config is external - let isExternal: Bool - /// Optional name of the external config if different from key - let name: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift deleted file mode 100644 index 07d6c8ce9..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ExternalNetwork.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents an external network reference. -struct ExternalNetwork: Codable { - /// True if the network is external - let isExternal: Bool - // Optional name of the external network if different from key - let name: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift deleted file mode 100644 index ce4411362..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ExternalSecret.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents an external secret reference. -struct ExternalSecret: Codable { - /// True if the secret is external - let isExternal: Bool - /// Optional name of the external secret if different from key - let name: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift deleted file mode 100644 index 04cfe4f92..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ExternalVolume.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents an external volume reference. -struct ExternalVolume: Codable { - /// True if the volume is external - let isExternal: Bool - /// Optional name of the external volume if different from key - let name: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift b/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift deleted file mode 100644 index 27f5aa912..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift +++ /dev/null @@ -1,37 +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. -//===----------------------------------------------------------------------===// - -// -// Healthcheck.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Healthcheck configuration for a service. -struct Healthcheck: Codable, Hashable { - /// Command to run to check health - let test: [String]? - /// Grace period for the container to start - let start_period: String? - /// How often to run the check - let interval: String? - /// Number of consecutive failures to consider unhealthy - let retries: Int? - /// Timeout for each check - let timeout: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Network.swift b/Sources/ContainerCLI/Compose/Codable Structs/Network.swift deleted file mode 100644 index 44752aecc..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Network.swift +++ /dev/null @@ -1,68 +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. -//===----------------------------------------------------------------------===// - -// -// Network.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a top-level network definition. -struct Network: Codable { - /// Network driver (e.g., 'bridge', 'overlay') - let driver: String? - /// Driver-specific options - let driver_opts: [String: String]? - /// Allow standalone containers to attach to this network - let attachable: Bool? - /// Enable IPv6 networking - let enable_ipv6: Bool? - /// RENAMED: from `internal` to `isInternal` to avoid keyword clash - let isInternal: Bool? - /// Labels for the network - let labels: [String: String]? - /// Explicit name for the network - let name: String? - /// Indicates if the network is external (pre-existing) - let external: ExternalNetwork? - - /// Updated CodingKeys to map 'internal' from YAML to 'isInternal' Swift property - enum CodingKeys: String, CodingKey { - case driver, driver_opts, attachable, enable_ipv6, isInternal = "internal", labels, name, external - } - - /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_net" }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - driver = try container.decodeIfPresent(String.self, forKey: .driver) - driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts) - attachable = try container.decodeIfPresent(Bool.self, forKey: .attachable) - enable_ipv6 = try container.decodeIfPresent(Bool.self, forKey: .enable_ipv6) - isInternal = try container.decodeIfPresent(Bool.self, forKey: .isInternal) // Use isInternal here - labels = try container.decodeIfPresent([String: String].self, forKey: .labels) - name = try container.decodeIfPresent(String.self, forKey: .name) - - if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { - external = ExternalNetwork(isExternal: externalBool, name: nil) - } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { - external = ExternalNetwork(isExternal: true, name: externalDict["name"]) - } else { - external = nil - } - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift b/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift deleted file mode 100644 index 4643d961b..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ResourceLimits.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// CPU and memory limits. -struct ResourceLimits: Codable, Hashable { - /// CPU limit (e.g., "0.5") - let cpus: String? - /// Memory limit (e.g., "512M") - let memory: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift b/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift deleted file mode 100644 index 26052e6b3..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift +++ /dev/null @@ -1,34 +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. -//===----------------------------------------------------------------------===// - -// -// ResourceReservations.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// **FIXED**: Renamed from `ResourceReservables` to `ResourceReservations` and made `Codable`. -/// CPU and memory reservations. -struct ResourceReservations: Codable, Hashable { - /// CPU reservation (e.g., "0.25") - let cpus: String? - /// Memory reservation (e.g., "256M") - let memory: String? - /// Device reservations for GPUs or other devices - let devices: [DeviceReservation]? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift b/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift deleted file mode 100644 index ff464c671..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift +++ /dev/null @@ -1,58 +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. -//===----------------------------------------------------------------------===// - -// -// Secret.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a top-level secret definition (primarily for Swarm). -struct Secret: Codable { - /// Path to the file containing the secret content - let file: String? - /// Environment variable to populate with the secret content - let environment: String? - /// Indicates if the secret is external (pre-existing) - let external: ExternalSecret? - /// Explicit name for the secret - let name: String? - /// Labels for the secret - let labels: [String: String]? - - enum CodingKeys: String, CodingKey { - case file, environment, external, name, labels - } - - /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_sec" }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - file = try container.decodeIfPresent(String.self, forKey: .file) - environment = try container.decodeIfPresent(String.self, forKey: .environment) - name = try container.decodeIfPresent(String.self, forKey: .name) - labels = try container.decodeIfPresent([String: String].self, forKey: .labels) - - if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { - external = ExternalSecret(isExternal: externalBool, name: nil) - } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { - external = ExternalSecret(isExternal: true, name: externalDict["name"]) - } else { - external = nil - } - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Service.swift b/Sources/ContainerCLI/Compose/Codable Structs/Service.swift deleted file mode 100644 index 1c5aeb528..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Service.swift +++ /dev/null @@ -1,203 +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. -//===----------------------------------------------------------------------===// - -// -// Service.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - -import Foundation - - -/// Represents a single service definition within the `services` section. -struct Service: Codable, Hashable { - /// Docker image name - let image: String? - - /// Build configuration if the service is built from a Dockerfile - let build: Build? - - /// Deployment configuration (primarily for Swarm) - let deploy: Deploy? - - /// Restart policy (e.g., 'unless-stopped', 'always') - let restart: String? - - /// Healthcheck configuration - let healthcheck: Healthcheck? - - /// List of volume mounts (e.g., "hostPath:containerPath", "namedVolume:/path") - let volumes: [String]? - - /// Environment variables to set in the container - let environment: [String: String]? - - /// List of .env files to load environment variables from - let env_file: [String]? - - /// Port mappings (e.g., "hostPort:containerPort") - let ports: [String]? - - /// Command to execute in the container, overriding the image's default - let command: [String]? - - /// Services this service depends on (for startup order) - let depends_on: [String]? - - /// User or UID to run the container as - let user: String? - - /// Explicit name for the container instance - let container_name: String? - - /// List of networks the service will connect to - let networks: [String]? - - /// Container hostname - let hostname: String? - - /// Entrypoint to execute in the container, overriding the image's default - let entrypoint: [String]? - - /// Run container in privileged mode - let privileged: Bool? - - /// Mount container's root filesystem as read-only - let read_only: Bool? - - /// Working directory inside the container - let working_dir: String? - - /// Platform architecture for the service - let platform: String? - - /// Service-specific config usage (primarily for Swarm) - let configs: [ServiceConfig]? - - /// Service-specific secret usage (primarily for Swarm) - let secrets: [ServiceSecret]? - - /// Keep STDIN open (-i flag for `container run`) - let stdin_open: Bool? - - /// Allocate a pseudo-TTY (-t flag for `container run`) - let tty: Bool? - - /// Other services that depend on this service - var dependedBy: [String] = [] - - // Defines custom coding keys to map YAML keys to Swift properties - enum CodingKeys: String, CodingKey { - case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, - container_name, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform - } - - /// Custom initializer to handle decoding and basic validation. - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - image = try container.decodeIfPresent(String.self, forKey: .image) - build = try container.decodeIfPresent(Build.self, forKey: .build) - deploy = try container.decodeIfPresent(Deploy.self, forKey: .deploy) - - // Ensure that a service has either an image or a build context. - guard image != nil || build != nil else { - throw DecodingError.dataCorruptedError(forKey: .image, in: container, debugDescription: "Service must have either 'image' or 'build' specified.") - } - - restart = try container.decodeIfPresent(String.self, forKey: .restart) - healthcheck = try container.decodeIfPresent(Healthcheck.self, forKey: .healthcheck) - volumes = try container.decodeIfPresent([String].self, forKey: .volumes) - environment = try container.decodeIfPresent([String: String].self, forKey: .environment) - env_file = try container.decodeIfPresent([String].self, forKey: .env_file) - ports = try container.decodeIfPresent([String].self, forKey: .ports) - - // Decode 'command' which can be either a single string or an array of strings. - if let cmdArray = try? container.decodeIfPresent([String].self, forKey: .command) { - command = cmdArray - } else if let cmdString = try? container.decodeIfPresent(String.self, forKey: .command) { - command = [cmdString] - } else { - command = nil - } - - depends_on = try container.decodeIfPresent([String].self, forKey: .depends_on) - user = try container.decodeIfPresent(String.self, forKey: .user) - - container_name = try container.decodeIfPresent(String.self, forKey: .container_name) - networks = try container.decodeIfPresent([String].self, forKey: .networks) - hostname = try container.decodeIfPresent(String.self, forKey: .hostname) - - // Decode 'entrypoint' which can be either a single string or an array of strings. - if let entrypointArray = try? container.decodeIfPresent([String].self, forKey: .entrypoint) { - entrypoint = entrypointArray - } else if let entrypointString = try? container.decodeIfPresent(String.self, forKey: .entrypoint) { - entrypoint = [entrypointString] - } else { - entrypoint = nil - } - - privileged = try container.decodeIfPresent(Bool.self, forKey: .privileged) - read_only = try container.decodeIfPresent(Bool.self, forKey: .read_only) - working_dir = try container.decodeIfPresent(String.self, forKey: .working_dir) - configs = try container.decodeIfPresent([ServiceConfig].self, forKey: .configs) - secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets) - stdin_open = try container.decodeIfPresent(Bool.self, forKey: .stdin_open) - tty = try container.decodeIfPresent(Bool.self, forKey: .tty) - platform = try container.decodeIfPresent(String.self, forKey: .platform) - } - - /// Returns the services in topological order based on `depends_on` relationships. - static func topoSortConfiguredServices( - _ services: [(serviceName: String, service: Service)] - ) throws -> [(serviceName: String, service: Service)] { - - var visited = Set() - var visiting = Set() - var sorted: [(String, Service)] = [] - - func visit(_ name: String, from service: String? = nil) throws { - guard var serviceTuple = services.first(where: { $0.serviceName == name }) else { return } - if let service { - serviceTuple.service.dependedBy.append(service) - } - - if visiting.contains(name) { - throw NSError(domain: "ComposeError", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" - ]) - } - guard !visited.contains(name) else { return } - - visiting.insert(name) - for depName in serviceTuple.service.depends_on ?? [] { - try visit(depName, from: name) - } - visiting.remove(name) - visited.insert(name) - sorted.append(serviceTuple) - } - - for (serviceName, _) in services { - if !visited.contains(serviceName) { - try visit(serviceName) - } - } - - return sorted - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift b/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift deleted file mode 100644 index 712d42b7b..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift +++ /dev/null @@ -1,64 +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. -//===----------------------------------------------------------------------===// - -// -// ServiceConfig.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a service's usage of a config. -struct ServiceConfig: Codable, Hashable { - /// Name of the config being used - let source: String - - /// Path in the container where the config will be mounted - let target: String? - - /// User ID for the mounted config file - let uid: String? - - /// Group ID for the mounted config file - let gid: String? - - /// Permissions mode for the mounted config file - let mode: Int? - - /// Custom initializer to handle `config_name` (string) or `{ source: config_name, target: /path }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let sourceName = try? container.decode(String.self) { - self.source = sourceName - self.target = nil - self.uid = nil - self.gid = nil - self.mode = nil - } else { - let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) - self.source = try keyedContainer.decode(String.self, forKey: .source) - self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) - self.uid = try keyedContainer.decodeIfPresent(String.self, forKey: .uid) - self.gid = try keyedContainer.decodeIfPresent(String.self, forKey: .gid) - self.mode = try keyedContainer.decodeIfPresent(Int.self, forKey: .mode) - } - } - - enum CodingKeys: String, CodingKey { - case source, target, uid, gid, mode - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift b/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift deleted file mode 100644 index 1849c495c..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift +++ /dev/null @@ -1,64 +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. -//===----------------------------------------------------------------------===// - -// -// ServiceSecret.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a service's usage of a secret. -struct ServiceSecret: Codable, Hashable { - /// Name of the secret being used - let source: String - - /// Path in the container where the secret will be mounted - let target: String? - - /// User ID for the mounted secret file - let uid: String? - - /// Group ID for the mounted secret file - let gid: String? - - /// Permissions mode for the mounted secret file - let mode: Int? - - /// Custom initializer to handle `secret_name` (string) or `{ source: secret_name, target: /path }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let sourceName = try? container.decode(String.self) { - self.source = sourceName - self.target = nil - self.uid = nil - self.gid = nil - self.mode = nil - } else { - let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) - self.source = try keyedContainer.decode(String.self, forKey: .source) - self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) - self.uid = try keyedContainer.decodeIfPresent(String.self, forKey: .uid) - self.gid = try keyedContainer.decodeIfPresent(String.self, forKey: .gid) - self.mode = try keyedContainer.decodeIfPresent(Int.self, forKey: .mode) - } - } - - enum CodingKeys: String, CodingKey { - case source, target, uid, gid, mode - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift b/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift deleted file mode 100644 index b43a1cca5..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift +++ /dev/null @@ -1,70 +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. -//===----------------------------------------------------------------------===// - -// -// Volume.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a top-level volume definition. -struct Volume: Codable { - /// Volume driver (e.g., 'local') - let driver: String? - - /// Driver-specific options - let driver_opts: [String: String]? - - /// Explicit name for the volume - let name: String? - - /// Labels for the volume - let labels: [String: String]? - - /// Indicates if the volume is external (pre-existing) - let external: ExternalVolume? - - enum CodingKeys: String, CodingKey { - case driver, driver_opts, name, labels, external - } - - /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_vol" }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - driver = try container.decodeIfPresent(String.self, forKey: .driver) - driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts) - name = try container.decodeIfPresent(String.self, forKey: .name) - labels = try container.decodeIfPresent([String: String].self, forKey: .labels) - - if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { - external = ExternalVolume(isExternal: externalBool, name: nil) - } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { - external = ExternalVolume(isExternal: true, name: externalDict["name"]) - } else { - external = nil - } - } - - init(driver: String? = nil, driver_opts: [String : String]? = nil, name: String? = nil, labels: [String : String]? = nil, external: ExternalVolume? = nil) { - self.driver = driver - self.driver_opts = driver_opts - self.name = name - self.labels = labels - self.external = external - } -} diff --git a/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift b/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift deleted file mode 100644 index 8993f8ddb..000000000 --- a/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift +++ /dev/null @@ -1,105 +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. -//===----------------------------------------------------------------------===// - -// -// ComposeDown.swift -// Container-Compose -// -// Created by Morris Richman on 6/19/25. -// - -import ArgumentParser -import ContainerClient -import Foundation -import Yams - -extension Application { - public struct ComposeDown: AsyncParsableCommand { - public init() {} - - public static let configuration: CommandConfiguration = .init( - commandName: "down", - abstract: "Stop containers with compose" - ) - - @Argument(help: "Specify the services to stop") - var services: [String] = [] - - @OptionGroup - var process: Flags.Process - - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - - var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml - - private var fileManager: FileManager { FileManager.default } - private var projectName: String? - - public mutating func run() async throws { - // Read docker-compose.yml content - guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { - throw YamlError.dockerfileNotFound(dockerComposePath) - } - - // Decode the YAML file into the DockerCompose struct - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - - // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") - } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") - } - - var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) - services = try Service.topoSortConfiguredServices(services) - - // Filter for specified services - if !self.services.isEmpty { - services = services.filter({ serviceName, service in - self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) - }) - } - - try await stopOldStuff(services.map({ $0.serviceName }), remove: false) - } - - private func stopOldStuff(_ services: [String], remove: Bool) async throws { - guard let projectName else { return } - let containers = services.map { "\(projectName)-\($0)" } - - for container in containers { - print("Stopping container: \(container)") - guard let container = try? await ClientContainer.get(id: container) else { continue } - - do { - try await container.stop() - } catch { - } - if remove { - do { - try await container.delete() - } catch { - } - } - } - } - } -} diff --git a/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift b/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift deleted file mode 100644 index 6b1053670..000000000 --- a/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift +++ /dev/null @@ -1,749 +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. -//===----------------------------------------------------------------------===// - -// -// ComposeUp.swift -// Container-Compose -// -// Created by Morris Richman on 6/19/25. -// - -import ArgumentParser -import ContainerClient -import Foundation -@preconcurrency import Rainbow -import Yams -import ContainerizationExtras - -extension Application { - public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { - public init() {} - - public static let configuration: CommandConfiguration = .init( - commandName: "up", - abstract: "Start containers with compose" - ) - - @Argument(help: "Specify the services to start") - var services: [String] = [] - - @Flag( - name: [.customShort("d"), .customLong("detach")], - help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") - var detatch: Bool = false - - @Flag(name: [.customShort("b"), .customLong("build")]) - var rebuild: Bool = false - - @Flag(name: .long, help: "Do not use cache") - var noCache: Bool = false - - @OptionGroup - var process: Flags.Process - - @OptionGroup - var global: Flags.Global - - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml - var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file - // - private var fileManager: FileManager { FileManager.default } - private var projectName: String? - private var environmentVariables: [String: String] = [:] - private var containerIps: [String: String] = [:] - private var containerConsoleColors: [String: NamedColor] = [:] - - private static let availableContainerConsoleColors: Set = [ - .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, - ] - - public mutating func run() async throws { - // Read docker-compose.yml content - guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { - throw YamlError.dockerfileNotFound(dockerComposePath) - } - - // Decode the YAML file into the DockerCompose struct - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - - // Load environment variables from .env file - environmentVariables = loadEnvFile(path: envFilePath) - - // Handle 'version' field - if let version = dockerCompose.version { - print("Info: Docker Compose file version parsed as: \(version)") - print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") - } - - // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") - } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") - } - - // Get Services to use - var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) - services = try Service.topoSortConfiguredServices(services) - - // Filter for specified services - if !self.services.isEmpty { - services = services.filter({ serviceName, service in - self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) - }) - } - - // Stop Services - try await stopOldStuff(services.map({ $0.serviceName }), remove: true) - - // Process top-level networks - // This creates named networks defined in the docker-compose.yml - if let networks = dockerCompose.networks { - print("\n--- Processing Networks ---") - for (networkName, networkConfig) in networks { - try await setupNetwork(name: networkName, config: networkConfig) - } - print("--- Networks Processed ---\n") - } - - // Process top-level volumes - // This creates named volumes defined in the docker-compose.yml - if let volumes = dockerCompose.volumes { - print("\n--- Processing Volumes ---") - for (volumeName, volumeConfig) in volumes { - await createVolumeHardLink(name: volumeName, config: volumeConfig) - } - print("--- Volumes Processed ---\n") - } - - // Process each service defined in the docker-compose.yml - print("\n--- Processing Services ---") - - print(services.map(\.serviceName)) - for (serviceName, service) in services { - try await configService(service, serviceName: serviceName, from: dockerCompose) - } - - if !detatch { - await waitForever() - } - } - - func waitForever() async -> Never { - for await _ in AsyncStream(unfolding: {}) { - // This will never run - } - fatalError("unreachable") - } - - private func getIPForRunningService(_ serviceName: String) async throws -> String? { - guard let projectName else { return nil } - - let containerName = "\(projectName)-\(serviceName)" - - let container = try await ClientContainer.get(id: containerName) - let ip = container.networks.compactMap { try? CIDRAddress($0.address).address.description }.first - - return ip - } - - /// Repeatedly checks `container list -a` until the given container is listed as `running`. - /// - Parameters: - /// - containerName: The exact name of the container (e.g. "Assignment-Manager-API-db"). - /// - timeout: Max seconds to wait before failing. - /// - interval: How often to poll (in seconds). - /// - Returns: `true` if the container reached "running" state within the timeout. - private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { - guard let projectName else { return } - let containerName = "\(projectName)-\(serviceName)" - - let deadline = Date().addingTimeInterval(timeout) - - while Date() < deadline { - let container = try? await ClientContainer.get(id: containerName) - if container?.status == .running { - return - } - - try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - } - - throw NSError( - domain: "ContainerWait", code: 1, - userInfo: [ - NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." - ]) - } - - private func stopOldStuff(_ services: [String], remove: Bool) async throws { - guard let projectName else { return } - let containers = services.map { "\(projectName)-\($0)" } - - for container in containers { - print("Stopping container: \(container)") - guard let container = try? await ClientContainer.get(id: container) else { continue } - - do { - try await container.stop() - } catch { - } - if remove { - do { - try await container.delete() - } catch { - } - } - } - } - - // MARK: Compose Top Level Functions - - private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { - let ip = try await getIPForRunningService(serviceName) - self.containerIps[serviceName] = ip - for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { - self.environmentVariables[key] = ip ?? value - } - } - - private func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { - guard let projectName else { return } - let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name - - let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") - let volumePath = volumeUrl.path(percentEncoded: false) - - print( - "Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." - ) - try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) - } - - private func setupNetwork(name networkName: String, config networkConfig: Network) async throws { - let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name - - if let externalNetwork = networkConfig.external, externalNetwork.isExternal { - print("Info: Network '\(networkName)' is declared as external.") - print("This tool assumes external network '\(externalNetwork.name ?? actualNetworkName)' already exists and will not attempt to create it.") - } else { - var networkCreateArgs: [String] = ["network", "create"] - - #warning("Docker Compose Network Options Not Supported") - // Add driver and driver options - if let driver = networkConfig.driver, !driver.isEmpty { -// networkCreateArgs.append("--driver") -// networkCreateArgs.append(driver) - print("Network Driver Detected, But Not Supported") - } - if let driverOpts = networkConfig.driver_opts, !driverOpts.isEmpty { -// for (optKey, optValue) in driverOpts { -// networkCreateArgs.append("--opt") -// networkCreateArgs.append("\(optKey)=\(optValue)") -// } - print("Network Options Detected, But Not Supported") - } - // Add various network flags - if networkConfig.attachable == true { -// networkCreateArgs.append("--attachable") - print("Network Attachable Flag Detected, But Not Supported") - } - if networkConfig.enable_ipv6 == true { -// networkCreateArgs.append("--ipv6") - print("Network IPv6 Flag Detected, But Not Supported") - } - if networkConfig.isInternal == true { -// networkCreateArgs.append("--internal") - print("Network Internal Flag Detected, But Not Supported") - } // CORRECTED: Use isInternal - - // Add labels - if let labels = networkConfig.labels, !labels.isEmpty { - print("Network Labels Detected, But Not Supported") -// for (labelKey, labelValue) in labels { -// networkCreateArgs.append("--label") -// networkCreateArgs.append("\(labelKey)=\(labelValue)") -// } - } - - print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") - print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") - guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else { - print("Network '\(networkName)' already exists") - return - } - var networkCreate = NetworkCreate() - networkCreate.global = global - networkCreate.name = actualNetworkName - - try await networkCreate.run() - print("Network '\(networkName)' created") - } - } - - // MARK: Compose Service Level Functions - private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { - guard let projectName else { throw ComposeError.invalidProjectName } - - var imageToRun: String - - // Handle 'build' configuration - if let buildConfig = service.build { - imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) - } else if let img = service.image { - // Use specified image if no build config - // Pull image if necessary - try await pullImage(img, platform: service.container_name) - imageToRun = img - } else { - // Should not happen due to Service init validation, but as a fallback - throw ComposeError.imageNotFound(serviceName) - } - - // Handle 'deploy' configuration (note that this tool doesn't fully support it) - if service.deploy != nil { - print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") - print( - "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." - ) - print("The service will be run as a single container based on other configurations.") - } - - var runCommandArgs: [String] = [] - - // Add detach flag if specified on the CLI - if detatch { - runCommandArgs.append("-d") - } - - // Determine container name - let containerName: String - if let explicitContainerName = service.container_name { - containerName = explicitContainerName - print("Info: Using explicit container_name: \(containerName)") - } else { - // Default container name based on project and service name - containerName = "\(projectName)-\(serviceName)" - } - runCommandArgs.append("--name") - runCommandArgs.append(containerName) - - // REMOVED: Restart policy is not supported by `container run` - // if let restart = service.restart { - // runCommandArgs.append("--restart") - // runCommandArgs.append(restart) - // } - - // Add user - if let user = service.user { - runCommandArgs.append("--user") - runCommandArgs.append(user) - } - - // Add volume mounts - if let volumes = service.volumes { - for volume in volumes { - let args = try await configVolume(volume) - runCommandArgs.append(contentsOf: args) - } - } - - // Combine environment variables from .env files and service environment - var combinedEnv: [String: String] = environmentVariables - - if let envFiles = service.env_file { - for envFile in envFiles { - let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") - combinedEnv.merge(additionalEnvVars) { (current, _) in current } - } - } - - if let serviceEnv = service.environment { - combinedEnv.merge(serviceEnv) { (old, new) in - guard !new.contains("${") else { - return old - } - return new - } // Service env overrides .env files - } - - // Fill in variables - combinedEnv = combinedEnv.mapValues({ value in - guard value.contains("${") else { return value } - - let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) - return combinedEnv[variableName] ?? value - }) - - // Fill in IPs - combinedEnv = combinedEnv.mapValues({ value in - containerIps[value] ?? value - }) - - // MARK: Spinning Spot - // Add environment variables to run command - for (key, value) in combinedEnv { - runCommandArgs.append("-e") - runCommandArgs.append("\(key)=\(value)") - } - - // REMOVED: Port mappings (-p) are not supported by `container run` - // if let ports = service.ports { - // for port in ports { - // let resolvedPort = resolveVariable(port, with: envVarsFromFile) - // runCommandArgs.append("-p") - // runCommandArgs.append(resolvedPort) - // } - // } - - // Connect to specified networks - if let serviceNetworks = service.networks { - for network in serviceNetworks { - let resolvedNetwork = resolveVariable(network, with: environmentVariables) - // Use the explicit network name from top-level definition if available, otherwise resolved name - let networkToConnect = dockerCompose.networks?[network]?.name ?? resolvedNetwork - runCommandArgs.append("--network") - runCommandArgs.append(networkToConnect) - } - print( - "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml." - ) - print( - "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." - ) - } else { - print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.") - } - - // Add hostname - if let hostname = service.hostname { - let resolvedHostname = resolveVariable(hostname, with: environmentVariables) - runCommandArgs.append("--hostname") - runCommandArgs.append(resolvedHostname) - } - - // Add working directory - if let workingDir = service.working_dir { - let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) - runCommandArgs.append("--workdir") - runCommandArgs.append(resolvedWorkingDir) - } - - // Add privileged flag - if service.privileged == true { - runCommandArgs.append("--privileged") - } - - // Add read-only flag - if service.read_only == true { - runCommandArgs.append("--read-only") - } - - // Handle service-level configs (note: still only parsing/logging, not attaching) - if let serviceConfigs = service.configs { - print( - "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." - ) - print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.") - for serviceConfig in serviceConfigs { - print( - " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" - ) - } - } - // - // Handle service-level secrets (note: still only parsing/logging, not attaching) - if let serviceSecrets = service.secrets { - print( - "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." - ) - print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.") - for serviceSecret in serviceSecrets { - print( - " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" - ) - } - } - - // Add interactive and TTY flags - if service.stdin_open == true { - runCommandArgs.append("-i") // --interactive - } - if service.tty == true { - runCommandArgs.append("-t") // --tty - } - - runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint - - // Add entrypoint or command - if let entrypointParts = service.entrypoint { - runCommandArgs.append("--entrypoint") - runCommandArgs.append(contentsOf: entrypointParts) - } else if let commandParts = service.command { - runCommandArgs.append(contentsOf: commandParts) - } - - var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! - - if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { - while containerConsoleColors.values.contains(serviceColor) { - serviceColor = Self.availableContainerConsoleColors.randomElement()! - } - } - - self.containerConsoleColors[serviceName] = serviceColor - - Task { [self, serviceColor] in - @Sendable - func handleOutput(_ output: String) { - print("\(serviceName): \(output)".applyingColor(serviceColor)) - } - - print("\nStarting service: \(serviceName)") - print("Starting \(serviceName)") - print("----------------------------------------\n") - let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) - } - - do { - try await waitUntilServiceIsRunning(serviceName) - try await updateEnvironmentWithServiceIP(serviceName) - } catch { - print(error) - } - } - - private func pullImage(_ imageName: String, platform: String?) async throws { - let imageList = try await ClientImage.list() - guard !imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageName }) else { - return - } - - print("Pulling Image \(imageName)...") - var registry = Flags.Registry() - registry.scheme = "auto" // Set or SwiftArgumentParser gets mad - - var progress = Flags.Progress() - progress.disableProgressUpdates = false - - var imagePull = ImagePull() - imagePull.progressFlags = progress - imagePull.registry = registry - imagePull.global = global - imagePull.reference = imageName - imagePull.platform = platform - try await imagePull.run() - } - - /// Builds Docker Service - /// - /// - Parameters: - /// - buildConfig: The configuration for the build - /// - service: The service you would like to build - /// - serviceName: The fallback name for the image - /// - /// - Returns: Image Name (`String`) - private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { - // Determine image tag for built image - let imageToRun = service.image ?? "\(serviceName):latest" - let imageList = try await ClientImage.list() - if !rebuild, imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageToRun }) { - return imageToRun - } - - var buildCommand = BuildCommand() - - // Set Build Commands - buildCommand.buildArg = (buildConfig.args ?? [:]).map({ "\($0.key)=\(resolveVariable($0.value, with: environmentVariables))" }) - - // Locate Dockerfile and context - buildCommand.contextDir = "\(self.cwd)/\(buildConfig.context)" - buildCommand.file = "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")" - - // Handle Caching - buildCommand.noCache = noCache - buildCommand.cacheIn = [] - buildCommand.cacheOut = [] - - // Handle OS/Arch - let split = service.platform?.split(separator: "/") - buildCommand.os = [String(split?.first ?? "linux")] - buildCommand.arch = [String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64")] - - // Set Image Name - buildCommand.targetImageName = imageToRun - - // Set CPU & Memory - buildCommand.cpus = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 - buildCommand.memory = service.deploy?.resources?.limits?.memory ?? "2048MB" - - // Set Miscelaneous - buildCommand.label = [] // No Label Equivalent? - buildCommand.progress = "auto" - buildCommand.vsockPort = 8088 - buildCommand.quiet = false - buildCommand.target = "" - buildCommand.output = ["type=oci"] - print("\n----------------------------------------") - print("Building image for service: \(serviceName) (Tag: \(imageToRun))") - try buildCommand.validate() - try await buildCommand.run() - print("Image build for \(serviceName) completed.") - print("----------------------------------------") - - return imageToRun - } - - private func configVolume(_ volume: String) async throws -> [String] { - let resolvedVolume = resolveVariable(volume, with: environmentVariables) - - var runCommandArgs: [String] = [] - - // Parse the volume string: destination[:mode] - let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) - - guard components.count >= 2 else { - print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") - return [] - } - - let source = components[0] - let destination = components[1] - - // Check if the source looks like a host path (contains '/' or starts with '.') - // This heuristic helps distinguish bind mounts from named volume references. - if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { - // This is likely a bind mount (local path to container path) - var isDirectory: ObjCBool = false - // Ensure the path is absolute or relative to the current directory for FileManager - let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) - - if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { - if isDirectory.boolValue { - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument - } else { - // Host path exists but is a file - print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") - } - } else { - // Host path does not exist, assume it's meant to be a directory and try to create it. - do { - try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) - print("Info: Created missing host directory for volume: \(fullHostPath)") - runCommandArgs.append("-v") - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument - } catch { - print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") - } - } - } else { - guard let projectName else { return [] } - let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") - let volumePath = volumeUrl.path(percentEncoded: false) - - let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() - let destinationPath = destinationUrl.path(percentEncoded: false) - - print( - "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." - ) - try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) - - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument - } - - return runCommandArgs - } - } -} - -// MARK: CommandLine Functions -extension Application.ComposeUp { - - /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. - /// - /// - Parameters: - /// - command: The name of the command to run (e.g., `"container"`). - /// - args: Command-line arguments to pass to the command. - /// - onStdout: Closure called with streamed stdout data. - /// - onStderr: Closure called with streamed stderr data. - /// - Returns: The process's exit code. - /// - Throws: If the process fails to launch. - @discardableResult - func streamCommand( - _ command: String, - args: [String] = [], - onStdout: @escaping (@Sendable (String) -> Void), - onStderr: @escaping (@Sendable (String) -> Void) - ) async throws -> Int32 { - try await withCheckedThrowingContinuation { continuation in - let process = Process() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [command] + args - process.currentDirectoryURL = URL(fileURLWithPath: cwd) - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - process.environment = ProcessInfo.processInfo.environment.merging([ - "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" - ]) { _, new in new } - - let stdoutHandle = stdoutPipe.fileHandleForReading - let stderrHandle = stderrPipe.fileHandleForReading - - stdoutHandle.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - if let string = String(data: data, encoding: .utf8) { - onStdout(string) - } - } - - stderrHandle.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - if let string = String(data: data, encoding: .utf8) { - onStderr(string) - } - } - - process.terminationHandler = { proc in - stdoutHandle.readabilityHandler = nil - stderrHandle.readabilityHandler = nil - continuation.resume(returning: proc.terminationStatus) - } - - do { - try process.run() - } catch { - continuation.resume(throwing: error) - } - } - } -} diff --git a/Sources/ContainerCLI/Compose/ComposeCommand.swift b/Sources/ContainerCLI/Compose/ComposeCommand.swift deleted file mode 100644 index 03e940332..000000000 --- a/Sources/ContainerCLI/Compose/ComposeCommand.swift +++ /dev/null @@ -1,55 +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. -//===----------------------------------------------------------------------===// - -// -// File.swift -// Container-Compose -// -// Created by Morris Richman on 6/18/25. -// - -import ArgumentParser -import Foundation -import Rainbow -import Yams - -extension Application { - struct ComposeCommand: AsyncParsableCommand { - static let configuration: CommandConfiguration = .init( - commandName: "compose", - abstract: "Manage containers with Docker Compose files", - subcommands: [ - ComposeUp.self, - ComposeDown.self, - ]) - } -} - -/// A structure representing the result of a command-line process execution. -struct CommandResult { - /// The standard output captured from the process. - let stdout: String - - /// The standard error output captured from the process. - let stderr: String - - /// The exit code returned by the process upon termination. - let exitCode: Int32 -} - -extension NamedColor: Codable { - -} diff --git a/Sources/ContainerCLI/Compose/Errors.swift b/Sources/ContainerCLI/Compose/Errors.swift deleted file mode 100644 index c5b375aa2..000000000 --- a/Sources/ContainerCLI/Compose/Errors.swift +++ /dev/null @@ -1,66 +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. -//===----------------------------------------------------------------------===// - -// -// Errors.swift -// Container-Compose -// -// Created by Morris Richman on 6/18/25. -// - -import Foundation - -extension Application { - internal enum YamlError: Error, LocalizedError { - case dockerfileNotFound(String) - - var errorDescription: String? { - switch self { - case .dockerfileNotFound(let path): - return "docker-compose.yml not found at \(path)" - } - } - } - - internal enum ComposeError: Error, LocalizedError { - case imageNotFound(String) - case invalidProjectName - - var errorDescription: String? { - switch self { - case .imageNotFound(let name): - return "Service \(name) must define either 'image' or 'build'." - case .invalidProjectName: - return "Could not find project name." - } - } - } - - internal enum TerminalError: Error, LocalizedError { - case commandFailed(String) - - var errorDescription: String? { - "Command failed: \(self)" - } - } - - /// An enum representing streaming output from either `stdout` or `stderr`. - internal enum CommandOutput { - case stdout(String) - case stderr(String) - case exitCode(Int32) - } -} diff --git a/Sources/ContainerCLI/Compose/Helper Functions.swift b/Sources/ContainerCLI/Compose/Helper Functions.swift deleted file mode 100644 index e1068ad94..000000000 --- a/Sources/ContainerCLI/Compose/Helper Functions.swift +++ /dev/null @@ -1,96 +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. -//===----------------------------------------------------------------------===// - -// -// Helper Functions.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - -import Foundation -import Yams - -extension Application { - /// Loads environment variables from a .env file. - /// - Parameter path: The full path to the .env file. - /// - Returns: A dictionary of key-value pairs representing environment variables. - internal static func loadEnvFile(path: String) -> [String: String] { - var envVars: [String: String] = [:] - let fileURL = URL(fileURLWithPath: path) - do { - let content = try String(contentsOf: fileURL, encoding: .utf8) - let lines = content.split(separator: "\n") - for line in lines { - let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) - // Ignore empty lines and comments - if !trimmedLine.isEmpty && !trimmedLine.starts(with: "#") { - // Parse key=value pairs - if let eqIndex = trimmedLine.firstIndex(of: "=") { - let key = String(trimmedLine[.. String { - var resolvedValue = value - // Regex to find ${VAR}, ${VAR:-default}, ${VAR:?error} - let regex = try! NSRegularExpression(pattern: "\\$\\{([A-Z0-9_]+)(:?-(.*?))?(:\\?(.*?))?\\}", options: []) - - // Combine process environment with loaded .env file variables, prioritizing process environment - let combinedEnv = ProcessInfo.processInfo.environment.merging(envVars) { (current, _) in current } - - // Loop to resolve all occurrences of variables in the string - while let match = regex.firstMatch(in: resolvedValue, options: [], range: NSRange(resolvedValue.startIndex.. 0 && all { - throw ContainerizationError( - .invalidArgument, - message: "explicitly supplied container ID(s) conflict with the --all flag" - ) - } - } - - mutating func run() async throws { - let set = Set(containerIDs) - var containers = [ClientContainer]() - - if all { - containers = try await ClientContainer.list() - } else { - let ctrs = try await ClientContainer.list() - containers = ctrs.filter { c in - set.contains(c.id) - } - // If one of the containers requested isn't present, let's throw. We don't need to do - // this for --all as --all should be perfectly usable with no containers to remove; otherwise, - // it'd be quite clunky. - if containers.count != set.count { - let missing = set.filter { id in - !containers.contains { c in - c.id == id - } - } - throw ContainerizationError( - .notFound, - message: "failed to delete one or more containers: \(missing)" - ) - } - } - - var failed = [String]() - let force = self.force - let all = self.all - try await withThrowingTaskGroup(of: ClientContainer?.self) { group in - for container in containers { - group.addTask { - do { - // First we need to find if the container supports auto-remove - // and if so we need to skip deletion. - if container.status == .running { - if !force { - // We don't want to error if the user just wants all containers deleted. - // It's implied we'll skip containers we can't actually delete. - if all { - return nil - } - throw ContainerizationError(.invalidState, message: "container is running") - } - let stopOpts = ContainerStopOptions( - timeoutInSeconds: 5, - signal: SIGKILL - ) - try await container.stop(opts: stopOpts) - } - try await container.delete() - print(container.id) - return nil - } catch { - log.error("failed to delete container \(container.id): \(error)") - return container - } - } - } - - for try await ctr in group { - guard let ctr else { - continue - } - failed.append(ctr.id) - } - } - - if failed.count > 0 { - throw ContainerizationError(.internalError, message: "delete failed for one or more containers: \(failed)") - } - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerExec.swift b/Sources/ContainerCLI/Container/ContainerExec.swift deleted file mode 100644 index de3969585..000000000 --- a/Sources/ContainerCLI/Container/ContainerExec.swift +++ /dev/null @@ -1,96 +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 ContainerizationError -import ContainerizationOS -import Foundation - -extension Application { - struct ContainerExec: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "exec", - abstract: "Run a new command in a running container") - - @OptionGroup - var processFlags: Flags.Process - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Running containers ID") - var containerID: String - - @Argument(parsing: .captureForPassthrough, help: "New process arguments") - var arguments: [String] - - func run() async throws { - var exitCode: Int32 = 127 - let container = try await ClientContainer.get(id: containerID) - try ensureRunning(container: container) - - let stdin = self.processFlags.interactive - let tty = self.processFlags.tty - - var config = container.configuration.initProcess - config.executable = arguments.first! - config.arguments = [String](self.arguments.dropFirst()) - config.terminal = tty - config.environment.append( - contentsOf: try Parser.allEnv( - imageEnvs: [], - envFiles: self.processFlags.envFile, - envs: self.processFlags.env - )) - - if let cwd = self.processFlags.cwd { - config.workingDirectory = cwd - } - - let defaultUser = config.user - let (user, additionalGroups) = Parser.user( - user: processFlags.user, uid: processFlags.uid, - gid: processFlags.gid, defaultUser: defaultUser) - config.user = user - config.supplementalGroups.append(contentsOf: additionalGroups) - - do { - let io = try ProcessIO.create(tty: tty, interactive: stdin, detach: false) - - if !self.processFlags.tty { - var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) - handler.start { - print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") - Darwin.exit(1) - } - } - - let process = try await container.createProcess( - id: UUID().uuidString.lowercased(), - configuration: config) - - exitCode = try await Application.handleProcess(io: io, process: process) - } catch { - if error is ContainerizationError { - throw error - } - throw ContainerizationError(.internalError, message: "failed to exec process \(error)") - } - throw ArgumentParser.ExitCode(exitCode) - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerInspect.swift b/Sources/ContainerCLI/Container/ContainerInspect.swift deleted file mode 100644 index 43bda51a1..000000000 --- a/Sources/ContainerCLI/Container/ContainerInspect.swift +++ /dev/null @@ -1,43 +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 Foundation -import SwiftProtobuf - -extension Application { - struct ContainerInspect: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "inspect", - abstract: "Display information about one or more containers") - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Containers to inspect") - var containers: [String] - - func run() async throws { - let objects: [any Codable] = try await ClientContainer.list().filter { - containers.contains($0.id) - }.map { - PrintableContainer($0) - } - print(try objects.jsonArray()) - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerKill.swift b/Sources/ContainerCLI/Container/ContainerKill.swift deleted file mode 100644 index 9b9ef4ed4..000000000 --- a/Sources/ContainerCLI/Container/ContainerKill.swift +++ /dev/null @@ -1,79 +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 ContainerizationError -import ContainerizationOS -import Darwin - -extension Application { - struct ContainerKill: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "kill", - abstract: "Kill one or more running containers") - - @Option(name: .shortAndLong, help: "Signal to send the container(s)") - var signal: String = "KILL" - - @Flag(name: .shortAndLong, help: "Kill all running containers") - var all = false - - @Argument(help: "Container IDs") - var containerIDs: [String] = [] - - @OptionGroup - var global: Flags.Global - - func validate() throws { - if containerIDs.count == 0 && !all { - throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") - } - if containerIDs.count > 0 && all { - throw ContainerizationError(.invalidArgument, message: "explicitly supplied container IDs conflicts with the --all flag") - } - } - - mutating func run() async throws { - let set = Set(containerIDs) - - var containers = try await ClientContainer.list().filter { c in - c.status == .running - } - if !self.all { - containers = containers.filter { c in - set.contains(c.id) - } - } - - let signalNumber = try Signals.parseSignal(signal) - - var failed: [String] = [] - for container in containers { - do { - try await container.kill(signalNumber) - print(container.id) - } catch { - log.error("failed to kill container \(container.id): \(error)") - failed.append(container.id) - } - } - if failed.count > 0 { - throw ContainerizationError(.internalError, message: "kill failed for one or more containers") - } - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerList.swift b/Sources/ContainerCLI/Container/ContainerList.swift deleted file mode 100644 index 43e5a4cec..000000000 --- a/Sources/ContainerCLI/Container/ContainerList.swift +++ /dev/null @@ -1,110 +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 ContainerNetworkService -import ContainerizationExtras -import Foundation -import SwiftProtobuf - -extension Application { - struct ContainerList: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List containers", - aliases: ["ls"]) - - @Flag(name: .shortAndLong, help: "Show stopped containers as well") - var all = false - - @Flag(name: .shortAndLong, help: "Only output the container ID") - 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 containers = try await ClientContainer.list() - try printContainers(containers: containers, format: format) - } - - private func createHeader() -> [[String]] { - [["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR"]] - } - - private func printContainers(containers: [ClientContainer], format: ListFormat) throws { - if format == .json { - let printables = containers.map { - PrintableContainer($0) - } - let data = try JSONEncoder().encode(printables) - print(String(data: data, encoding: .utf8)!) - - return - } - - if self.quiet { - containers.forEach { - if !self.all && $0.status != .running { - return - } - print($0.id) - } - return - } - - var rows = createHeader() - for container in containers { - if !self.all && container.status != .running { - continue - } - rows.append(container.asRow) - } - - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } - } -} - -extension ClientContainer { - var asRow: [String] { - [ - self.id, - self.configuration.image.reference, - self.configuration.platform.os, - self.configuration.platform.architecture, - self.status.rawValue, - self.networks.compactMap { try? CIDRAddress($0.address).address.description }.joined(separator: ","), - ] - } -} - -struct PrintableContainer: Codable { - let status: RuntimeStatus - let configuration: ContainerConfiguration - let networks: [Attachment] - - init(_ container: ClientContainer) { - self.status = container.status - self.configuration = container.configuration - self.networks = container.networks - } -} diff --git a/Sources/ContainerCLI/Container/ContainerLogs.swift b/Sources/ContainerCLI/Container/ContainerLogs.swift deleted file mode 100644 index d70e80323..000000000 --- a/Sources/ContainerCLI/Container/ContainerLogs.swift +++ /dev/null @@ -1,144 +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 ContainerizationError -import Dispatch -import Foundation - -extension Application { - struct ContainerLogs: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "logs", - abstract: "Fetch container stdio or boot logs" - ) - - @OptionGroup - var global: Flags.Global - - @Flag(name: .shortAndLong, help: "Follow log output") - var follow: Bool = false - - @Flag(name: .long, help: "Display the boot log for the container instead of stdio") - var boot: Bool = false - - @Option(name: [.customShort("n")], help: "Number of lines to show from the end of the logs. If not provided this will print all of the logs") - var numLines: Int? - - @Argument(help: "Container to fetch logs for") - var container: String - - func run() async throws { - do { - let container = try await ClientContainer.get(id: container) - let fhs = try await container.logs() - let fileHandle = boot ? fhs[1] : fhs[0] - - try await Self.tail( - fh: fileHandle, - n: numLines, - follow: follow - ) - } catch { - throw ContainerizationError( - .invalidArgument, - message: "failed to fetch container logs for \(container): \(error)" - ) - } - } - - private static func tail( - fh: FileHandle, - n: Int?, - follow: Bool - ) async throws { - if let n { - var buffer = Data() - let size = try fh.seekToEnd() - var offset = size - var lines: [String] = [] - - while offset > 0, lines.count < n { - let readSize = min(1024, offset) - offset -= readSize - try fh.seek(toOffset: offset) - - let data = fh.readData(ofLength: Int(readSize)) - buffer.insert(contentsOf: data, at: 0) - - if let chunk = String(data: buffer, encoding: .utf8) { - lines = chunk.components(separatedBy: .newlines) - lines = lines.filter { !$0.isEmpty } - } - } - - lines = Array(lines.suffix(n)) - for line in lines { - print(line) - } - } else { - // Fast path if all they want is the full file. - guard let data = try fh.readToEnd() else { - // Seems you get nil if it's a zero byte read, or you - // try and read from dev/null. - return - } - guard let str = String(data: data, encoding: .utf8) else { - throw ContainerizationError( - .internalError, - message: "failed to convert container logs to utf8" - ) - } - print(str.trimmingCharacters(in: .newlines)) - } - - if follow { - try await Self.followFile(fh: fh) - } - } - - private static func followFile(fh: FileHandle) async throws { - _ = try fh.seekToEnd() - let stream = AsyncStream { cont in - fh.readabilityHandler = { handle in - let data = handle.availableData - if data.isEmpty { - // Triggers on container restart - can exit here as well - do { - _ = try fh.seekToEnd() // To continue streaming existing truncated log files - } catch { - fh.readabilityHandler = nil - cont.finish() - return - } - } - if let str = String(data: data, encoding: .utf8), !str.isEmpty { - var lines = str.components(separatedBy: .newlines) - lines = lines.filter { !$0.isEmpty } - for line in lines { - cont.yield(line) - } - } - } - } - - for await line in stream { - print(line) - } - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerStart.swift b/Sources/ContainerCLI/Container/ContainerStart.swift deleted file mode 100644 index a804b9c20..000000000 --- a/Sources/ContainerCLI/Container/ContainerStart.swift +++ /dev/null @@ -1,87 +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 ContainerizationError -import ContainerizationOS -import TerminalProgress - -extension Application { - struct ContainerStart: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "start", - abstract: "Start a container") - - @Flag(name: .shortAndLong, help: "Attach STDOUT/STDERR") - var attach = false - - @Flag(name: .shortAndLong, help: "Attach container's STDIN") - var interactive = false - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Container's ID") - var containerID: String - - func run() async throws { - var exitCode: Int32 = 127 - - let progressConfig = try ProgressConfig( - description: "Starting container" - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - let container = try await ClientContainer.get(id: containerID) - let process = try await container.bootstrap() - - progress.set(description: "Starting init process") - let detach = !self.attach && !self.interactive - do { - let io = try ProcessIO.create( - tty: container.configuration.initProcess.terminal, - interactive: self.interactive, - detach: detach - ) - progress.finish() - if detach { - try await process.start(io.stdio) - defer { - try? io.close() - } - try io.closeAfterStart() - print(self.containerID) - return - } - - exitCode = try await Application.handleProcess(io: io, process: process) - } catch { - try? await container.stop() - - if error is ContainerizationError { - throw error - } - throw ContainerizationError(.internalError, message: "failed to start container: \(error)") - } - throw ArgumentParser.ExitCode(exitCode) - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerStop.swift b/Sources/ContainerCLI/Container/ContainerStop.swift deleted file mode 100644 index 78f69090e..000000000 --- a/Sources/ContainerCLI/Container/ContainerStop.swift +++ /dev/null @@ -1,102 +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 ContainerizationError -import ContainerizationOS -import Foundation - -extension Application { - struct ContainerStop: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "stop", - abstract: "Stop one or more running containers") - - @Flag(name: .shortAndLong, help: "Stop all running containers") - var all = false - - @Option(name: .shortAndLong, help: "Signal to send the container(s)") - var signal: String = "SIGTERM" - - @Option(name: .shortAndLong, help: "Seconds to wait before killing the container(s)") - var time: Int32 = 5 - - @Argument - var containerIDs: [String] = [] - - @OptionGroup - var global: Flags.Global - - func validate() throws { - if containerIDs.count == 0 && !all { - throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") - } - if containerIDs.count > 0 && all { - throw ContainerizationError( - .invalidArgument, message: "explicitly supplied container IDs conflicts with the --all flag") - } - } - - mutating func run() async throws { - let set = Set(containerIDs) - var containers = [ClientContainer]() - if self.all { - containers = try await ClientContainer.list() - } else { - containers = try await ClientContainer.list().filter { c in - set.contains(c.id) - } - } - - let opts = ContainerStopOptions( - timeoutInSeconds: self.time, - signal: try Signals.parseSignal(self.signal) - ) - let failed = try await Self.stopContainers(containers: containers, stopOptions: opts) - if failed.count > 0 { - throw ContainerizationError(.internalError, message: "stop failed for one or more containers \(failed.joined(separator: ","))") - } - } - - static func stopContainers(containers: [ClientContainer], stopOptions: ContainerStopOptions) async throws -> [String] { - var failed: [String] = [] - try await withThrowingTaskGroup(of: ClientContainer?.self) { group in - for container in containers { - group.addTask { - do { - try await container.stop(opts: stopOptions) - print(container.id) - return nil - } catch { - log.error("failed to stop container \(container.id): \(error)") - return container - } - } - } - - for try await ctr in group { - guard let ctr else { - continue - } - failed.append(ctr.id) - } - } - - return failed - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainersCommand.swift b/Sources/ContainerCLI/Container/ContainersCommand.swift deleted file mode 100644 index ef6aff93e..000000000 --- a/Sources/ContainerCLI/Container/ContainersCommand.swift +++ /dev/null @@ -1,38 +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 - -extension Application { - struct ContainersCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "containers", - abstract: "Manage containers", - subcommands: [ - ContainerCreate.self, - ContainerDelete.self, - ContainerExec.self, - ContainerInspect.self, - ContainerKill.self, - ContainerList.self, - ContainerLogs.self, - ContainerStart.self, - ContainerStop.self, - ], - aliases: ["container", "c"] - ) - } -} diff --git a/Sources/ContainerCLI/Container/ProcessUtils.swift b/Sources/ContainerCLI/Container/ProcessUtils.swift deleted file mode 100644 index d4dda6a27..000000000 --- a/Sources/ContainerCLI/Container/ProcessUtils.swift +++ /dev/null @@ -1,31 +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 ContainerClient -import Containerization -import ContainerizationError -import ContainerizationOS -import Foundation - -extension Application { - static func ensureRunning(container: ClientContainer) throws { - if container.status != .running { - throw ContainerizationError(.invalidState, message: "container \(container.id) is not running") - } - } -} diff --git a/Sources/ContainerCLI/DefaultCommand.swift b/Sources/ContainerCLI/DefaultCommand.swift deleted file mode 100644 index ef88aaaa3..000000000 --- a/Sources/ContainerCLI/DefaultCommand.swift +++ /dev/null @@ -1,54 +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 ContainerPlugin - -struct DefaultCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: nil, - shouldDisplay: false - ) - - @OptionGroup(visibility: .hidden) - var global: Flags.Global - - @Argument(parsing: .captureForPassthrough) - var remaining: [String] = [] - - func run() async throws { - // See if we have a possible plugin command. - guard let command = remaining.first else { - Application.printModifiedHelpText() - return - } - - // Check for edge cases and unknown options to match the behavior in the absence of plugins. - if command.isEmpty { - throw ValidationError("Unknown argument '\(command)'") - } else if command.starts(with: "-") { - throw ValidationError("Unknown option '\(command)'") - } - - let pluginLoader = Application.pluginLoader - guard let plugin = pluginLoader.findPlugin(name: command), plugin.config.isCLI else { - throw ValidationError("failed to find plugin named container-\(command)") - } - // Exec performs execvp (with no fork). - try plugin.exec(args: remaining) - } -} diff --git a/Sources/ContainerCLI/Image/ImageInspect.swift b/Sources/ContainerCLI/Image/ImageInspect.swift deleted file mode 100644 index cea356867..000000000 --- a/Sources/ContainerCLI/Image/ImageInspect.swift +++ /dev/null @@ -1,53 +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 ContainerizationError -import Foundation -import SwiftProtobuf - -extension Application { - struct ImageInspect: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "inspect", - abstract: "Display information about one or more images") - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Images to inspect") - var images: [String] - - func run() async throws { - var printable = [any Codable]() - let result = try await ClientImage.get(names: images) - let notFound = result.error - for image in result.images { - guard !Utility.isInfraImage(name: image.reference) else { - continue - } - printable.append(try await image.details()) - } - if printable.count > 0 { - print(try printable.jsonArray()) - } - if notFound.count > 0 { - throw ContainerizationError(.notFound, message: "Images: \(notFound.joined(separator: "\n"))") - } - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageList.swift b/Sources/ContainerCLI/Image/ImageList.swift deleted file mode 100644 index e666feca7..000000000 --- a/Sources/ContainerCLI/Image/ImageList.swift +++ /dev/null @@ -1,175 +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 Containerization -import ContainerizationError -import ContainerizationOCI -import Foundation -import SwiftProtobuf - -extension Application { - struct ListImageOptions: ParsableArguments { - @Flag(name: .shortAndLong, help: "Only output the image name") - var quiet = false - - @Flag(name: .shortAndLong, help: "Verbose output") - var verbose = false - - @Option(name: .long, help: "Format of the output") - var format: ListFormat = .table - - @OptionGroup - var global: Flags.Global - } - - struct ListImageImplementation { - static private func createHeader() -> [[String]] { - [["NAME", "TAG", "DIGEST"]] - } - - static private func createVerboseHeader() -> [[String]] { - [["NAME", "TAG", "INDEX DIGEST", "OS", "ARCH", "VARIANT", "SIZE", "CREATED", "MANIFEST DIGEST"]] - } - - static private func printImagesVerbose(images: [ClientImage]) async throws { - - var rows = createVerboseHeader() - for image in images { - let formatter = ByteCountFormatter() - for descriptor in try await image.index().manifests { - // Don't list attestation manifests - if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], - referenceType == "attestation-manifest" - { - continue - } - - guard let platform = descriptor.platform else { - continue - } - - let os = platform.os - let arch = platform.architecture - let variant = platform.variant ?? "" - - var config: ContainerizationOCI.Image - var manifest: ContainerizationOCI.Manifest - do { - config = try await image.config(for: platform) - manifest = try await image.manifest(for: platform) - } catch { - continue - } - - let created = config.created ?? "" - let size = descriptor.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size }) - let formattedSize = formatter.string(fromByteCount: size) - - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) - let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) - let row = [ - reference.name, - reference.tag ?? "", - Utility.trimDigest(digest: image.descriptor.digest), - os, - arch, - variant, - formattedSize, - created, - Utility.trimDigest(digest: descriptor.digest), - ] - rows.append(row) - } - } - - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } - - static private func printImages(images: [ClientImage], format: ListFormat, options: ListImageOptions) async throws { - var images = images - images.sort { - $0.reference < $1.reference - } - - if format == .json { - let data = try JSONEncoder().encode(images.map { $0.description }) - print(String(data: data, encoding: .utf8)!) - return - } - - if options.quiet { - try images.forEach { image in - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) - print(processedReferenceString) - } - return - } - - if options.verbose { - try await Self.printImagesVerbose(images: images) - return - } - - var rows = createHeader() - for image in images { - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) - let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) - rows.append([ - reference.name, - reference.tag ?? "", - Utility.trimDigest(digest: image.descriptor.digest), - ]) - } - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } - - static func validate(options: ListImageOptions) throws { - if options.quiet && options.verbose { - throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite and --verbose together") - } - let modifier = options.quiet || options.verbose - if modifier && options.format == .json { - throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite or --verbose along with --format json") - } - } - - static func listImages(options: ListImageOptions) async throws { - let images = try await ClientImage.list().filter { img in - !Utility.isInfraImage(name: img.reference) - } - try await printImages(images: images, format: options.format, options: options) - } - } - - struct ImageList: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List images", - aliases: ["ls"]) - - @OptionGroup - var options: ListImageOptions - - mutating func run() async throws { - try ListImageImplementation.validate(options: options) - try await ListImageImplementation.listImages(options: options) - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageLoad.swift b/Sources/ContainerCLI/Image/ImageLoad.swift deleted file mode 100644 index 719fd19ec..000000000 --- a/Sources/ContainerCLI/Image/ImageLoad.swift +++ /dev/null @@ -1,76 +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 Containerization -import ContainerizationError -import Foundation -import TerminalProgress - -extension Application { - struct ImageLoad: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "load", - abstract: "Load images from an OCI compatible tar archive" - ) - - @OptionGroup - var global: Flags.Global - - @Option( - name: .shortAndLong, help: "Path to the tar archive to load images from", completion: .file(), - transform: { str in - URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) - }) - var input: String - - func run() async throws { - guard FileManager.default.fileExists(atPath: input) else { - print("File does not exist \(input)") - Application.exit(withError: ArgumentParser.ExitCode(1)) - } - - let progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - totalTasks: 2 - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - progress.set(description: "Loading tar archive") - let loaded = try await ClientImage.load(from: input) - - let taskManager = ProgressTaskCoordinator() - let unpackTask = await taskManager.startTask() - progress.set(description: "Unpacking image") - progress.set(itemsName: "entries") - for image in loaded { - try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) - } - await taskManager.finish() - progress.finish() - print("Loaded images:") - for image in loaded { - print(image.reference) - } - } - } -} diff --git a/Sources/ContainerCLI/Image/ImagePrune.swift b/Sources/ContainerCLI/Image/ImagePrune.swift deleted file mode 100644 index d233247f1..000000000 --- a/Sources/ContainerCLI/Image/ImagePrune.swift +++ /dev/null @@ -1,38 +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 Foundation - -extension Application { - struct ImagePrune: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "prune", - abstract: "Remove unreferenced and dangling images") - - @OptionGroup - var global: Flags.Global - - func run() async throws { - let (_, size) = try await ClientImage.pruneImages() - let formatter = ByteCountFormatter() - let freed = formatter.string(fromByteCount: Int64(size)) - print("Cleaned unreferenced images and snapshots") - print("Reclaimed \(freed) in disk space") - } - } -} diff --git a/Sources/ContainerCLI/Image/ImagePull.swift b/Sources/ContainerCLI/Image/ImagePull.swift deleted file mode 100644 index 58f6dc2c6..000000000 --- a/Sources/ContainerCLI/Image/ImagePull.swift +++ /dev/null @@ -1,98 +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 Containerization -import ContainerizationError -import ContainerizationOCI -import TerminalProgress - -extension Application { - struct ImagePull: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "pull", - abstract: "Pull an image" - ) - - @OptionGroup - var global: Flags.Global - - @OptionGroup - var registry: Flags.Registry - - @OptionGroup - var progressFlags: Flags.Progress - - @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? - - @Argument var reference: String - - init() {} - - init(platform: String? = nil, scheme: String = "auto", reference: String, disableProgress: Bool = false) { - self.global = Flags.Global() - self.registry = Flags.Registry(scheme: scheme) - self.progressFlags = Flags.Progress(disableProgressUpdates: disableProgress) - self.platform = platform - self.reference = reference - } - - func run() async throws { - var p: Platform? - if let platform { - p = try Platform(from: platform) - } - - let scheme = try RequestScheme(registry.scheme) - - let processedReference = try ClientImage.normalizeReference(reference) - - var progressConfig: ProgressConfig - if self.progressFlags.disableProgressUpdates { - progressConfig = try ProgressConfig(disableProgressUpdates: self.progressFlags.disableProgressUpdates) - } else { - progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - ignoreSmallSize: true, - totalTasks: 2 - ) - } - - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - progress.set(description: "Fetching image") - progress.set(itemsName: "blobs") - let taskManager = ProgressTaskCoordinator() - let fetchTask = await taskManager.startTask() - let image = try await ClientImage.pull( - reference: processedReference, platform: p, scheme: scheme, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler) - ) - - progress.set(description: "Unpacking image") - progress.set(itemsName: "entries") - let unpackTask = await taskManager.startTask() - try await image.unpack(platform: p, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) - await taskManager.finish() - progress.finish() - } - } -} diff --git a/Sources/ContainerCLI/Image/ImagePush.swift b/Sources/ContainerCLI/Image/ImagePush.swift deleted file mode 100644 index e61d162de..000000000 --- a/Sources/ContainerCLI/Image/ImagePush.swift +++ /dev/null @@ -1,73 +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 Containerization -import ContainerizationOCI -import TerminalProgress - -extension Application { - struct ImagePush: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "push", - abstract: "Push an image" - ) - - @OptionGroup - var global: Flags.Global - - @OptionGroup - var registry: Flags.Registry - - @OptionGroup - var progressFlags: Flags.Progress - - @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? - - @Argument var reference: String - - func run() async throws { - var p: Platform? - if let platform { - p = try Platform(from: platform) - } - - let scheme = try RequestScheme(registry.scheme) - let image = try await ClientImage.get(reference: reference) - - var progressConfig: ProgressConfig - if progressFlags.disableProgressUpdates { - progressConfig = try ProgressConfig(disableProgressUpdates: progressFlags.disableProgressUpdates) - } else { - progressConfig = try ProgressConfig( - description: "Pushing image \(image.reference)", - itemsName: "blobs", - showItems: true, - showSpeed: false, - ignoreSmallSize: true - ) - } - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - _ = try await image.push(platform: p, scheme: scheme, progressUpdate: progress.handler) - progress.finish() - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageRemove.swift b/Sources/ContainerCLI/Image/ImageRemove.swift deleted file mode 100644 index 2f0c86c22..000000000 --- a/Sources/ContainerCLI/Image/ImageRemove.swift +++ /dev/null @@ -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 Containerization -import ContainerizationError -import Foundation - -extension Application { - struct RemoveImageOptions: ParsableArguments { - @Flag(name: .shortAndLong, help: "Remove all images") - var all: Bool = false - - @Argument - var images: [String] = [] - - @OptionGroup - var global: Flags.Global - } - - struct RemoveImageImplementation { - static func validate(options: RemoveImageOptions) throws { - if options.images.count == 0 && !options.all { - throw ContainerizationError(.invalidArgument, message: "no image specified and --all not supplied") - } - if options.images.count > 0 && options.all { - throw ContainerizationError(.invalidArgument, message: "explicitly supplied images conflict with the --all flag") - } - } - - static func removeImage(options: RemoveImageOptions) async throws { - let (found, notFound) = try await { - if options.all { - let found = try await ClientImage.list() - let notFound: [String] = [] - return (found, notFound) - } - return try await ClientImage.get(names: options.images) - }() - var failures: [String] = notFound - var didDeleteAnyImage = false - for image in found { - guard !Utility.isInfraImage(name: image.reference) else { - continue - } - do { - try await ClientImage.delete(reference: image.reference, garbageCollect: false) - print(image.reference) - didDeleteAnyImage = true - } catch { - log.error("failed to remove \(image.reference): \(error)") - failures.append(image.reference) - } - } - let (_, size) = try await ClientImage.pruneImages() - let formatter = ByteCountFormatter() - let freed = formatter.string(fromByteCount: Int64(size)) - - if didDeleteAnyImage { - print("Reclaimed \(freed) in disk space") - } - if failures.count > 0 { - throw ContainerizationError(.internalError, message: "failed to delete one or more images: \(failures)") - } - } - } - - struct ImageRemove: AsyncParsableCommand { - @OptionGroup - var options: RemoveImageOptions - - static let configuration = CommandConfiguration( - commandName: "delete", - abstract: "Remove one or more images", - aliases: ["rm"]) - - func validate() throws { - try RemoveImageImplementation.validate(options: options) - } - - mutating func run() async throws { - try await RemoveImageImplementation.removeImage(options: options) - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageSave.swift b/Sources/ContainerCLI/Image/ImageSave.swift deleted file mode 100644 index 8c0b6eac4..000000000 --- a/Sources/ContainerCLI/Image/ImageSave.swift +++ /dev/null @@ -1,67 +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 Containerization -import ContainerizationOCI -import Foundation -import TerminalProgress - -extension Application { - struct ImageSave: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "save", - abstract: "Save an image as an OCI compatible tar archive" - ) - - @OptionGroup - var global: Flags.Global - - @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? - - @Option( - name: .shortAndLong, help: "Path to save the image tar archive", completion: .file(), - transform: { str in - URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) - }) - var output: String - - @Argument var reference: String - - func run() async throws { - var p: Platform? - if let platform { - p = try Platform(from: platform) - } - - let progressConfig = try ProgressConfig( - description: "Saving image" - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - let image = try await ClientImage.get(reference: reference) - try await image.save(out: output, platform: p) - - progress.finish() - print("Image saved") - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageTag.swift b/Sources/ContainerCLI/Image/ImageTag.swift deleted file mode 100644 index 01a76190f..000000000 --- a/Sources/ContainerCLI/Image/ImageTag.swift +++ /dev/null @@ -1,42 +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 - -extension Application { - struct ImageTag: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "tag", - abstract: "Tag an image") - - @Argument(help: "SOURCE_IMAGE[:TAG]") - var source: String - - @Argument(help: "TARGET_IMAGE[:TAG]") - var target: String - - @OptionGroup - var global: Flags.Global - - func run() async throws { - let existing = try await ClientImage.get(reference: source) - let targetReference = try ClientImage.normalizeReference(target) - try await existing.tag(new: targetReference) - print("Image \(source) tagged as \(target)") - } - } -} diff --git a/Sources/ContainerCLI/Image/ImagesCommand.swift b/Sources/ContainerCLI/Image/ImagesCommand.swift deleted file mode 100644 index 968dfd239..000000000 --- a/Sources/ContainerCLI/Image/ImagesCommand.swift +++ /dev/null @@ -1,38 +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 - -extension Application { - struct ImagesCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "images", - abstract: "Manage images", - subcommands: [ - ImageInspect.self, - ImageList.self, - ImageLoad.self, - ImagePrune.self, - ImagePull.self, - ImagePush.self, - ImageRemove.self, - ImageSave.self, - ImageTag.self, - ], - aliases: ["image", "i"] - ) - } -} diff --git a/Sources/ContainerCLI/Network/NetworkCommand.swift b/Sources/ContainerCLI/Network/NetworkCommand.swift deleted file mode 100644 index 7e502431b..000000000 --- a/Sources/ContainerCLI/Network/NetworkCommand.swift +++ /dev/null @@ -1,33 +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 - -extension Application { - struct NetworkCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "network", - abstract: "Manage container networks", - subcommands: [ - NetworkCreate.self, - NetworkDelete.self, - NetworkList.self, - NetworkInspect.self, - ], - aliases: ["n"] - ) - } -} diff --git a/Sources/ContainerCLI/Network/NetworkCreate.swift b/Sources/ContainerCLI/Network/NetworkCreate.swift deleted file mode 100644 index 535e029ed..000000000 --- a/Sources/ContainerCLI/Network/NetworkCreate.swift +++ /dev/null @@ -1,42 +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 ContainerNetworkService -import ContainerizationError -import Foundation -import TerminalProgress - -extension Application { - struct NetworkCreate: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "create", - abstract: "Create a new network") - - @Argument(help: "Network name") - var name: String - - @OptionGroup - var global: Flags.Global - - func run() async throws { - let config = NetworkConfiguration(id: self.name, mode: .nat) - let state = try await ClientNetwork.create(configuration: config) - print(state.id) - } - } -} diff --git a/Sources/ContainerCLI/Network/NetworkDelete.swift b/Sources/ContainerCLI/Network/NetworkDelete.swift deleted file mode 100644 index 836d6c8ca..000000000 --- a/Sources/ContainerCLI/Network/NetworkDelete.swift +++ /dev/null @@ -1,116 +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 ContainerNetworkService -import ContainerizationError -import Foundation - -extension Application { - struct NetworkDelete: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "delete", - abstract: "Delete one or more networks", - aliases: ["rm"]) - - @Flag(name: .shortAndLong, help: "Remove all networks") - var all = false - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Network names") - var networkNames: [String] = [] - - func validate() throws { - if networkNames.count == 0 && !all { - throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied") - } - if networkNames.count > 0 && all { - throw ContainerizationError( - .invalidArgument, - message: "explicitly supplied network name(s) conflict with the --all flag" - ) - } - } - - mutating func run() async throws { - let uniqueNetworkNames = Set(networkNames) - let networks: [NetworkState] - - if all { - networks = try await ClientNetwork.list() - } else { - networks = try await ClientNetwork.list() - .filter { c in - uniqueNetworkNames.contains(c.id) - } - - // If one of the networks requested isn't present lets throw. We don't need to do - // this for --all as --all should be perfectly usable with no networks to remove, - // otherwise it'd be quite clunky. - if networks.count != uniqueNetworkNames.count { - let missing = uniqueNetworkNames.filter { id in - !networks.contains { n in - n.id == id - } - } - throw ContainerizationError( - .notFound, - message: "failed to delete one or more networks: \(missing)" - ) - } - } - - if uniqueNetworkNames.contains(ClientNetwork.defaultNetworkName) { - throw ContainerizationError( - .invalidArgument, - message: "cannot delete the default network" - ) - } - - var failed = [String]() - try await withThrowingTaskGroup(of: NetworkState?.self) { group in - for network in networks { - group.addTask { - do { - // delete atomically disables the IP allocator, then deletes - // the allocator disable fails if any IPs are still in use - try await ClientNetwork.delete(id: network.id) - print(network.id) - return nil - } catch { - log.error("failed to delete network \(network.id): \(error)") - return network - } - } - } - - for try await network in group { - guard let network else { - continue - } - failed.append(network.id) - } - } - - if failed.count > 0 { - throw ContainerizationError(.internalError, message: "delete failed for one or more networks: \(failed)") - } - } - } -} diff --git a/Sources/ContainerCLI/Network/NetworkInspect.swift b/Sources/ContainerCLI/Network/NetworkInspect.swift deleted file mode 100644 index 614c8b111..000000000 --- a/Sources/ContainerCLI/Network/NetworkInspect.swift +++ /dev/null @@ -1,44 +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 ContainerNetworkService -import Foundation -import SwiftProtobuf - -extension Application { - struct NetworkInspect: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "inspect", - abstract: "Display information about one or more networks") - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Networks to inspect") - var networks: [String] - - func run() async throws { - let objects: [any Codable] = try await ClientNetwork.list().filter { - networks.contains($0.id) - }.map { - PrintableNetwork($0) - } - print(try objects.jsonArray()) - } - } -} diff --git a/Sources/ContainerCLI/Network/NetworkList.swift b/Sources/ContainerCLI/Network/NetworkList.swift deleted file mode 100644 index 9fb44dcb4..000000000 --- a/Sources/ContainerCLI/Network/NetworkList.swift +++ /dev/null @@ -1,107 +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 ContainerNetworkService -import ContainerizationExtras -import Foundation -import SwiftProtobuf - -extension Application { - struct NetworkList: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List networks", - 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 networks = try await ClientNetwork.list() - try printNetworks(networks: networks, format: format) - } - - private func createHeader() -> [[String]] { - [["NETWORK", "STATE", "SUBNET"]] - } - - private func printNetworks(networks: [NetworkState], format: ListFormat) throws { - if format == .json { - let printables = networks.map { - PrintableNetwork($0) - } - let data = try JSONEncoder().encode(printables) - print(String(data: data, encoding: .utf8)!) - - return - } - - if self.quiet { - networks.forEach { - print($0.id) - } - return - } - - var rows = createHeader() - for network in networks { - rows.append(network.asRow) - } - - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } - } -} - -extension NetworkState { - var asRow: [String] { - switch self { - case .created(_): - return [self.id, self.state, "none"] - case .running(_, let status): - return [self.id, self.state, status.address] - } - } -} - -struct PrintableNetwork: Codable { - let id: String - let state: String - let config: NetworkConfiguration - let status: NetworkStatus? - - init(_ network: NetworkState) { - self.id = network.id - self.state = network.state - switch network { - case .created(let config): - self.config = config - self.status = nil - case .running(let config, let status): - self.config = config - self.status = status - } - } -} diff --git a/Sources/ContainerCLI/Registry/Login.swift b/Sources/ContainerCLI/Registry/Login.swift deleted file mode 100644 index 7de7fe7e4..000000000 --- a/Sources/ContainerCLI/Registry/Login.swift +++ /dev/null @@ -1,92 +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 Containerization -import ContainerizationError -import ContainerizationOCI -import Foundation - -extension Application { - struct Login: AsyncParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Login to a registry" - ) - - @Option(name: .shortAndLong, help: "Username") - var username: String = "" - - @Flag(help: "Take the password from stdin") - var passwordStdin: Bool = false - - @Argument(help: "Registry server name") - var server: String - - @OptionGroup - var registry: Flags.Registry - - func run() async throws { - var username = self.username - var password = "" - if passwordStdin { - if username == "" { - throw ContainerizationError( - .invalidArgument, message: "must provide --username with --password-stdin") - } - guard let passwordData = try FileHandle.standardInput.readToEnd() else { - throw ContainerizationError(.invalidArgument, message: "failed to read password from stdin") - } - password = String(decoding: passwordData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) - } - let keychain = KeychainHelper(id: Constants.keychainID) - if username == "" { - username = try keychain.userPrompt(domain: server) - } - if password == "" { - password = try keychain.passwordPrompt() - print() - } - - let server = Reference.resolveDomain(domain: server) - let scheme = try RequestScheme(registry.scheme).schemeFor(host: server) - let _url = "\(scheme)://\(server)" - guard let url = URL(string: _url) else { - throw ContainerizationError(.invalidArgument, message: "Cannot convert \(_url) to URL") - } - guard let host = url.host else { - throw ContainerizationError(.invalidArgument, message: "Invalid host \(server)") - } - - let client = RegistryClient( - host: host, - scheme: scheme.rawValue, - port: url.port, - authentication: BasicAuthentication(username: username, password: password), - retryOptions: .init( - maxRetries: 10, - retryInterval: 300_000_000, - shouldRetry: ({ response in - response.status.code >= 500 - }) - ) - ) - try await client.ping() - try keychain.save(domain: server, username: username, password: password) - print("Login succeeded") - } - } -} diff --git a/Sources/ContainerCLI/Registry/Logout.swift b/Sources/ContainerCLI/Registry/Logout.swift deleted file mode 100644 index a24996e12..000000000 --- a/Sources/ContainerCLI/Registry/Logout.swift +++ /dev/null @@ -1,39 +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 Containerization -import ContainerizationOCI - -extension Application { - struct Logout: AsyncParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Log out from a registry") - - @Argument(help: "Registry server name") - var registry: String - - @OptionGroup - var global: Flags.Global - - func run() async throws { - let keychain = KeychainHelper(id: Constants.keychainID) - let r = Reference.resolveDomain(domain: registry) - try keychain.delete(domain: r) - } - } -} diff --git a/Sources/ContainerCLI/Registry/RegistryCommand.swift b/Sources/ContainerCLI/Registry/RegistryCommand.swift deleted file mode 100644 index c160c9469..000000000 --- a/Sources/ContainerCLI/Registry/RegistryCommand.swift +++ /dev/null @@ -1,32 +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 - -extension Application { - struct RegistryCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "registry", - abstract: "Manage registry configurations", - subcommands: [ - Login.self, - Logout.self, - RegistryDefault.self, - ], - aliases: ["r"] - ) - } -} diff --git a/Sources/ContainerCLI/Registry/RegistryDefault.swift b/Sources/ContainerCLI/Registry/RegistryDefault.swift deleted file mode 100644 index 593d41e27..000000000 --- a/Sources/ContainerCLI/Registry/RegistryDefault.swift +++ /dev/null @@ -1,98 +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 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 - } - } - ClientDefaults.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 { - ClientDefaults.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(ClientDefaults.get(key: .defaultRegistryDomain)) - } - } -} diff --git a/Sources/ContainerCLI/RunCommand.swift b/Sources/ContainerCLI/RunCommand.swift deleted file mode 100644 index 3a818e939..000000000 --- a/Sources/ContainerCLI/RunCommand.swift +++ /dev/null @@ -1,317 +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 Containerization -import ContainerizationError -import ContainerizationExtras -import ContainerizationOS -import Foundation -import NIOCore -import NIOPosix -import TerminalProgress - -extension Application { - struct ContainerRunCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "run", - abstract: "Run a container") - - @OptionGroup - var processFlags: Flags.Process - - @OptionGroup - var resourceFlags: Flags.Resource - - @OptionGroup - var managementFlags: Flags.Management - - @OptionGroup - var registryFlags: Flags.Registry - - @OptionGroup - var global: Flags.Global - - @OptionGroup - var progressFlags: Flags.Progress - - @Argument(help: "Image name") - var image: String - - @Argument(parsing: .captureForPassthrough, help: "Container init process arguments") - var arguments: [String] = [] - - func run() async throws { - var exitCode: Int32 = 127 - let id = Utility.createContainerID(name: self.managementFlags.name) - - var progressConfig: ProgressConfig - if progressFlags.disableProgressUpdates { - progressConfig = try ProgressConfig(disableProgressUpdates: progressFlags.disableProgressUpdates) - } else { - progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - ignoreSmallSize: true, - totalTasks: 6 - ) - } - - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - try Utility.validEntityName(id) - - // Check if container with id already exists. - let existing = try? await ClientContainer.get(id: id) - guard existing == nil else { - throw ContainerizationError( - .exists, - message: "container with id \(id) already exists" - ) - } - - let ck = try await Utility.containerConfigFromFlags( - id: id, - image: image, - arguments: arguments, - process: processFlags, - management: managementFlags, - resource: resourceFlags, - registry: registryFlags, - progressUpdate: progress.handler - ) - - progress.set(description: "Starting container") - - let options = ContainerCreateOptions(autoRemove: managementFlags.remove) - let container = try await ClientContainer.create( - configuration: ck.0, - options: options, - kernel: ck.1 - ) - - let detach = self.managementFlags.detach - - let process = try await container.bootstrap() - progress.finish() - - do { - let io = try ProcessIO.create( - tty: self.processFlags.tty, - interactive: self.processFlags.interactive, - detach: detach - ) - - if !self.managementFlags.cidfile.isEmpty { - let path = self.managementFlags.cidfile - let data = id.data(using: .utf8) - var attributes = [FileAttributeKey: Any]() - attributes[.posixPermissions] = 0o644 - let success = FileManager.default.createFile( - atPath: path, - contents: data, - attributes: attributes - ) - guard success else { - throw ContainerizationError( - .internalError, message: "failed to create cidfile at \(path): \(errno)") - } - } - - if detach { - try await process.start(io.stdio) - defer { - try? io.close() - } - try io.closeAfterStart() - print(id) - return - } - - if !self.processFlags.tty { - var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) - handler.start { - print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") - Darwin.exit(1) - } - } - - exitCode = try await Application.handleProcess(io: io, process: process) - } catch { - if error is ContainerizationError { - throw error - } - throw ContainerizationError(.internalError, message: "failed to run container: \(error)") - } - throw ArgumentParser.ExitCode(exitCode) - } - } -} - -struct ProcessIO { - let stdin: Pipe? - let stdout: Pipe? - let stderr: Pipe? - var ioTracker: IoTracker? - - struct IoTracker { - let stream: AsyncStream - let cont: AsyncStream.Continuation - let configuredStreams: Int - } - - let stdio: [FileHandle?] - - let console: Terminal? - - func closeAfterStart() throws { - try stdin?.fileHandleForReading.close() - try stdout?.fileHandleForWriting.close() - try stderr?.fileHandleForWriting.close() - } - - func close() throws { - try console?.reset() - } - - static func create(tty: Bool, interactive: Bool, detach: Bool) throws -> ProcessIO { - let current: Terminal? = try { - if !tty { - return nil - } - let current = try Terminal.current - try current.setraw() - return current - }() - - var stdio = [FileHandle?](repeating: nil, count: 3) - - let stdin: Pipe? = { - if !interactive && !tty { - return nil - } - return Pipe() - }() - - if let stdin { - if interactive { - let pin = FileHandle.standardInput - pin.readabilityHandler = { handle in - let data = handle.availableData - if data.isEmpty { - pin.readabilityHandler = nil - return - } - try! stdin.fileHandleForWriting.write(contentsOf: data) - } - } - stdio[0] = stdin.fileHandleForReading - } - - let stdout: Pipe? = { - if detach { - return nil - } - return Pipe() - }() - - var configuredStreams = 0 - let (stream, cc) = AsyncStream.makeStream() - if let stdout { - configuredStreams += 1 - let pout: FileHandle = { - if let current { - return current.handle - } - return .standardOutput - }() - - let rout = stdout.fileHandleForReading - rout.readabilityHandler = { handle in - let data = handle.availableData - if data.isEmpty { - rout.readabilityHandler = nil - cc.yield() - return - } - try! pout.write(contentsOf: data) - } - stdio[1] = stdout.fileHandleForWriting - } - - let stderr: Pipe? = { - if detach || tty { - return nil - } - return Pipe() - }() - if let stderr { - configuredStreams += 1 - let perr: FileHandle = .standardError - let rerr = stderr.fileHandleForReading - rerr.readabilityHandler = { handle in - let data = handle.availableData - if data.isEmpty { - rerr.readabilityHandler = nil - cc.yield() - return - } - try! perr.write(contentsOf: data) - } - stdio[2] = stderr.fileHandleForWriting - } - - var ioTracker: IoTracker? = nil - if configuredStreams > 0 { - ioTracker = .init(stream: stream, cont: cc, configuredStreams: configuredStreams) - } - - return .init( - stdin: stdin, - stdout: stdout, - stderr: stderr, - ioTracker: ioTracker, - stdio: stdio, - console: current - ) - } - - public func wait() async throws { - guard let ioTracker = self.ioTracker else { - return - } - do { - try await Timeout.run(seconds: 3) { - var counter = ioTracker.configuredStreams - for await _ in ioTracker.stream { - counter -= 1 - if counter == 0 { - ioTracker.cont.finish() - break - } - } - } - } catch { - log.error("Timeout waiting for IO to complete : \(error)") - throw error - } - } -} diff --git a/Sources/ContainerCLI/System/DNS/DNSCreate.swift b/Sources/ContainerCLI/System/DNS/DNSCreate.swift deleted file mode 100644 index 2dbe2d8ac..000000000 --- a/Sources/ContainerCLI/System/DNS/DNSCreate.swift +++ /dev/null @@ -1,51 +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 ContainerizationError -import ContainerizationExtras -import Foundation - -extension Application { - struct DNSCreate: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "create", - abstract: "Create a local DNS domain for containers (must run as an administrator)" - ) - - @Argument(help: "the local domain name") - var domainName: String - - func run() async throws { - let resolver: HostDNSResolver = HostDNSResolver() - do { - try resolver.createDomain(name: domainName) - print(domainName) - } catch let error as ContainerizationError { - throw error - } catch { - throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") - } - - do { - try HostDNSResolver.reinitialize() - } catch { - throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") - } - } - } -} diff --git a/Sources/ContainerCLI/System/DNS/DNSDefault.swift b/Sources/ContainerCLI/System/DNS/DNSDefault.swift deleted file mode 100644 index 5a746eab5..000000000 --- a/Sources/ContainerCLI/System/DNS/DNSDefault.swift +++ /dev/null @@ -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 ContainerClient - -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 { - ClientDefaults.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 { - ClientDefaults.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(ClientDefaults.getOptional(key: .defaultDNSDomain) ?? "") - } - } - } -} diff --git a/Sources/ContainerCLI/System/DNS/DNSDelete.swift b/Sources/ContainerCLI/System/DNS/DNSDelete.swift deleted file mode 100644 index b3360bb57..000000000 --- a/Sources/ContainerCLI/System/DNS/DNSDelete.swift +++ /dev/null @@ -1,49 +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 ContainerizationError -import Foundation - -extension Application { - struct DNSDelete: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "delete", - abstract: "Delete a local DNS domain (must run as an administrator)", - aliases: ["rm"] - ) - - @Argument(help: "the local domain name") - var domainName: String - - func run() async throws { - let resolver = HostDNSResolver() - do { - try resolver.deleteDomain(name: domainName) - print(domainName) - } catch { - throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") - } - - do { - try HostDNSResolver.reinitialize() - } catch { - throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") - } - } - } -} diff --git a/Sources/ContainerCLI/System/DNS/DNSList.swift b/Sources/ContainerCLI/System/DNS/DNSList.swift deleted file mode 100644 index 616415775..000000000 --- a/Sources/ContainerCLI/System/DNS/DNSList.swift +++ /dev/null @@ -1,36 +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 Foundation - -extension Application { - struct DNSList: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List local DNS domains", - aliases: ["ls"] - ) - - func run() async throws { - let resolver: HostDNSResolver = HostDNSResolver() - let domains = resolver.listDomains() - print(domains.joined(separator: "\n")) - } - - } -} diff --git a/Sources/ContainerCLI/System/Kernel/KernelSet.swift b/Sources/ContainerCLI/System/Kernel/KernelSet.swift deleted file mode 100644 index 6a1ac1790..000000000 --- a/Sources/ContainerCLI/System/Kernel/KernelSet.swift +++ /dev/null @@ -1,114 +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 Containerization -import ContainerizationError -import ContainerizationExtras -import ContainerizationOCI -import Foundation -import TerminalProgress - -extension Application { - struct KernelSet: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "set", - abstract: "Set the default kernel" - ) - - @Option(name: .customLong("binary"), help: "Path to the binary to set as the default kernel. If used with --tar, this points to a location inside the tar") - var binaryPath: String? = nil - - @Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar ball that contains the kernel to use") - var tarPath: String? = nil - - @Option(name: .customLong("arch"), help: "The architecture of the kernel binary. One of (amd64, arm64)") - var architecture: String = ContainerizationOCI.Platform.current.architecture.description - - @Flag(name: .customLong("recommended"), help: "Download and install the recommended kernel as the default. This flag ignores any other arguments") - var recommended: Bool = false - - func run() async throws { - if recommended { - let url = ClientDefaults.get(key: .defaultKernelURL) - let path = ClientDefaults.get(key: .defaultKernelBinaryPath) - print("Installing the recommended kernel from \(url)...") - try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path) - return - } - guard tarPath != nil else { - return try await self.setKernelFromBinary() - } - try await self.setKernelFromTar() - } - - private func setKernelFromBinary() async throws { - guard let binaryPath else { - throw ArgumentParser.ValidationError("Missing argument '--binary'") - } - let absolutePath = URL(fileURLWithPath: binaryPath, relativeTo: .currentDirectory()).absoluteURL.absoluteString - let platform = try getSystemPlatform() - try await ClientKernel.installKernel(kernelFilePath: absolutePath, platform: platform) - } - - private func setKernelFromTar() async throws { - guard let binaryPath else { - throw ArgumentParser.ValidationError("Missing argument '--binary'") - } - guard let tarPath else { - throw ArgumentParser.ValidationError("Missing argument '--tar") - } - let platform = try getSystemPlatform() - let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).absoluteString - let fm = FileManager.default - if fm.fileExists(atPath: localTarPath) { - try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform) - return - } - guard let remoteURL = URL(string: tarPath) else { - throw ContainerizationError(.invalidArgument, message: "Invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?") - } - try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL.absoluteString, kernelFilePath: binaryPath, platform: platform) - } - - private func getSystemPlatform() throws -> SystemPlatform { - switch architecture { - case "arm64": - return .linuxArm - case "amd64": - return .linuxAmd - default: - throw ContainerizationError(.unsupported, message: "Unsupported architecture \(architecture)") - } - } - - public static func downloadAndInstallWithProgressBar(tarRemoteURL: String, kernelFilePath: String, platform: SystemPlatform = .current) async throws { - let progressConfig = try ProgressConfig( - showTasks: true, - totalTasks: 2 - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - try await ClientKernel.installKernelFromTar(tarFile: tarRemoteURL, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler) - progress.finish() - } - - } -} diff --git a/Sources/ContainerCLI/System/SystemCommand.swift b/Sources/ContainerCLI/System/SystemCommand.swift deleted file mode 100644 index 3a92bfb92..000000000 --- a/Sources/ContainerCLI/System/SystemCommand.swift +++ /dev/null @@ -1,35 +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 - -extension Application { - struct SystemCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "system", - abstract: "Manage system components", - subcommands: [ - SystemDNS.self, - SystemLogs.self, - SystemStart.self, - SystemStop.self, - SystemStatus.self, - SystemKernel.self, - ], - aliases: ["s"] - ) - } -} diff --git a/Sources/ContainerCLI/System/SystemDNS.swift b/Sources/ContainerCLI/System/SystemDNS.swift deleted file mode 100644 index 4f9b3e3b3..000000000 --- a/Sources/ContainerCLI/System/SystemDNS.swift +++ /dev/null @@ -1,34 +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 ContainerizationError -import Foundation - -extension Application { - struct SystemDNS: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "dns", - abstract: "Manage local DNS domains", - subcommands: [ - DNSCreate.self, - DNSDelete.self, - DNSList.self, - DNSDefault.self, - ] - ) - } -} diff --git a/Sources/ContainerCLI/System/SystemKernel.swift b/Sources/ContainerCLI/System/SystemKernel.swift deleted file mode 100644 index 942bd6965..000000000 --- a/Sources/ContainerCLI/System/SystemKernel.swift +++ /dev/null @@ -1,29 +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 - -extension Application { - struct SystemKernel: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "kernel", - abstract: "Manage the default kernel configuration", - subcommands: [ - KernelSet.self - ] - ) - } -} diff --git a/Sources/ContainerCLI/System/SystemLogs.swift b/Sources/ContainerCLI/System/SystemLogs.swift deleted file mode 100644 index e2b87ffb9..000000000 --- a/Sources/ContainerCLI/System/SystemLogs.swift +++ /dev/null @@ -1,82 +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 ContainerizationError -import ContainerizationOS -import Foundation -import OSLog - -extension Application { - struct SystemLogs: AsyncParsableCommand { - static let subsystem = "com.apple.container" - - static let configuration = CommandConfiguration( - commandName: "logs", - abstract: "Fetch system logs for `container` services" - ) - - @OptionGroup - var global: Flags.Global - - @Option( - name: .long, - help: "Fetch logs starting from the specified time period (minus the current time); supported formats: m, h, d" - ) - var last: String = "5m" - - @Flag(name: .shortAndLong, help: "Follow log output") - var follow: Bool = false - - func run() async throws { - let process = Process() - let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM]) - - Task { - for await _ in sigHandler.signals { - process.terminate() - Darwin.exit(0) - } - } - - do { - var args = ["log"] - args.append(self.follow ? "stream" : "show") - args.append(contentsOf: ["--info", "--debug"]) - if !self.follow { - args.append(contentsOf: ["--last", last]) - } - args.append(contentsOf: ["--predicate", "subsystem = 'com.apple.container'"]) - - process.launchPath = "/usr/bin/env" - process.arguments = args - - process.standardOutput = FileHandle.standardOutput - process.standardError = FileHandle.standardError - - try process.run() - process.waitUntilExit() - } catch { - throw ContainerizationError( - .invalidArgument, - message: "failed to system logs: \(error)" - ) - } - throw ArgumentParser.ExitCode(process.terminationStatus) - } - } -} diff --git a/Sources/ContainerCLI/System/SystemStart.swift b/Sources/ContainerCLI/System/SystemStart.swift deleted file mode 100644 index acce91391..000000000 --- a/Sources/ContainerCLI/System/SystemStart.swift +++ /dev/null @@ -1,170 +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 ContainerPlugin -import ContainerizationError -import Foundation -import TerminalProgress - -extension Application { - struct SystemStart: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "start", - abstract: "Start `container` services" - ) - - @Option(name: .shortAndLong, help: "Path to the `container-apiserver` binary") - var path: String = Bundle.main.executablePath ?? "" - - @Flag(name: .long, help: "Enable debug logging for the runtime daemon.") - var debug = false - - @Flag( - name: .long, inversion: .prefixedEnableDisable, - help: "Specify whether the default kernel should be installed or not. The default behavior is to prompt the user for a response.") - var kernelInstall: Bool? - - func run() async throws { - // Without the true path to the binary in the plist, `container-apiserver` won't launch properly. - let executableUrl = URL(filePath: path) - .resolvingSymlinksInPath() - .deletingLastPathComponent() - .appendingPathComponent("container-apiserver") - - var args = [executableUrl.absolutePath()] - if debug { - args.append("--debug") - } - - let apiServerDataUrl = appRoot.appending(path: "apiserver") - try! FileManager.default.createDirectory(at: apiServerDataUrl, withIntermediateDirectories: true) - let env = ProcessInfo.processInfo.environment.filter { key, _ in - key.hasPrefix("CONTAINER_") - } - - let logURL = apiServerDataUrl.appending(path: "apiserver.log") - let plist = LaunchPlist( - label: "com.apple.container.apiserver", - arguments: args, - environment: env, - limitLoadToSessionType: [.Aqua, .Background, .System], - runAtLoad: true, - stdout: logURL.path, - stderr: logURL.path, - machServices: ["com.apple.container.apiserver"] - ) - - let plistURL = apiServerDataUrl.appending(path: "apiserver.plist") - let data = try plist.encode() - try data.write(to: plistURL) - - try ServiceManager.register(plistPath: plistURL.path) - - // Now ping our friendly daemon. Fail if we don't get a response. - do { - print("Verifying apiserver is running...") - try await ClientHealthCheck.ping(timeout: .seconds(10)) - } catch { - throw ContainerizationError( - .internalError, - message: "failed to get a response from apiserver: \(error)" - ) - } - - if await !initImageExists() { - try? await installInitialFilesystem() - } - - guard await !kernelExists() else { - return - } - try await installDefaultKernel() - } - - private func installInitialFilesystem() async throws { - let dep = Dependencies.initFs - let pullCommand = ImagePull(reference: dep.source) - print("Installing base container filesystem...") - do { - try await pullCommand.run() - } catch { - log.error("Failed to install base container filesystem: \(error)") - } - } - - private func installDefaultKernel() async throws { - let kernelDependency = Dependencies.kernel - let defaultKernelURL = kernelDependency.source - let defaultKernelBinaryPath = ClientDefaults.get(key: .defaultKernelBinaryPath) - - var shouldInstallKernel = false - if kernelInstall == nil { - print("No default kernel configured.") - print("Install the recommended default kernel from [\(kernelDependency.source)]? [Y/n]: ", terminator: "") - guard let read = readLine(strippingNewline: true) else { - throw ContainerizationError(.internalError, message: "Failed to read user input") - } - guard read.lowercased() == "y" || read.count == 0 else { - print("Please use the `container system kernel set --recommended` command to configure the default kernel") - return - } - shouldInstallKernel = true - } else { - shouldInstallKernel = kernelInstall ?? false - } - guard shouldInstallKernel else { - return - } - print("Installing kernel...") - try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: defaultKernelURL, kernelFilePath: defaultKernelBinaryPath) - } - - private func initImageExists() async -> Bool { - do { - let img = try await ClientImage.get(reference: Dependencies.initFs.source) - let _ = try await img.getSnapshot(platform: .current) - return true - } catch { - return false - } - } - - private func kernelExists() async -> Bool { - do { - try await ClientKernel.getDefaultKernel(for: .current) - return true - } catch { - return false - } - } - } - - private enum Dependencies: String { - case kernel - case initFs - - var source: String { - switch self { - case .initFs: - return ClientDefaults.get(key: .defaultInitImage) - case .kernel: - return ClientDefaults.get(key: .defaultKernelURL) - } - } - } -} diff --git a/Sources/ContainerCLI/System/SystemStatus.swift b/Sources/ContainerCLI/System/SystemStatus.swift deleted file mode 100644 index 132607681..000000000 --- a/Sources/ContainerCLI/System/SystemStatus.swift +++ /dev/null @@ -1,52 +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 ContainerPlugin -import ContainerizationError -import Foundation -import Logging - -extension Application { - struct SystemStatus: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "status", - abstract: "Show the status of `container` services" - ) - - @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") - var prefix: String = "com.apple.container." - - func run() async throws { - let isRegistered = try ServiceManager.isRegistered(fullServiceLabel: "\(prefix)apiserver") - if !isRegistered { - print("apiserver is not running and not registered with launchd") - Application.exit(withError: ExitCode(1)) - } - - // Now ping our friendly daemon. Fail after 10 seconds with no response. - do { - print("Verifying apiserver is running...") - try await ClientHealthCheck.ping(timeout: .seconds(10)) - print("apiserver is running") - } catch { - print("apiserver is not running") - Application.exit(withError: ExitCode(1)) - } - } - } -} diff --git a/Sources/ContainerCLI/System/SystemStop.swift b/Sources/ContainerCLI/System/SystemStop.swift deleted file mode 100644 index 32824dd0c..000000000 --- a/Sources/ContainerCLI/System/SystemStop.swift +++ /dev/null @@ -1,91 +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 ContainerPlugin -import ContainerizationOS -import Foundation -import Logging - -extension Application { - struct SystemStop: AsyncParsableCommand { - private static let stopTimeoutSeconds: Int32 = 5 - private static let shutdownTimeoutSeconds: Int32 = 20 - - static let configuration = CommandConfiguration( - commandName: "stop", - abstract: "Stop all `container` services" - ) - - @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") - var prefix: String = "com.apple.container." - - func run() async throws { - let log = Logger( - label: "com.apple.container.cli", - factory: { label in - StreamLogHandler.standardOutput(label: label) - } - ) - - let launchdDomainString = try ServiceManager.getDomainString() - let fullLabel = "\(launchdDomainString)/\(prefix)apiserver" - - log.info("stopping containers", metadata: ["stopTimeoutSeconds": "\(Self.stopTimeoutSeconds)"]) - do { - let containers = try await ClientContainer.list() - let signal = try Signals.parseSignal("SIGTERM") - let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: signal) - let failed = try await ContainerStop.stopContainers(containers: containers, stopOptions: opts) - if !failed.isEmpty { - log.warning("some containers could not be stopped gracefully", metadata: ["ids": "\(failed)"]) - } - - } catch { - log.warning("failed to stop all containers", metadata: ["error": "\(error)"]) - } - - log.info("waiting for containers to exit") - do { - for _ in 0.. Date: Tue, 15 Jul 2025 21:00:16 -0600 Subject: [PATCH 45/80] Revert "exposed CLI as product" This reverts commit 7b7a0ab9258e38b87975cbec78885a5003aa6c4b. --- Package.swift | 20 -------------------- Sources/ContainerCLI | Bin 916 -> 0 bytes 2 files changed, 20 deletions(-) delete mode 100644 Sources/ContainerCLI diff --git a/Package.swift b/Package.swift index e81e38005..04b3f3977 100644 --- a/Package.swift +++ b/Package.swift @@ -76,26 +76,6 @@ let package = Package( ], path: "Sources/CLI" ), - .target( - name: "ContainerCLI", - dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "Logging", package: "swift-log"), - .product(name: "SwiftProtobuf", package: "swift-protobuf"), - .product(name: "Containerization", package: "containerization"), - .product(name: "ContainerizationOCI", package: "containerization"), - .product(name: "ContainerizationOS", package: "containerization"), - "CVersion", - "TerminalProgress", - "ContainerBuild", - "ContainerClient", - "ContainerPlugin", - "ContainerLog", - "Yams", - "Rainbow", - ], - path: "Sources/ContainerCLI" - ), .executableTarget( name: "container-apiserver", dependencies: [ diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI deleted file mode 100644 index ce9b7e01c3c1838dc5198f48c2a927a02ac3d673..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 916 zcmZvayG{a85Qfis#YR^#YUjq-5|G=16&H;fO)L~bVS~#OiNKOYV`GfI0TyN?bbnot|+K=eGCL%gLVKhi6YPCaIXpJU4ma;2bxp~)HIHT!3#7@5p{B1!!f z3@u+Mn(JP#*YOcAWsUs<%^#NUE5uXa0=50~ynH?1!C$5Qs80lfn+cz;dxCm2=n2O4 zkSCh-M?Hz8KN1eDg*L(oU7r1h_CRm$;gw?4%Zru1gMxR9ZIv)~5v z9Jt2p2G^N2@GG+qEc+`>+(C}df{}7;u8Em&Txk#LRj~ZNi|@U=OB+_eE*{P=_%T-2 From a371c82a1b388e0c09e6227bd1a9e8e5e75d0550 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:00:19 -0600 Subject: [PATCH 46/80] Revert "Update Package.swift" This reverts commit 4c517c53871262d0fd06e8261c51964bca2e1712. --- Package.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Package.swift b/Package.swift index 04b3f3977..c8b0e7a69 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,6 @@ let package = Package( .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService"]), .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerClient", targets: ["ContainerClient"]), - .library(name: "ContainerCLI", targets: ["ContainerCLI"]), .library(name: "ContainerBuild", targets: ["ContainerBuild"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), From 3445fe3eefe697c6f60f8bf96a9a70393c4729a9 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:01:21 -0600 Subject: [PATCH 47/80] Update Package.swift --- Package.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Package.swift b/Package.swift index c8b0e7a69..85a3f058b 100644 --- a/Package.swift +++ b/Package.swift @@ -51,7 +51,6 @@ let package = Package( .package(url: "https://github.com/Bouke/DNS.git", from: "1.2.0"), .package(url: "https://github.com/jpsim/Yams.git", from: "5.0.6"), .package(url: "https://github.com/onevcat/Rainbow", .upToNextMajor(from: "4.0.0")), - scDependency, .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)), ], targets: [ From bd19de16344de08780160109ba3ae4cca9c8bfe1 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:08:40 -0600 Subject: [PATCH 48/80] moved Compose to plugin format --- Package.swift | 13 +++++++++++-- .../compose}/Codable Structs/Build.swift | 0 .../compose}/Codable Structs/Config.swift | 0 .../compose}/Codable Structs/Deploy.swift | 0 .../compose}/Codable Structs/DeployResources.swift | 0 .../Codable Structs/DeployRestartPolicy.swift | 0 .../Codable Structs/DeviceReservation.swift | 0 .../compose}/Codable Structs/DockerCompose.swift | 0 .../compose}/Codable Structs/ExternalConfig.swift | 0 .../compose}/Codable Structs/ExternalNetwork.swift | 0 .../compose}/Codable Structs/ExternalSecret.swift | 0 .../compose}/Codable Structs/ExternalVolume.swift | 0 .../compose}/Codable Structs/Healthcheck.swift | 0 .../compose}/Codable Structs/Network.swift | 0 .../compose}/Codable Structs/ResourceLimits.swift | 0 .../Codable Structs/ResourceReservations.swift | 0 .../compose}/Codable Structs/Secret.swift | 0 .../compose}/Codable Structs/Service.swift | 0 .../compose}/Codable Structs/ServiceConfig.swift | 0 .../compose}/Codable Structs/ServiceSecret.swift | 0 .../compose}/Codable Structs/Volume.swift | 0 .../compose}/Commands/ComposeDown.swift | 1 + .../compose}/Commands/ComposeUp.swift | 1 + .../compose}/ComposeCommand.swift | 0 .../CLI/Compose => Plugins/compose}/Errors.swift | 1 + .../compose}/Helper Functions.swift | 1 + Sources/CLI/Application.swift | 1 - 27 files changed, 15 insertions(+), 3 deletions(-) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/Build.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/Config.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/Deploy.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/DeployResources.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/DeployRestartPolicy.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/DeviceReservation.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/DockerCompose.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/ExternalConfig.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/ExternalNetwork.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/ExternalSecret.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/ExternalVolume.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/Healthcheck.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/Network.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/ResourceLimits.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/ResourceReservations.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/Secret.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/Service.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/ServiceConfig.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/ServiceSecret.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Codable Structs/Volume.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Commands/ComposeDown.swift (99%) rename {Sources/CLI/Compose => Plugins/compose}/Commands/ComposeUp.swift (99%) rename {Sources/CLI/Compose => Plugins/compose}/ComposeCommand.swift (100%) rename {Sources/CLI/Compose => Plugins/compose}/Errors.swift (99%) rename {Sources/CLI/Compose => Plugins/compose}/Helper Functions.swift (99%) diff --git a/Package.swift b/Package.swift index 85a3f058b..0a52ace41 100644 --- a/Package.swift +++ b/Package.swift @@ -69,8 +69,6 @@ let package = Package( "ContainerClient", "ContainerPlugin", "ContainerLog", - "Yams", - "Rainbow", ], path: "Sources/CLI" ), @@ -311,5 +309,16 @@ let package = Package( .define("BUILDER_SHIM_VERSION", to: "\"\(builderShimVersion)\""), ] ), + + // MARK: Plugins + .executableTarget( + name: "Compose", + dependencies: [ + "container", + "Yams", + "Rainbow", + ], + path: "Plugins/compose" + ) ] ) diff --git a/Sources/CLI/Compose/Codable Structs/Build.swift b/Plugins/compose/Codable Structs/Build.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/Build.swift rename to Plugins/compose/Codable Structs/Build.swift diff --git a/Sources/CLI/Compose/Codable Structs/Config.swift b/Plugins/compose/Codable Structs/Config.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/Config.swift rename to Plugins/compose/Codable Structs/Config.swift diff --git a/Sources/CLI/Compose/Codable Structs/Deploy.swift b/Plugins/compose/Codable Structs/Deploy.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/Deploy.swift rename to Plugins/compose/Codable Structs/Deploy.swift diff --git a/Sources/CLI/Compose/Codable Structs/DeployResources.swift b/Plugins/compose/Codable Structs/DeployResources.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/DeployResources.swift rename to Plugins/compose/Codable Structs/DeployResources.swift diff --git a/Sources/CLI/Compose/Codable Structs/DeployRestartPolicy.swift b/Plugins/compose/Codable Structs/DeployRestartPolicy.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/DeployRestartPolicy.swift rename to Plugins/compose/Codable Structs/DeployRestartPolicy.swift diff --git a/Sources/CLI/Compose/Codable Structs/DeviceReservation.swift b/Plugins/compose/Codable Structs/DeviceReservation.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/DeviceReservation.swift rename to Plugins/compose/Codable Structs/DeviceReservation.swift diff --git a/Sources/CLI/Compose/Codable Structs/DockerCompose.swift b/Plugins/compose/Codable Structs/DockerCompose.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/DockerCompose.swift rename to Plugins/compose/Codable Structs/DockerCompose.swift diff --git a/Sources/CLI/Compose/Codable Structs/ExternalConfig.swift b/Plugins/compose/Codable Structs/ExternalConfig.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/ExternalConfig.swift rename to Plugins/compose/Codable Structs/ExternalConfig.swift diff --git a/Sources/CLI/Compose/Codable Structs/ExternalNetwork.swift b/Plugins/compose/Codable Structs/ExternalNetwork.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/ExternalNetwork.swift rename to Plugins/compose/Codable Structs/ExternalNetwork.swift diff --git a/Sources/CLI/Compose/Codable Structs/ExternalSecret.swift b/Plugins/compose/Codable Structs/ExternalSecret.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/ExternalSecret.swift rename to Plugins/compose/Codable Structs/ExternalSecret.swift diff --git a/Sources/CLI/Compose/Codable Structs/ExternalVolume.swift b/Plugins/compose/Codable Structs/ExternalVolume.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/ExternalVolume.swift rename to Plugins/compose/Codable Structs/ExternalVolume.swift diff --git a/Sources/CLI/Compose/Codable Structs/Healthcheck.swift b/Plugins/compose/Codable Structs/Healthcheck.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/Healthcheck.swift rename to Plugins/compose/Codable Structs/Healthcheck.swift diff --git a/Sources/CLI/Compose/Codable Structs/Network.swift b/Plugins/compose/Codable Structs/Network.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/Network.swift rename to Plugins/compose/Codable Structs/Network.swift diff --git a/Sources/CLI/Compose/Codable Structs/ResourceLimits.swift b/Plugins/compose/Codable Structs/ResourceLimits.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/ResourceLimits.swift rename to Plugins/compose/Codable Structs/ResourceLimits.swift diff --git a/Sources/CLI/Compose/Codable Structs/ResourceReservations.swift b/Plugins/compose/Codable Structs/ResourceReservations.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/ResourceReservations.swift rename to Plugins/compose/Codable Structs/ResourceReservations.swift diff --git a/Sources/CLI/Compose/Codable Structs/Secret.swift b/Plugins/compose/Codable Structs/Secret.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/Secret.swift rename to Plugins/compose/Codable Structs/Secret.swift diff --git a/Sources/CLI/Compose/Codable Structs/Service.swift b/Plugins/compose/Codable Structs/Service.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/Service.swift rename to Plugins/compose/Codable Structs/Service.swift diff --git a/Sources/CLI/Compose/Codable Structs/ServiceConfig.swift b/Plugins/compose/Codable Structs/ServiceConfig.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/ServiceConfig.swift rename to Plugins/compose/Codable Structs/ServiceConfig.swift diff --git a/Sources/CLI/Compose/Codable Structs/ServiceSecret.swift b/Plugins/compose/Codable Structs/ServiceSecret.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/ServiceSecret.swift rename to Plugins/compose/Codable Structs/ServiceSecret.swift diff --git a/Sources/CLI/Compose/Codable Structs/Volume.swift b/Plugins/compose/Codable Structs/Volume.swift similarity index 100% rename from Sources/CLI/Compose/Codable Structs/Volume.swift rename to Plugins/compose/Codable Structs/Volume.swift diff --git a/Sources/CLI/Compose/Commands/ComposeDown.swift b/Plugins/compose/Commands/ComposeDown.swift similarity index 99% rename from Sources/CLI/Compose/Commands/ComposeDown.swift rename to Plugins/compose/Commands/ComposeDown.swift index 8993f8ddb..39826b22e 100644 --- a/Sources/CLI/Compose/Commands/ComposeDown.swift +++ b/Plugins/compose/Commands/ComposeDown.swift @@ -25,6 +25,7 @@ import ArgumentParser import ContainerClient import Foundation import Yams +import container extension Application { public struct ComposeDown: AsyncParsableCommand { diff --git a/Sources/CLI/Compose/Commands/ComposeUp.swift b/Plugins/compose/Commands/ComposeUp.swift similarity index 99% rename from Sources/CLI/Compose/Commands/ComposeUp.swift rename to Plugins/compose/Commands/ComposeUp.swift index 6b1053670..7230dd63a 100644 --- a/Sources/CLI/Compose/Commands/ComposeUp.swift +++ b/Plugins/compose/Commands/ComposeUp.swift @@ -27,6 +27,7 @@ import Foundation @preconcurrency import Rainbow import Yams import ContainerizationExtras +import container extension Application { public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { diff --git a/Sources/CLI/Compose/ComposeCommand.swift b/Plugins/compose/ComposeCommand.swift similarity index 100% rename from Sources/CLI/Compose/ComposeCommand.swift rename to Plugins/compose/ComposeCommand.swift diff --git a/Sources/CLI/Compose/Errors.swift b/Plugins/compose/Errors.swift similarity index 99% rename from Sources/CLI/Compose/Errors.swift rename to Plugins/compose/Errors.swift index c5b375aa2..3b650cd1b 100644 --- a/Sources/CLI/Compose/Errors.swift +++ b/Plugins/compose/Errors.swift @@ -22,6 +22,7 @@ // import Foundation +import container extension Application { internal enum YamlError: Error, LocalizedError { diff --git a/Sources/CLI/Compose/Helper Functions.swift b/Plugins/compose/Helper Functions.swift similarity index 99% rename from Sources/CLI/Compose/Helper Functions.swift rename to Plugins/compose/Helper Functions.swift index e1068ad94..6defad617 100644 --- a/Sources/CLI/Compose/Helper Functions.swift +++ b/Plugins/compose/Helper Functions.swift @@ -23,6 +23,7 @@ import Foundation import Yams +import container extension Application { /// Loads environment variables from a .env file. diff --git a/Sources/CLI/Application.swift b/Sources/CLI/Application.swift index fe925b496..e62ded4fd 100644 --- a/Sources/CLI/Application.swift +++ b/Sources/CLI/Application.swift @@ -58,7 +58,6 @@ public struct Application: AsyncParsableCommand { CommandGroup( name: "Container", subcommands: [ - ComposeCommand.self, ContainerCreate.self, ContainerDelete.self, ContainerExec.self, From 82a23325c79808d67776c5c3158ffcce5d9434db Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:10:27 -0600 Subject: [PATCH 49/80] Create compose-config.json --- config/compose-config.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 config/compose-config.json diff --git a/config/compose-config.json b/config/compose-config.json new file mode 100644 index 000000000..0e59a61cf --- /dev/null +++ b/config/compose-config.json @@ -0,0 +1,4 @@ +{ + "abstract" : "Orchestrate container resources using Docker Compose files", + "author": "http://github.com/mcrich23/container" +} From 0cb47d322db0cde9f963930c74eb206907b13c19 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:11:37 -0600 Subject: [PATCH 50/80] add compose install to make file --- Makefile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6d005dc9a..58b3c1d1e 100644 --- a/Makefile +++ b/Makefile @@ -68,7 +68,13 @@ install: installer-pkg rm -rf $${temp_dir} ; \ else \ $(SUDO) installer -pkg $(PKG_PATH) -target / ; \ - fi + fi + + @echo Installing compose into $(DESTDIR)... + @$(SUDO) mkdir -p $(join $(DESTDIR), libexec/container-plugins/compose/bin) + @$(SUDO) install $(BUILD_BIN_DIR)/compose $(join $(DESTDIR), libexec/container-plugins/compose/bin/compose) + @$(SUDO) install config/compose-config.json $(join $(DESTDIR), libexec/container-plugins/compose/config.json) + @$(SUDO) codesign $(CODESIGN_OPTS) $(join $(DESTDIR), libexec/container-plugins/compose/bin/compose) $(STAGING_DIR): @echo Installing container binaries from $(BUILD_BIN_DIR) into $(STAGING_DIR)... From e24e0488f285cee97b7eee0fb922287bd3127955 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:14:58 -0600 Subject: [PATCH 51/80] Update ComposeCommand.swift --- Plugins/compose/ComposeCommand.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Plugins/compose/ComposeCommand.swift b/Plugins/compose/ComposeCommand.swift index 03e940332..07ebd683a 100644 --- a/Plugins/compose/ComposeCommand.swift +++ b/Plugins/compose/ComposeCommand.swift @@ -25,6 +25,7 @@ import ArgumentParser import Foundation import Rainbow import Yams +import container extension Application { struct ComposeCommand: AsyncParsableCommand { From b960fdf30f619814056e1d1bd8324cde950f1716 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:23:58 -0600 Subject: [PATCH 52/80] access control fixes --- Package.swift | 2 +- Sources/CLI/BuildCommand.swift | 40 +++++++++++++------------ Sources/CLI/Image/ImagePull.swift | 20 ++++++------- Sources/CLI/Network/NetworkCreate.swift | 12 ++++---- 4 files changed, 39 insertions(+), 35 deletions(-) diff --git a/Package.swift b/Package.swift index 0a52ace41..d3edefc1a 100644 --- a/Package.swift +++ b/Package.swift @@ -312,7 +312,7 @@ let package = Package( // MARK: Plugins .executableTarget( - name: "Compose", + name: "compose", dependencies: [ "container", "Yams", diff --git a/Sources/CLI/BuildCommand.swift b/Sources/CLI/BuildCommand.swift index a1e84c258..8df8af6e4 100644 --- a/Sources/CLI/BuildCommand.swift +++ b/Sources/CLI/BuildCommand.swift @@ -27,7 +27,7 @@ import NIO import TerminalProgress extension Application { - struct BuildCommand: AsyncParsableCommand { + public struct BuildCommand: AsyncParsableCommand { public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "build" @@ -36,6 +36,8 @@ extension Application { config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) return config } + + public init() {} @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") public var cpus: Int64 = 2 @@ -45,64 +47,64 @@ extension Application { help: "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" ) - var memory: String = "2048MB" + public var memory: String = "2048MB" @Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val")) - var buildArg: [String] = [] + public var buildArg: [String] = [] @Argument(help: "Build directory") - var contextDir: String = "." + public var contextDir: String = "." @Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path")) - var file: String = "Dockerfile" + public var file: String = "Dockerfile" @Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val")) - var label: [String] = [] + public var label: [String] = [] @Flag(name: .long, help: "Do not use cache") - var noCache: Bool = false + public var noCache: Bool = false @Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build", valueName: "value")) - var output: [String] = { + public var output: [String] = { ["type=oci"] }() @Option(name: .long, help: ArgumentHelp("Cache imports for the build", valueName: "value", visibility: .hidden)) - var cacheIn: [String] = { + public var cacheIn: [String] = { [] }() @Option(name: .long, help: ArgumentHelp("Cache exports for the build", valueName: "value", visibility: .hidden)) - var cacheOut: [String] = { + public var cacheOut: [String] = { [] }() @Option(name: .long, help: ArgumentHelp("set the build architecture", valueName: "value")) - var arch: [String] = { + public var arch: [String] = { ["arm64"] }() @Option(name: .long, help: ArgumentHelp("set the build os", valueName: "value")) - var os: [String] = { + public var os: [String] = { ["linux"] }() @Option(name: .long, help: ArgumentHelp("Progress type - one of [auto|plain|tty]", valueName: "type")) - var progress: String = "auto" + public var progress: String = "auto" @Option(name: .long, help: ArgumentHelp("Builder-shim vsock port", valueName: "port")) - var vsockPort: UInt32 = 8088 + public var vsockPort: UInt32 = 8088 @Option(name: [.customShort("t"), .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) - var targetImageName: String = UUID().uuidString.lowercased() + public var targetImageName: String = UUID().uuidString.lowercased() @Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage")) - var target: String = "" + public var target: String = "" @Flag(name: .shortAndLong, help: "Suppress build output") - var quiet: Bool = false + public var quiet: Bool = false - func run() async throws { + public func run() async throws { do { let timeout: Duration = .seconds(300) let progressConfig = try ProgressConfig( @@ -298,7 +300,7 @@ extension Application { } } - func validate() throws { + public func validate() throws { guard FileManager.default.fileExists(atPath: file) else { throw ValidationError("Dockerfile does not exist at path: \(file)") } diff --git a/Sources/CLI/Image/ImagePull.swift b/Sources/CLI/Image/ImagePull.swift index 58f6dc2c6..806cb8f42 100644 --- a/Sources/CLI/Image/ImagePull.swift +++ b/Sources/CLI/Image/ImagePull.swift @@ -22,28 +22,28 @@ import ContainerizationOCI import TerminalProgress extension Application { - struct ImagePull: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ImagePull: AsyncParsableCommand { + public static let configuration = CommandConfiguration( commandName: "pull", abstract: "Pull an image" ) @OptionGroup - var global: Flags.Global + public var global: Flags.Global @OptionGroup - var registry: Flags.Registry + public var registry: Flags.Registry @OptionGroup - var progressFlags: Flags.Progress + public var progressFlags: Flags.Progress - @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") public var platform: String? - @Argument var reference: String + @Argument public var reference: String - init() {} + public init() {} - init(platform: String? = nil, scheme: String = "auto", reference: String, disableProgress: Bool = false) { + public init(platform: String? = nil, scheme: String = "auto", reference: String, disableProgress: Bool = false) { self.global = Flags.Global() self.registry = Flags.Registry(scheme: scheme) self.progressFlags = Flags.Progress(disableProgressUpdates: disableProgress) @@ -51,7 +51,7 @@ extension Application { self.reference = reference } - func run() async throws { + public func run() async throws { var p: Platform? if let platform { p = try Platform(from: platform) diff --git a/Sources/CLI/Network/NetworkCreate.swift b/Sources/CLI/Network/NetworkCreate.swift index 535e029ed..40f86f7d1 100644 --- a/Sources/CLI/Network/NetworkCreate.swift +++ b/Sources/CLI/Network/NetworkCreate.swift @@ -22,18 +22,20 @@ import Foundation import TerminalProgress extension Application { - struct NetworkCreate: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct NetworkCreate: AsyncParsableCommand { + public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a new network") + + public init() {} @Argument(help: "Network name") - var name: String + public var name: String @OptionGroup - var global: Flags.Global + public var global: Flags.Global - func run() async throws { + public func run() async throws { let config = NetworkConfiguration(id: self.name, mode: .nat) let state = try await ClientNetwork.create(configuration: config) print(state.id) From e9d2926dbab56f09c625969e9eed7faa0501719f Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:32:55 -0600 Subject: [PATCH 53/80] swift concurrency fix --- Sources/CLI/BuildCommand.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Sources/CLI/BuildCommand.swift b/Sources/CLI/BuildCommand.swift index 8df8af6e4..9c4d6e0bb 100644 --- a/Sources/CLI/BuildCommand.swift +++ b/Sources/CLI/BuildCommand.swift @@ -47,7 +47,7 @@ extension Application { help: "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" ) - public var memory: String = "2048MB" + var memory: String = "2048MB" @Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val")) public var buildArg: [String] = [] @@ -93,7 +93,7 @@ extension Application { public var progress: String = "auto" @Option(name: .long, help: ArgumentHelp("Builder-shim vsock port", valueName: "port")) - public var vsockPort: UInt32 = 8088 + var vsockPort: UInt32 = 8088 @Option(name: [.customShort("t"), .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) public var targetImageName: String = UUID().uuidString.lowercased() @@ -124,17 +124,18 @@ extension Application { group.cancelAll() } - group.addTask { + group.addTask { [vsockPort, cpus, memory] in while true { do { let container = try await ClientContainer.get(id: "buildkit") - let fh = try await container.dial(self.vsockPort) + let fh = try await container.dial(vsockPort) let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) let b = try Builder(socket: fh, group: threadGroup) // If this call succeeds, then BuildKit is running. let _ = try await b.info() + return b } catch { // If we get here, "Dialing builder" is shown for such a short period @@ -143,8 +144,8 @@ extension Application { progress.set(totalTasks: 3) try await BuilderStart.start( - cpus: self.cpus, - memory: self.memory, + cpus: cpus, + memory: memory, progressUpdate: progress.handler ) @@ -231,7 +232,7 @@ extension Application { } return results }() - group.addTask { [terminal] in + group.addTask { [buildArg, contextDir, label, noCache, terminal, target, quiet, cacheIn, cacheOut] in let config = ContainerBuild.Builder.BuildConfig( buildID: buildID, contentStore: RemoteContentStoreClient(), From d2186ed6250bce276bca48643aa22036132eb64a Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:36:43 -0600 Subject: [PATCH 54/80] Update BuildCommand.swift --- Sources/CLI/BuildCommand.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CLI/BuildCommand.swift b/Sources/CLI/BuildCommand.swift index 9c4d6e0bb..59f289372 100644 --- a/Sources/CLI/BuildCommand.swift +++ b/Sources/CLI/BuildCommand.swift @@ -47,7 +47,7 @@ extension Application { help: "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" ) - var memory: String = "2048MB" + public var memory: String = "2048MB" @Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val")) public var buildArg: [String] = [] @@ -93,7 +93,7 @@ extension Application { public var progress: String = "auto" @Option(name: .long, help: ArgumentHelp("Builder-shim vsock port", valueName: "port")) - var vsockPort: UInt32 = 8088 + public var vsockPort: UInt32 = 8088 @Option(name: [.customShort("t"), .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) public var targetImageName: String = UUID().uuidString.lowercased() From c5fe1d4ccd70b1c3d08e5a61132979e9afc89b90 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 22:48:10 -0600 Subject: [PATCH 55/80] Reapply "Update Package.swift" This reverts commit a371c82a1b388e0c09e6227bd1a9e8e5e75d0550. --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index d3edefc1a..7ffc002d0 100644 --- a/Package.swift +++ b/Package.swift @@ -33,6 +33,7 @@ let package = Package( .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService"]), .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerClient", targets: ["ContainerClient"]), + .library(name: "ContainerCLI", targets: ["ContainerCLI"]), .library(name: "ContainerBuild", targets: ["ContainerBuild"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), From a44bcc164c33f6f7fdd5c53aaf3811b91db7df04 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 22:48:12 -0600 Subject: [PATCH 56/80] Reapply "exposed CLI as product" This reverts commit c76dd7af4a1073173c582c8d3c2ce80d3bd910cc. --- Package.swift | 20 ++++++++++++++++++++ Sources/ContainerCLI | Bin 0 -> 916 bytes 2 files changed, 20 insertions(+) create mode 100644 Sources/ContainerCLI diff --git a/Package.swift b/Package.swift index 7ffc002d0..3cdd99578 100644 --- a/Package.swift +++ b/Package.swift @@ -73,6 +73,26 @@ let package = Package( ], path: "Sources/CLI" ), + .target( + name: "ContainerCLI", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "SwiftProtobuf", package: "swift-protobuf"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + "CVersion", + "TerminalProgress", + "ContainerBuild", + "ContainerClient", + "ContainerPlugin", + "ContainerLog", + "Yams", + "Rainbow", + ], + path: "Sources/ContainerCLI" + ), .executableTarget( name: "container-apiserver", dependencies: [ diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI new file mode 100644 index 0000000000000000000000000000000000000000..ce9b7e01c3c1838dc5198f48c2a927a02ac3d673 GIT binary patch literal 916 zcmZvayG{a85Qfis#YR^#YUjq-5|G=16&H;fO)L~bVS~#OiNKOYV`GfI0TyN?bbnot|+K=eGCL%gLVKhi6YPCaIXpJU4ma;2bxp~)HIHT!3#7@5p{B1!!f z3@u+Mn(JP#*YOcAWsUs<%^#NUE5uXa0=50~ynH?1!C$5Qs80lfn+cz;dxCm2=n2O4 zkSCh-M?Hz8KN1eDg*L(oU7r1h_CRm$;gw?4%Zru1gMxR9ZIv)~5v z9Jt2p2G^N2@GG+qEc+`>+(C}df{}7;u8Em&Txk#LRj~ZNi|@U=OB+_eE*{P=_%T-2 literal 0 HcmV?d00001 From 970c8848e6e8f89df1a8fb32cba78c0628a22c47 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 22:48:14 -0600 Subject: [PATCH 57/80] Reapply "copy cli to container cli" This reverts commit 59ec9ddaf9107c71c9a365e57f353898aa4466fd. --- Sources/ContainerCLI | Bin 916 -> 0 bytes Sources/ContainerCLI/Application.swift | 335 ++++++++ Sources/ContainerCLI/BuildCommand.swift | 313 ++++++++ Sources/ContainerCLI/Builder/Builder.swift | 31 + .../ContainerCLI/Builder/BuilderDelete.swift | 57 ++ .../ContainerCLI/Builder/BuilderStart.swift | 262 ++++++ .../ContainerCLI/Builder/BuilderStatus.swift | 71 ++ .../ContainerCLI/Builder/BuilderStop.swift | 49 ++ Sources/ContainerCLI/Codable+JSON.swift | 25 + .../Compose/Codable Structs/Build.swift | 52 ++ .../Compose/Codable Structs/Config.swift | 55 ++ .../Compose/Codable Structs/Deploy.swift | 35 + .../Codable Structs/DeployResources.swift | 31 + .../Codable Structs/DeployRestartPolicy.swift | 35 + .../Codable Structs/DeviceReservation.swift | 35 + .../Codable Structs/DockerCompose.swift | 60 ++ .../Codable Structs/ExternalConfig.swift | 31 + .../Codable Structs/ExternalNetwork.swift | 31 + .../Codable Structs/ExternalSecret.swift | 31 + .../Codable Structs/ExternalVolume.swift | 31 + .../Compose/Codable Structs/Healthcheck.swift | 37 + .../Compose/Codable Structs/Network.swift | 68 ++ .../Codable Structs/ResourceLimits.swift | 31 + .../ResourceReservations.swift | 34 + .../Compose/Codable Structs/Secret.swift | 58 ++ .../Compose/Codable Structs/Service.swift | 203 +++++ .../Codable Structs/ServiceConfig.swift | 64 ++ .../Codable Structs/ServiceSecret.swift | 64 ++ .../Compose/Codable Structs/Volume.swift | 70 ++ .../Compose/Commands/ComposeDown.swift | 105 +++ .../Compose/Commands/ComposeUp.swift | 749 ++++++++++++++++++ .../ContainerCLI/Compose/ComposeCommand.swift | 55 ++ Sources/ContainerCLI/Compose/Errors.swift | 66 ++ .../Compose/Helper Functions.swift | 96 +++ .../Container/ContainerCreate.swift | 100 +++ .../Container/ContainerDelete.swift | 127 +++ .../Container/ContainerExec.swift | 96 +++ .../Container/ContainerInspect.swift | 43 + .../Container/ContainerKill.swift | 79 ++ .../Container/ContainerList.swift | 110 +++ .../Container/ContainerLogs.swift | 144 ++++ .../Container/ContainerStart.swift | 87 ++ .../Container/ContainerStop.swift | 102 +++ .../Container/ContainersCommand.swift | 38 + .../ContainerCLI/Container/ProcessUtils.swift | 31 + Sources/ContainerCLI/DefaultCommand.swift | 54 ++ Sources/ContainerCLI/Image/ImageInspect.swift | 53 ++ Sources/ContainerCLI/Image/ImageList.swift | 175 ++++ Sources/ContainerCLI/Image/ImageLoad.swift | 76 ++ Sources/ContainerCLI/Image/ImagePrune.swift | 38 + Sources/ContainerCLI/Image/ImagePull.swift | 98 +++ Sources/ContainerCLI/Image/ImagePush.swift | 73 ++ Sources/ContainerCLI/Image/ImageRemove.swift | 99 +++ Sources/ContainerCLI/Image/ImageSave.swift | 67 ++ Sources/ContainerCLI/Image/ImageTag.swift | 42 + .../ContainerCLI/Image/ImagesCommand.swift | 38 + .../ContainerCLI/Network/NetworkCommand.swift | 33 + .../ContainerCLI/Network/NetworkCreate.swift | 42 + .../ContainerCLI/Network/NetworkDelete.swift | 116 +++ .../ContainerCLI/Network/NetworkInspect.swift | 44 + .../ContainerCLI/Network/NetworkList.swift | 107 +++ Sources/ContainerCLI/Registry/Login.swift | 92 +++ Sources/ContainerCLI/Registry/Logout.swift | 39 + .../Registry/RegistryCommand.swift | 32 + .../Registry/RegistryDefault.swift | 98 +++ Sources/ContainerCLI/RunCommand.swift | 317 ++++++++ .../ContainerCLI/System/DNS/DNSCreate.swift | 51 ++ .../ContainerCLI/System/DNS/DNSDefault.swift | 72 ++ .../ContainerCLI/System/DNS/DNSDelete.swift | 49 ++ Sources/ContainerCLI/System/DNS/DNSList.swift | 36 + .../System/Kernel/KernelSet.swift | 114 +++ .../ContainerCLI/System/SystemCommand.swift | 35 + Sources/ContainerCLI/System/SystemDNS.swift | 34 + .../ContainerCLI/System/SystemKernel.swift | 29 + Sources/ContainerCLI/System/SystemLogs.swift | 82 ++ Sources/ContainerCLI/System/SystemStart.swift | 170 ++++ .../ContainerCLI/System/SystemStatus.swift | 52 ++ Sources/ContainerCLI/System/SystemStop.swift | 91 +++ 78 files changed, 6775 insertions(+) delete mode 100644 Sources/ContainerCLI create mode 100644 Sources/ContainerCLI/Application.swift create mode 100644 Sources/ContainerCLI/BuildCommand.swift create mode 100644 Sources/ContainerCLI/Builder/Builder.swift create mode 100644 Sources/ContainerCLI/Builder/BuilderDelete.swift create mode 100644 Sources/ContainerCLI/Builder/BuilderStart.swift create mode 100644 Sources/ContainerCLI/Builder/BuilderStatus.swift create mode 100644 Sources/ContainerCLI/Builder/BuilderStop.swift create mode 100644 Sources/ContainerCLI/Codable+JSON.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Build.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Config.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Network.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Secret.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Service.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift create mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Volume.swift create mode 100644 Sources/ContainerCLI/Compose/Commands/ComposeDown.swift create mode 100644 Sources/ContainerCLI/Compose/Commands/ComposeUp.swift create mode 100644 Sources/ContainerCLI/Compose/ComposeCommand.swift create mode 100644 Sources/ContainerCLI/Compose/Errors.swift create mode 100644 Sources/ContainerCLI/Compose/Helper Functions.swift create mode 100644 Sources/ContainerCLI/Container/ContainerCreate.swift create mode 100644 Sources/ContainerCLI/Container/ContainerDelete.swift create mode 100644 Sources/ContainerCLI/Container/ContainerExec.swift create mode 100644 Sources/ContainerCLI/Container/ContainerInspect.swift create mode 100644 Sources/ContainerCLI/Container/ContainerKill.swift create mode 100644 Sources/ContainerCLI/Container/ContainerList.swift create mode 100644 Sources/ContainerCLI/Container/ContainerLogs.swift create mode 100644 Sources/ContainerCLI/Container/ContainerStart.swift create mode 100644 Sources/ContainerCLI/Container/ContainerStop.swift create mode 100644 Sources/ContainerCLI/Container/ContainersCommand.swift create mode 100644 Sources/ContainerCLI/Container/ProcessUtils.swift create mode 100644 Sources/ContainerCLI/DefaultCommand.swift create mode 100644 Sources/ContainerCLI/Image/ImageInspect.swift create mode 100644 Sources/ContainerCLI/Image/ImageList.swift create mode 100644 Sources/ContainerCLI/Image/ImageLoad.swift create mode 100644 Sources/ContainerCLI/Image/ImagePrune.swift create mode 100644 Sources/ContainerCLI/Image/ImagePull.swift create mode 100644 Sources/ContainerCLI/Image/ImagePush.swift create mode 100644 Sources/ContainerCLI/Image/ImageRemove.swift create mode 100644 Sources/ContainerCLI/Image/ImageSave.swift create mode 100644 Sources/ContainerCLI/Image/ImageTag.swift create mode 100644 Sources/ContainerCLI/Image/ImagesCommand.swift create mode 100644 Sources/ContainerCLI/Network/NetworkCommand.swift create mode 100644 Sources/ContainerCLI/Network/NetworkCreate.swift create mode 100644 Sources/ContainerCLI/Network/NetworkDelete.swift create mode 100644 Sources/ContainerCLI/Network/NetworkInspect.swift create mode 100644 Sources/ContainerCLI/Network/NetworkList.swift create mode 100644 Sources/ContainerCLI/Registry/Login.swift create mode 100644 Sources/ContainerCLI/Registry/Logout.swift create mode 100644 Sources/ContainerCLI/Registry/RegistryCommand.swift create mode 100644 Sources/ContainerCLI/Registry/RegistryDefault.swift create mode 100644 Sources/ContainerCLI/RunCommand.swift create mode 100644 Sources/ContainerCLI/System/DNS/DNSCreate.swift create mode 100644 Sources/ContainerCLI/System/DNS/DNSDefault.swift create mode 100644 Sources/ContainerCLI/System/DNS/DNSDelete.swift create mode 100644 Sources/ContainerCLI/System/DNS/DNSList.swift create mode 100644 Sources/ContainerCLI/System/Kernel/KernelSet.swift create mode 100644 Sources/ContainerCLI/System/SystemCommand.swift create mode 100644 Sources/ContainerCLI/System/SystemDNS.swift create mode 100644 Sources/ContainerCLI/System/SystemKernel.swift create mode 100644 Sources/ContainerCLI/System/SystemLogs.swift create mode 100644 Sources/ContainerCLI/System/SystemStart.swift create mode 100644 Sources/ContainerCLI/System/SystemStatus.swift create mode 100644 Sources/ContainerCLI/System/SystemStop.swift diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI deleted file mode 100644 index ce9b7e01c3c1838dc5198f48c2a927a02ac3d673..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 916 zcmZvayG{a85Qfis#YR^#YUjq-5|G=16&H;fO)L~bVS~#OiNKOYV`GfI0TyN?bbnot|+K=eGCL%gLVKhi6YPCaIXpJU4ma;2bxp~)HIHT!3#7@5p{B1!!f z3@u+Mn(JP#*YOcAWsUs<%^#NUE5uXa0=50~ynH?1!C$5Qs80lfn+cz;dxCm2=n2O4 zkSCh-M?Hz8KN1eDg*L(oU7r1h_CRm$;gw?4%Zru1gMxR9ZIv)~5v z9Jt2p2G^N2@GG+qEc+`>+(C}df{}7;u8Em&Txk#LRj~ZNi|@U=OB+_eE*{P=_%T-2 diff --git a/Sources/ContainerCLI/Application.swift b/Sources/ContainerCLI/Application.swift new file mode 100644 index 000000000..ba002a74c --- /dev/null +++ b/Sources/ContainerCLI/Application.swift @@ -0,0 +1,335 @@ +//===----------------------------------------------------------------------===// +// 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 CVersion +import ContainerClient +import ContainerLog +import ContainerPlugin +import ContainerizationError +import ContainerizationOS +import Foundation +import Logging +import TerminalProgress + +// `log` is updated only once in the `validate()` method. +nonisolated(unsafe) var log = { + LoggingSystem.bootstrap { label in + OSLogHandler( + label: label, + category: "CLI" + ) + } + var log = Logger(label: "com.apple.container") + log.logLevel = .debug + return log +}() + +@main +public struct Application: AsyncParsableCommand { + public init() {} + + @OptionGroup + var global: Flags.Global + + public static let configuration = CommandConfiguration( + commandName: "container", + abstract: "A container platform for macOS", + version: releaseVersion(), + subcommands: [ + DefaultCommand.self + ], + groupedSubcommands: [ + CommandGroup( + name: "Container", + subcommands: [ + ComposeCommand.self, + ContainerCreate.self, + ContainerDelete.self, + ContainerExec.self, + ContainerInspect.self, + ContainerKill.self, + ContainerList.self, + ContainerLogs.self, + ContainerRunCommand.self, + ContainerStart.self, + ContainerStop.self, + ] + ), + CommandGroup( + name: "Image", + subcommands: [ + BuildCommand.self, + ImagesCommand.self, + RegistryCommand.self, + ] + ), + CommandGroup( + name: "Other", + subcommands: Self.otherCommands() + ), + ], + // Hidden command to handle plugins on unrecognized input. + defaultSubcommand: DefaultCommand.self + ) + + static let appRoot: URL = { + FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + .appendingPathComponent("com.apple.container") + }() + + static let pluginLoader: PluginLoader = { + // create user-installed plugins directory if it doesn't exist + let pluginsURL = PluginLoader.userPluginsDir(root: Self.appRoot) + try! FileManager.default.createDirectory(at: pluginsURL, withIntermediateDirectories: true) + let pluginDirectories = [ + pluginsURL + ] + let pluginFactories = [ + DefaultPluginFactory() + ] + + let statePath = PluginLoader.defaultPluginResourcePath(root: Self.appRoot) + try! FileManager.default.createDirectory(at: statePath, withIntermediateDirectories: true) + return PluginLoader(pluginDirectories: pluginDirectories, pluginFactories: pluginFactories, defaultResourcePath: statePath, log: log) + }() + + public static func main() async throws { + restoreCursorAtExit() + + #if DEBUG + let warning = "Running debug build. Performance may be degraded." + let formattedWarning = "\u{001B}[33mWarning!\u{001B}[0m \(warning)\n" + let warningData = Data(formattedWarning.utf8) + FileHandle.standardError.write(warningData) + #endif + + let fullArgs = CommandLine.arguments + let args = Array(fullArgs.dropFirst()) + + do { + // container -> defaultHelpCommand + var command = try Application.parseAsRoot(args) + if var asyncCommand = command as? AsyncParsableCommand { + try await asyncCommand.run() + } else { + try command.run() + } + } catch { + // Regular ol `command` with no args will get caught by DefaultCommand. --help + // on the root command will land here. + let containsHelp = fullArgs.contains("-h") || fullArgs.contains("--help") + if fullArgs.count <= 2 && containsHelp { + Self.printModifiedHelpText() + return + } + let errorAsString: String = String(describing: error) + if errorAsString.contains("XPC connection error") { + let modifiedError = ContainerizationError(.interrupted, message: "\(error)\nEnsure container system service has been started with `container system start`.") + Application.exit(withError: modifiedError) + } else { + Application.exit(withError: error) + } + } + } + + static func handleProcess(io: ProcessIO, process: ClientProcess) async throws -> Int32 { + let signals = AsyncSignalHandler.create(notify: Application.signalSet) + return try await withThrowingTaskGroup(of: Int32?.self, returning: Int32.self) { group in + let waitAdded = group.addTaskUnlessCancelled { + let code = try await process.wait() + try await io.wait() + return code + } + + guard waitAdded else { + group.cancelAll() + return -1 + } + + try await process.start(io.stdio) + defer { + try? io.close() + } + try io.closeAfterStart() + + if let current = io.console { + let size = try current.size + // It's supremely possible the process could've exited already. We shouldn't treat + // this as fatal. + try? await process.resize(size) + _ = group.addTaskUnlessCancelled { + let winchHandler = AsyncSignalHandler.create(notify: [SIGWINCH]) + for await _ in winchHandler.signals { + do { + try await process.resize(try current.size) + } catch { + log.error( + "failed to send terminal resize event", + metadata: [ + "error": "\(error)" + ] + ) + } + } + return nil + } + } else { + _ = group.addTaskUnlessCancelled { + for await sig in signals.signals { + do { + try await process.kill(sig) + } catch { + log.error( + "failed to send signal", + metadata: [ + "signal": "\(sig)", + "error": "\(error)", + ] + ) + } + } + return nil + } + } + + while true { + let result = try await group.next() + if result == nil { + return -1 + } + let status = result! + if let status { + group.cancelAll() + return status + } + } + return -1 + } + } + + public func validate() throws { + // Not really a "validation", but a cheat to run this before + // any of the commands do their business. + let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"] + if self.global.debug || debugEnvVar != nil { + log.logLevel = .debug + } + // Ensure we're not running under Rosetta. + if try isTranslated() { + throw ValidationError( + """ + `container` is currently running under Rosetta Translation, which could be + caused by your terminal application. Please ensure this is turned off. + """ + ) + } + } + + private static func otherCommands() -> [any ParsableCommand.Type] { + guard #available(macOS 26, *) else { + return [ + BuilderCommand.self, + SystemCommand.self, + ] + } + + return [ + BuilderCommand.self, + NetworkCommand.self, + SystemCommand.self, + ] + } + + private static func restoreCursorAtExit() { + let signalHandler: @convention(c) (Int32) -> Void = { signal in + let exitCode = ExitCode(signal + 128) + Application.exit(withError: exitCode) + } + // Termination by Ctrl+C. + signal(SIGINT, signalHandler) + // Termination using `kill`. + signal(SIGTERM, signalHandler) + // Normal and explicit exit. + atexit { + if let progressConfig = try? ProgressConfig() { + let progressBar = ProgressBar(config: progressConfig) + progressBar.resetCursor() + } + } + } +} + +extension Application { + // Because we support plugins, we need to modify the help text to display + // any if we found some. + static func printModifiedHelpText() { + let altered = Self.pluginLoader.alterCLIHelpText( + original: Application.helpMessage(for: Application.self) + ) + print(altered) + } + + enum ListFormat: String, CaseIterable, ExpressibleByArgument { + case json + case table + } + + static let signalSet: [Int32] = [ + SIGTERM, + SIGINT, + SIGUSR1, + SIGUSR2, + SIGWINCH, + ] + + func isTranslated() throws -> Bool { + do { + return try Sysctl.byName("sysctl.proc_translated") == 1 + } catch let posixErr as POSIXError { + if posixErr.code == .ENOENT { + return false + } + throw posixErr + } + } + + private static func releaseVersion() -> String { + var versionDetails: [String: String] = ["build": "release"] + #if DEBUG + versionDetails["build"] = "debug" + #endif + let gitCommit = { + let sha = get_git_commit().map { String(cString: $0) } + guard let sha else { + return "unspecified" + } + return String(sha.prefix(7)) + }() + versionDetails["commit"] = gitCommit + let extras: String = versionDetails.map { "\($0): \($1)" }.sorted().joined(separator: ", ") + + let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) + let releaseVersion = bundleVersion ?? get_release_version().map { String(cString: $0) } ?? "0.0.0" + + return "container CLI version \(releaseVersion) (\(extras))" + } +} diff --git a/Sources/ContainerCLI/BuildCommand.swift b/Sources/ContainerCLI/BuildCommand.swift new file mode 100644 index 000000000..a1e84c258 --- /dev/null +++ b/Sources/ContainerCLI/BuildCommand.swift @@ -0,0 +1,313 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerBuild +import ContainerClient +import ContainerImagesServiceClient +import Containerization +import ContainerizationError +import ContainerizationOCI +import ContainerizationOS +import Foundation +import NIO +import TerminalProgress + +extension Application { + struct BuildCommand: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "build" + config.abstract = "Build an image from a Dockerfile" + config._superCommandName = "container" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") + public var cpus: Int64 = 2 + + @Option( + name: [.customLong("memory"), .customShort("m")], + help: + "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" + ) + var memory: String = "2048MB" + + @Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val")) + var buildArg: [String] = [] + + @Argument(help: "Build directory") + var contextDir: String = "." + + @Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path")) + var file: String = "Dockerfile" + + @Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val")) + var label: [String] = [] + + @Flag(name: .long, help: "Do not use cache") + var noCache: Bool = false + + @Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build", valueName: "value")) + var output: [String] = { + ["type=oci"] + }() + + @Option(name: .long, help: ArgumentHelp("Cache imports for the build", valueName: "value", visibility: .hidden)) + var cacheIn: [String] = { + [] + }() + + @Option(name: .long, help: ArgumentHelp("Cache exports for the build", valueName: "value", visibility: .hidden)) + var cacheOut: [String] = { + [] + }() + + @Option(name: .long, help: ArgumentHelp("set the build architecture", valueName: "value")) + var arch: [String] = { + ["arm64"] + }() + + @Option(name: .long, help: ArgumentHelp("set the build os", valueName: "value")) + var os: [String] = { + ["linux"] + }() + + @Option(name: .long, help: ArgumentHelp("Progress type - one of [auto|plain|tty]", valueName: "type")) + var progress: String = "auto" + + @Option(name: .long, help: ArgumentHelp("Builder-shim vsock port", valueName: "port")) + var vsockPort: UInt32 = 8088 + + @Option(name: [.customShort("t"), .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) + var targetImageName: String = UUID().uuidString.lowercased() + + @Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage")) + var target: String = "" + + @Flag(name: .shortAndLong, help: "Suppress build output") + var quiet: Bool = false + + func run() async throws { + do { + let timeout: Duration = .seconds(300) + let progressConfig = try ProgressConfig( + showTasks: true, + showItems: true + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + progress.set(description: "Dialing builder") + + let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { group in + defer { + group.cancelAll() + } + + group.addTask { + while true { + do { + let container = try await ClientContainer.get(id: "buildkit") + let fh = try await container.dial(self.vsockPort) + + let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + let b = try Builder(socket: fh, group: threadGroup) + + // If this call succeeds, then BuildKit is running. + let _ = try await b.info() + return b + } catch { + // If we get here, "Dialing builder" is shown for such a short period + // of time that it's invisible to the user. + progress.set(tasks: 0) + progress.set(totalTasks: 3) + + try await BuilderStart.start( + cpus: self.cpus, + memory: self.memory, + progressUpdate: progress.handler + ) + + // wait (seconds) for builder to start listening on vsock + try await Task.sleep(for: .seconds(5)) + continue + } + } + } + + group.addTask { + try await Task.sleep(for: timeout) + throw ValidationError( + """ + Timeout waiting for connection to builder + """ + ) + } + + return try await group.next() + } + + guard let builder else { + throw ValidationError("builder is not running") + } + + let dockerfile = try Data(contentsOf: URL(filePath: file)) + let exportPath = Application.appRoot.appendingPathComponent(".build") + + let buildID = UUID().uuidString + let tempURL = exportPath.appendingPathComponent(buildID) + try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil) + defer { + try? FileManager.default.removeItem(at: tempURL) + } + + let imageName: String = try { + let parsedReference = try Reference.parse(targetImageName) + parsedReference.normalize() + return parsedReference.description + }() + + var terminal: Terminal? + switch self.progress { + case "tty": + terminal = try Terminal(descriptor: STDERR_FILENO) + case "auto": + terminal = try? Terminal(descriptor: STDERR_FILENO) + case "plain": + terminal = nil + default: + throw ContainerizationError(.invalidArgument, message: "invalid progress mode \(self.progress)") + } + + defer { terminal?.tryReset() } + + let exports: [Builder.BuildExport] = try output.map { output in + var exp = try Builder.BuildExport(from: output) + if exp.destination == nil { + exp.destination = tempURL.appendingPathComponent("out.tar") + } + return exp + } + + try await withThrowingTaskGroup(of: Void.self) { [terminal] group in + defer { + group.cancelAll() + } + group.addTask { + let handler = AsyncSignalHandler.create(notify: [SIGTERM, SIGINT, SIGUSR1, SIGUSR2]) + for await sig in handler.signals { + throw ContainerizationError(.interrupted, message: "exiting on signal \(sig)") + } + } + let platforms: [Platform] = try { + var results: [Platform] = [] + for o in self.os { + for a in self.arch { + guard let platform = try? Platform(from: "\(o)/\(a)") else { + throw ValidationError("invalid os/architecture combination \(o)/\(a)") + } + results.append(platform) + } + } + return results + }() + group.addTask { [terminal] in + let config = ContainerBuild.Builder.BuildConfig( + buildID: buildID, + contentStore: RemoteContentStoreClient(), + buildArgs: buildArg, + contextDir: contextDir, + dockerfile: dockerfile, + labels: label, + noCache: noCache, + platforms: platforms, + terminal: terminal, + tag: imageName, + target: target, + quiet: quiet, + exports: exports, + cacheIn: cacheIn, + cacheOut: cacheOut + ) + progress.finish() + + try await builder.build(config) + } + + try await group.next() + } + + let unpackProgressConfig = try ProgressConfig( + description: "Unpacking built image", + itemsName: "entries", + showTasks: exports.count > 1, + totalTasks: exports.count + ) + let unpackProgress = ProgressBar(config: unpackProgressConfig) + defer { + unpackProgress.finish() + } + unpackProgress.start() + + let taskManager = ProgressTaskCoordinator() + // Currently, only a single export can be specified. + for exp in exports { + unpackProgress.add(tasks: 1) + let unpackTask = await taskManager.startTask() + switch exp.type { + case "oci": + try Task.checkCancellation() + guard let dest = exp.destination else { + throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)") + } + let loaded = try await ClientImage.load(from: dest.absolutePath()) + + for image in loaded { + try Task.checkCancellation() + try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler)) + } + case "tar": + break + default: + throw ContainerizationError(.invalidArgument, message: "invalid exporter \(exp.rawValue)") + } + } + await taskManager.finish() + unpackProgress.finish() + print("Successfully built \(imageName)") + } catch { + throw NSError(domain: "Build", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(error)"]) + } + } + + func validate() throws { + guard FileManager.default.fileExists(atPath: file) else { + throw ValidationError("Dockerfile does not exist at path: \(file)") + } + guard FileManager.default.fileExists(atPath: contextDir) else { + throw ValidationError("context dir does not exist \(contextDir)") + } + guard let _ = try? Reference.parse(targetImageName) else { + throw ValidationError("invalid reference \(targetImageName)") + } + } + } +} diff --git a/Sources/ContainerCLI/Builder/Builder.swift b/Sources/ContainerCLI/Builder/Builder.swift new file mode 100644 index 000000000..ad9eb6c97 --- /dev/null +++ b/Sources/ContainerCLI/Builder/Builder.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct BuilderCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "builder", + abstract: "Manage an image builder instance", + subcommands: [ + BuilderStart.self, + BuilderStatus.self, + BuilderStop.self, + BuilderDelete.self, + ]) + } +} diff --git a/Sources/ContainerCLI/Builder/BuilderDelete.swift b/Sources/ContainerCLI/Builder/BuilderDelete.swift new file mode 100644 index 000000000..e848da95e --- /dev/null +++ b/Sources/ContainerCLI/Builder/BuilderDelete.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct BuilderDelete: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "delete" + config._superCommandName = "builder" + config.abstract = "Delete builder" + config.usage = "\n\t builder delete [command options]" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Flag(name: .shortAndLong, help: "Force delete builder even if it is running") + var force = false + + func run() async throws { + do { + let container = try await ClientContainer.get(id: "buildkit") + if container.status != .stopped { + guard force else { + throw ContainerizationError(.invalidState, message: "BuildKit container is not stopped, use --force to override") + } + try await container.stop() + } + try await container.delete() + } catch { + if error is ContainerizationError { + if (error as? ContainerizationError)?.code == .notFound { + return + } + } + throw error + } + } + } +} diff --git a/Sources/ContainerCLI/Builder/BuilderStart.swift b/Sources/ContainerCLI/Builder/BuilderStart.swift new file mode 100644 index 000000000..5800b712e --- /dev/null +++ b/Sources/ContainerCLI/Builder/BuilderStart.swift @@ -0,0 +1,262 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerBuild +import ContainerClient +import ContainerNetworkService +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Foundation +import TerminalProgress + +extension Application { + struct BuilderStart: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "start" + config._superCommandName = "builder" + config.abstract = "Start builder" + config.usage = "\nbuilder start [command options]" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") + public var cpus: Int64 = 2 + + @Option( + name: [.customLong("memory"), .customShort("m")], + help: + "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" + ) + public var memory: String = "2048MB" + + func run() async throws { + let progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + totalTasks: 4 + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + try await Self.start(cpus: self.cpus, memory: self.memory, progressUpdate: progress.handler) + progress.finish() + } + + static func start(cpus: Int64?, memory: String?, progressUpdate: @escaping ProgressUpdateHandler) async throws { + await progressUpdate([ + .setDescription("Fetching BuildKit image"), + .setItemsName("blobs"), + ]) + let taskManager = ProgressTaskCoordinator() + let fetchTask = await taskManager.startTask() + + let builderImage: String = ClientDefaults.get(key: .defaultBuilderImage) + let exportsMount: String = Application.appRoot.appendingPathComponent(".build").absolutePath() + + if !FileManager.default.fileExists(atPath: exportsMount) { + try FileManager.default.createDirectory( + atPath: exportsMount, + withIntermediateDirectories: true, + attributes: nil + ) + } + + let builderPlatform = ContainerizationOCI.Platform(arch: "arm64", os: "linux", variant: "v8") + + let existingContainer = try? await ClientContainer.get(id: "buildkit") + if let existingContainer { + let existingImage = existingContainer.configuration.image.reference + let existingResources = existingContainer.configuration.resources + + // Check if we need to recreate the builder due to different image + let imageChanged = existingImage != builderImage + let cpuChanged = { + if let cpus { + if existingResources.cpus != cpus { + return true + } + } + return false + }() + let memChanged = try { + if let memory { + let memoryInBytes = try Parser.resources(cpus: nil, memory: memory).memoryInBytes + if existingResources.memoryInBytes != memoryInBytes { + return true + } + } + return false + }() + + switch existingContainer.status { + case .running: + guard imageChanged || cpuChanged || memChanged else { + // If image, mem and cpu are the same, continue using the existing builder + return + } + // If they changed, stop and delete the existing builder + try await existingContainer.stop() + try await existingContainer.delete() + case .stopped: + // If the builder is stopped and matches our requirements, start it + // Otherwise, delete it and create a new one + guard imageChanged || cpuChanged || memChanged else { + try await existingContainer.startBuildKit(progressUpdate, nil) + return + } + try await existingContainer.delete() + case .stopping: + throw ContainerizationError( + .invalidState, + message: "builder is stopping, please wait until it is fully stopped before proceeding" + ) + case .unknown: + break + } + } + + let shimArguments: [String] = [ + "--debug", + "--vsock", + ] + + let id = "buildkit" + try ContainerClient.Utility.validEntityName(id) + + let processConfig = ProcessConfiguration( + executable: "/usr/local/bin/container-builder-shim", + arguments: shimArguments, + environment: [], + workingDirectory: "/", + terminal: false, + user: .id(uid: 0, gid: 0) + ) + + let resources = try Parser.resources( + cpus: cpus, + memory: memory + ) + + let image = try await ClientImage.fetch( + reference: builderImage, + platform: builderPlatform, + progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate) + ) + // Unpack fetched image before use + await progressUpdate([ + .setDescription("Unpacking BuildKit image"), + .setItemsName("entries"), + ]) + + let unpackTask = await taskManager.startTask() + _ = try await image.getCreateSnapshot( + platform: builderPlatform, + progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate) + ) + let imageConfig = ImageDescription( + reference: builderImage, + descriptor: image.descriptor + ) + + var config = ContainerConfiguration(id: id, image: imageConfig, process: processConfig) + config.resources = resources + config.mounts = [ + .init( + type: .tmpfs, + source: "", + destination: "/run", + options: [] + ), + .init( + type: .virtiofs, + source: exportsMount, + destination: "/var/lib/container-builder-shim/exports", + options: [] + ), + ] + // Enable Rosetta only if the user didn't ask to disable it + config.rosetta = ClientDefaults.getBool(key: .buildRosetta) ?? true + + let network = try await ClientNetwork.get(id: ClientNetwork.defaultNetworkName) + guard case .running(_, let networkStatus) = network else { + throw ContainerizationError(.invalidState, message: "default network is not running") + } + config.networks = [network.id] + let subnet = try CIDRAddress(networkStatus.address) + let nameserver = IPv4Address(fromValue: subnet.lower.value + 1).description + let nameservers = [nameserver] + config.dns = ContainerConfiguration.DNSConfiguration(nameservers: nameservers) + + let kernel = try await { + await progressUpdate([ + .setDescription("Fetching kernel"), + .setItemsName("binary"), + ]) + + let kernel = try await ClientKernel.getDefaultKernel(for: .current) + return kernel + }() + + await progressUpdate([ + .setDescription("Starting BuildKit container") + ]) + + let container = try await ClientContainer.create( + configuration: config, + options: .default, + kernel: kernel + ) + + try await container.startBuildKit(progressUpdate, taskManager) + } + } +} + +// MARK: - ClientContainer Extension for BuildKit + +extension ClientContainer { + /// Starts the BuildKit process within the container + /// This method handles bootstrapping the container and starting the BuildKit process + fileprivate func startBuildKit(_ progress: @escaping ProgressUpdateHandler, _ taskManager: ProgressTaskCoordinator? = nil) async throws { + do { + let io = try ProcessIO.create( + tty: false, + interactive: false, + detach: true + ) + defer { try? io.close() } + let process = try await bootstrap() + _ = try await process.start(io.stdio) + await taskManager?.finish() + try io.closeAfterStart() + log.debug("starting BuildKit and BuildKit-shim") + } catch { + try? await stop() + try? await delete() + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to start BuildKit: \(error)") + } + } +} diff --git a/Sources/ContainerCLI/Builder/BuilderStatus.swift b/Sources/ContainerCLI/Builder/BuilderStatus.swift new file mode 100644 index 000000000..b1210a3dd --- /dev/null +++ b/Sources/ContainerCLI/Builder/BuilderStatus.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct BuilderStatus: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "status" + config._superCommandName = "builder" + config.abstract = "Print builder status" + config.usage = "\n\t builder status [command options]" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Flag(name: .long, help: ArgumentHelp("Display detailed status in json format")) + var json: Bool = false + + func run() async throws { + do { + let container = try await ClientContainer.get(id: "buildkit") + if json { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let jsonData = try encoder.encode(container) + + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw ContainerizationError(.internalError, message: "failed to encode BuildKit container as json") + } + print(jsonString) + return + } + + let image = container.configuration.image.reference + let resources = container.configuration.resources + let cpus = resources.cpus + let memory = resources.memoryInBytes / (1024 * 1024) // bytes to MB + let addr = "" + + print("ID IMAGE STATE ADDR CPUS MEMORY") + print("\(container.id) \(image) \(container.status.rawValue.uppercased()) \(addr) \(cpus) \(memory) MB") + } catch { + if error is ContainerizationError { + if (error as? ContainerizationError)?.code == .notFound { + print("builder is not running") + return + } + } + throw error + } + } + } +} diff --git a/Sources/ContainerCLI/Builder/BuilderStop.swift b/Sources/ContainerCLI/Builder/BuilderStop.swift new file mode 100644 index 000000000..e7484c9c1 --- /dev/null +++ b/Sources/ContainerCLI/Builder/BuilderStop.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct BuilderStop: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "stop" + config._superCommandName = "builder" + config.abstract = "Stop builder" + config.usage = "\n\t builder stop" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + func run() async throws { + do { + let container = try await ClientContainer.get(id: "buildkit") + try await container.stop() + } catch { + if error is ContainerizationError { + if (error as? ContainerizationError)?.code == .notFound { + print("builder is not running") + return + } + } + throw error + } + } + } +} diff --git a/Sources/ContainerCLI/Codable+JSON.swift b/Sources/ContainerCLI/Codable+JSON.swift new file mode 100644 index 000000000..60cbd04d7 --- /dev/null +++ b/Sources/ContainerCLI/Codable+JSON.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension [any Codable] { + func jsonArray() throws -> String { + "[\(try self.map { String(data: try JSONEncoder().encode($0), encoding: .utf8)! }.joined(separator: ","))]" + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Build.swift b/Sources/ContainerCLI/Compose/Codable Structs/Build.swift new file mode 100644 index 000000000..5dc9a7ffa --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Build.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Build.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents the `build` configuration for a service. +struct Build: Codable, Hashable { + /// Path to the build context + let context: String + /// Optional path to the Dockerfile within the context + let dockerfile: String? + /// Build arguments + let args: [String: String]? + + /// Custom initializer to handle `build: .` (string) or `build: { context: . }` (object) + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let contextString = try? container.decode(String.self) { + self.context = contextString + self.dockerfile = nil + self.args = nil + } else { + let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) + self.context = try keyedContainer.decode(String.self, forKey: .context) + self.dockerfile = try keyedContainer.decodeIfPresent(String.self, forKey: .dockerfile) + self.args = try keyedContainer.decodeIfPresent([String: String].self, forKey: .args) + } + } + + enum CodingKeys: String, CodingKey { + case context, dockerfile, args + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Config.swift b/Sources/ContainerCLI/Compose/Codable Structs/Config.swift new file mode 100644 index 000000000..6b982bfdb --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Config.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Config.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level config definition (primarily for Swarm). +struct Config: Codable { + /// Path to the file containing the config content + let file: String? + /// Indicates if the config is external (pre-existing) + let external: ExternalConfig? + /// Explicit name for the config + let name: String? + /// Labels for the config + let labels: [String: String]? + + enum CodingKeys: String, CodingKey { + case file, external, name, labels + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_cfg" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + file = try container.decodeIfPresent(String.self, forKey: .file) + name = try container.decodeIfPresent(String.self, forKey: .name) + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalConfig(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalConfig(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift b/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift new file mode 100644 index 000000000..d30f9ffa8 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift @@ -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. +//===----------------------------------------------------------------------===// + +// +// Deploy.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents the `deploy` configuration for a service (primarily for Swarm orchestration). +struct Deploy: Codable, Hashable { + /// Deployment mode (e.g., 'replicated', 'global') + let mode: String? + /// Number of replicated service tasks + let replicas: Int? + /// Resource constraints (limits, reservations) + let resources: DeployResources? + /// Restart policy for tasks + let restart_policy: DeployRestartPolicy? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift new file mode 100644 index 000000000..370e61a46 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// DeployResources.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Resource constraints for deployment. +struct DeployResources: Codable, Hashable { + /// Hard limits on resources + let limits: ResourceLimits? + /// Guarantees for resources + let reservations: ResourceReservations? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift new file mode 100644 index 000000000..56daa6573 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift @@ -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. +//===----------------------------------------------------------------------===// + +// +// DeployRestartPolicy.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Restart policy for deployed tasks. +struct DeployRestartPolicy: Codable, Hashable { + /// Condition to restart on (e.g., 'on-failure', 'any') + let condition: String? + /// Delay before attempting restart + let delay: String? + /// Maximum number of restart attempts + let max_attempts: Int? + /// Window to evaluate restart policy + let window: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift new file mode 100644 index 000000000..47a58acad --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift @@ -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. +//===----------------------------------------------------------------------===// + +// +// DeviceReservation.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Device reservations for GPUs or other devices. +struct DeviceReservation: Codable, Hashable { + /// Device capabilities + let capabilities: [String]? + /// Device driver + let driver: String? + /// Number of devices + let count: String? + /// Specific device IDs + let device_ids: [String]? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift b/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift new file mode 100644 index 000000000..503d98664 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// DockerCompose.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents the top-level structure of a docker-compose.yml file. +struct DockerCompose: Codable { + /// The Compose file format version (e.g., '3.8') + let version: String? + /// Optional project name + let name: String? + /// Dictionary of service definitions, keyed by service name + let services: [String: Service] + /// Optional top-level volume definitions + let volumes: [String: Volume]? + /// Optional top-level network definitions + let networks: [String: Network]? + /// Optional top-level config definitions (primarily for Swarm) + let configs: [String: Config]? + /// Optional top-level secret definitions (primarily for Swarm) + let secrets: [String: Secret]? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decodeIfPresent(String.self, forKey: .version) + name = try container.decodeIfPresent(String.self, forKey: .name) + services = try container.decode([String: Service].self, forKey: .services) + + if let volumes = try container.decodeIfPresent([String: Optional].self, forKey: .volumes) { + let safeVolumes: [String : Volume] = volumes.mapValues { value in + value ?? Volume() + } + self.volumes = safeVolumes + } else { + self.volumes = nil + } + networks = try container.decodeIfPresent([String: Network].self, forKey: .networks) + configs = try container.decodeIfPresent([String: Config].self, forKey: .configs) + secrets = try container.decodeIfPresent([String: Secret].self, forKey: .secrets) + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift new file mode 100644 index 000000000..d05ccd461 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ExternalConfig.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external config reference. +struct ExternalConfig: Codable { + /// True if the config is external + let isExternal: Bool + /// Optional name of the external config if different from key + let name: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift new file mode 100644 index 000000000..07d6c8ce9 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ExternalNetwork.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external network reference. +struct ExternalNetwork: Codable { + /// True if the network is external + let isExternal: Bool + // Optional name of the external network if different from key + let name: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift new file mode 100644 index 000000000..ce4411362 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ExternalSecret.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external secret reference. +struct ExternalSecret: Codable { + /// True if the secret is external + let isExternal: Bool + /// Optional name of the external secret if different from key + let name: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift new file mode 100644 index 000000000..04cfe4f92 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ExternalVolume.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents an external volume reference. +struct ExternalVolume: Codable { + /// True if the volume is external + let isExternal: Bool + /// Optional name of the external volume if different from key + let name: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift b/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift new file mode 100644 index 000000000..27f5aa912 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Healthcheck.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Healthcheck configuration for a service. +struct Healthcheck: Codable, Hashable { + /// Command to run to check health + let test: [String]? + /// Grace period for the container to start + let start_period: String? + /// How often to run the check + let interval: String? + /// Number of consecutive failures to consider unhealthy + let retries: Int? + /// Timeout for each check + let timeout: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Network.swift b/Sources/ContainerCLI/Compose/Codable Structs/Network.swift new file mode 100644 index 000000000..44752aecc --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Network.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Network.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level network definition. +struct Network: Codable { + /// Network driver (e.g., 'bridge', 'overlay') + let driver: String? + /// Driver-specific options + let driver_opts: [String: String]? + /// Allow standalone containers to attach to this network + let attachable: Bool? + /// Enable IPv6 networking + let enable_ipv6: Bool? + /// RENAMED: from `internal` to `isInternal` to avoid keyword clash + let isInternal: Bool? + /// Labels for the network + let labels: [String: String]? + /// Explicit name for the network + let name: String? + /// Indicates if the network is external (pre-existing) + let external: ExternalNetwork? + + /// Updated CodingKeys to map 'internal' from YAML to 'isInternal' Swift property + enum CodingKeys: String, CodingKey { + case driver, driver_opts, attachable, enable_ipv6, isInternal = "internal", labels, name, external + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_net" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + driver = try container.decodeIfPresent(String.self, forKey: .driver) + driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts) + attachable = try container.decodeIfPresent(Bool.self, forKey: .attachable) + enable_ipv6 = try container.decodeIfPresent(Bool.self, forKey: .enable_ipv6) + isInternal = try container.decodeIfPresent(Bool.self, forKey: .isInternal) // Use isInternal here + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + name = try container.decodeIfPresent(String.self, forKey: .name) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalNetwork(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalNetwork(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift b/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift new file mode 100644 index 000000000..4643d961b --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ResourceLimits.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// CPU and memory limits. +struct ResourceLimits: Codable, Hashable { + /// CPU limit (e.g., "0.5") + let cpus: String? + /// Memory limit (e.g., "512M") + let memory: String? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift b/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift new file mode 100644 index 000000000..26052e6b3 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ResourceReservations.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// **FIXED**: Renamed from `ResourceReservables` to `ResourceReservations` and made `Codable`. +/// CPU and memory reservations. +struct ResourceReservations: Codable, Hashable { + /// CPU reservation (e.g., "0.25") + let cpus: String? + /// Memory reservation (e.g., "256M") + let memory: String? + /// Device reservations for GPUs or other devices + let devices: [DeviceReservation]? +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift b/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift new file mode 100644 index 000000000..ff464c671 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Secret.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level secret definition (primarily for Swarm). +struct Secret: Codable { + /// Path to the file containing the secret content + let file: String? + /// Environment variable to populate with the secret content + let environment: String? + /// Indicates if the secret is external (pre-existing) + let external: ExternalSecret? + /// Explicit name for the secret + let name: String? + /// Labels for the secret + let labels: [String: String]? + + enum CodingKeys: String, CodingKey { + case file, environment, external, name, labels + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_sec" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + file = try container.decodeIfPresent(String.self, forKey: .file) + environment = try container.decodeIfPresent(String.self, forKey: .environment) + name = try container.decodeIfPresent(String.self, forKey: .name) + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalSecret(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalSecret(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Service.swift b/Sources/ContainerCLI/Compose/Codable Structs/Service.swift new file mode 100644 index 000000000..1c5aeb528 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Service.swift @@ -0,0 +1,203 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Service.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + +import Foundation + + +/// Represents a single service definition within the `services` section. +struct Service: Codable, Hashable { + /// Docker image name + let image: String? + + /// Build configuration if the service is built from a Dockerfile + let build: Build? + + /// Deployment configuration (primarily for Swarm) + let deploy: Deploy? + + /// Restart policy (e.g., 'unless-stopped', 'always') + let restart: String? + + /// Healthcheck configuration + let healthcheck: Healthcheck? + + /// List of volume mounts (e.g., "hostPath:containerPath", "namedVolume:/path") + let volumes: [String]? + + /// Environment variables to set in the container + let environment: [String: String]? + + /// List of .env files to load environment variables from + let env_file: [String]? + + /// Port mappings (e.g., "hostPort:containerPort") + let ports: [String]? + + /// Command to execute in the container, overriding the image's default + let command: [String]? + + /// Services this service depends on (for startup order) + let depends_on: [String]? + + /// User or UID to run the container as + let user: String? + + /// Explicit name for the container instance + let container_name: String? + + /// List of networks the service will connect to + let networks: [String]? + + /// Container hostname + let hostname: String? + + /// Entrypoint to execute in the container, overriding the image's default + let entrypoint: [String]? + + /// Run container in privileged mode + let privileged: Bool? + + /// Mount container's root filesystem as read-only + let read_only: Bool? + + /// Working directory inside the container + let working_dir: String? + + /// Platform architecture for the service + let platform: String? + + /// Service-specific config usage (primarily for Swarm) + let configs: [ServiceConfig]? + + /// Service-specific secret usage (primarily for Swarm) + let secrets: [ServiceSecret]? + + /// Keep STDIN open (-i flag for `container run`) + let stdin_open: Bool? + + /// Allocate a pseudo-TTY (-t flag for `container run`) + let tty: Bool? + + /// Other services that depend on this service + var dependedBy: [String] = [] + + // Defines custom coding keys to map YAML keys to Swift properties + enum CodingKeys: String, CodingKey { + case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, + container_name, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform + } + + /// Custom initializer to handle decoding and basic validation. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + image = try container.decodeIfPresent(String.self, forKey: .image) + build = try container.decodeIfPresent(Build.self, forKey: .build) + deploy = try container.decodeIfPresent(Deploy.self, forKey: .deploy) + + // Ensure that a service has either an image or a build context. + guard image != nil || build != nil else { + throw DecodingError.dataCorruptedError(forKey: .image, in: container, debugDescription: "Service must have either 'image' or 'build' specified.") + } + + restart = try container.decodeIfPresent(String.self, forKey: .restart) + healthcheck = try container.decodeIfPresent(Healthcheck.self, forKey: .healthcheck) + volumes = try container.decodeIfPresent([String].self, forKey: .volumes) + environment = try container.decodeIfPresent([String: String].self, forKey: .environment) + env_file = try container.decodeIfPresent([String].self, forKey: .env_file) + ports = try container.decodeIfPresent([String].self, forKey: .ports) + + // Decode 'command' which can be either a single string or an array of strings. + if let cmdArray = try? container.decodeIfPresent([String].self, forKey: .command) { + command = cmdArray + } else if let cmdString = try? container.decodeIfPresent(String.self, forKey: .command) { + command = [cmdString] + } else { + command = nil + } + + depends_on = try container.decodeIfPresent([String].self, forKey: .depends_on) + user = try container.decodeIfPresent(String.self, forKey: .user) + + container_name = try container.decodeIfPresent(String.self, forKey: .container_name) + networks = try container.decodeIfPresent([String].self, forKey: .networks) + hostname = try container.decodeIfPresent(String.self, forKey: .hostname) + + // Decode 'entrypoint' which can be either a single string or an array of strings. + if let entrypointArray = try? container.decodeIfPresent([String].self, forKey: .entrypoint) { + entrypoint = entrypointArray + } else if let entrypointString = try? container.decodeIfPresent(String.self, forKey: .entrypoint) { + entrypoint = [entrypointString] + } else { + entrypoint = nil + } + + privileged = try container.decodeIfPresent(Bool.self, forKey: .privileged) + read_only = try container.decodeIfPresent(Bool.self, forKey: .read_only) + working_dir = try container.decodeIfPresent(String.self, forKey: .working_dir) + configs = try container.decodeIfPresent([ServiceConfig].self, forKey: .configs) + secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets) + stdin_open = try container.decodeIfPresent(Bool.self, forKey: .stdin_open) + tty = try container.decodeIfPresent(Bool.self, forKey: .tty) + platform = try container.decodeIfPresent(String.self, forKey: .platform) + } + + /// Returns the services in topological order based on `depends_on` relationships. + static func topoSortConfiguredServices( + _ services: [(serviceName: String, service: Service)] + ) throws -> [(serviceName: String, service: Service)] { + + var visited = Set() + var visiting = Set() + var sorted: [(String, Service)] = [] + + func visit(_ name: String, from service: String? = nil) throws { + guard var serviceTuple = services.first(where: { $0.serviceName == name }) else { return } + if let service { + serviceTuple.service.dependedBy.append(service) + } + + if visiting.contains(name) { + throw NSError(domain: "ComposeError", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" + ]) + } + guard !visited.contains(name) else { return } + + visiting.insert(name) + for depName in serviceTuple.service.depends_on ?? [] { + try visit(depName, from: name) + } + visiting.remove(name) + visited.insert(name) + sorted.append(serviceTuple) + } + + for (serviceName, _) in services { + if !visited.contains(serviceName) { + try visit(serviceName) + } + } + + return sorted + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift b/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift new file mode 100644 index 000000000..712d42b7b --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ServiceConfig.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a service's usage of a config. +struct ServiceConfig: Codable, Hashable { + /// Name of the config being used + let source: String + + /// Path in the container where the config will be mounted + let target: String? + + /// User ID for the mounted config file + let uid: String? + + /// Group ID for the mounted config file + let gid: String? + + /// Permissions mode for the mounted config file + let mode: Int? + + /// Custom initializer to handle `config_name` (string) or `{ source: config_name, target: /path }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let sourceName = try? container.decode(String.self) { + self.source = sourceName + self.target = nil + self.uid = nil + self.gid = nil + self.mode = nil + } else { + let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) + self.source = try keyedContainer.decode(String.self, forKey: .source) + self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) + self.uid = try keyedContainer.decodeIfPresent(String.self, forKey: .uid) + self.gid = try keyedContainer.decodeIfPresent(String.self, forKey: .gid) + self.mode = try keyedContainer.decodeIfPresent(Int.self, forKey: .mode) + } + } + + enum CodingKeys: String, CodingKey { + case source, target, uid, gid, mode + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift b/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift new file mode 100644 index 000000000..1849c495c --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ServiceSecret.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a service's usage of a secret. +struct ServiceSecret: Codable, Hashable { + /// Name of the secret being used + let source: String + + /// Path in the container where the secret will be mounted + let target: String? + + /// User ID for the mounted secret file + let uid: String? + + /// Group ID for the mounted secret file + let gid: String? + + /// Permissions mode for the mounted secret file + let mode: Int? + + /// Custom initializer to handle `secret_name` (string) or `{ source: secret_name, target: /path }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let sourceName = try? container.decode(String.self) { + self.source = sourceName + self.target = nil + self.uid = nil + self.gid = nil + self.mode = nil + } else { + let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) + self.source = try keyedContainer.decode(String.self, forKey: .source) + self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) + self.uid = try keyedContainer.decodeIfPresent(String.self, forKey: .uid) + self.gid = try keyedContainer.decodeIfPresent(String.self, forKey: .gid) + self.mode = try keyedContainer.decodeIfPresent(Int.self, forKey: .mode) + } + } + + enum CodingKeys: String, CodingKey { + case source, target, uid, gid, mode + } +} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift b/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift new file mode 100644 index 000000000..b43a1cca5 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Volume.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + + +/// Represents a top-level volume definition. +struct Volume: Codable { + /// Volume driver (e.g., 'local') + let driver: String? + + /// Driver-specific options + let driver_opts: [String: String]? + + /// Explicit name for the volume + let name: String? + + /// Labels for the volume + let labels: [String: String]? + + /// Indicates if the volume is external (pre-existing) + let external: ExternalVolume? + + enum CodingKeys: String, CodingKey { + case driver, driver_opts, name, labels, external + } + + /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_vol" }` (object). + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + driver = try container.decodeIfPresent(String.self, forKey: .driver) + driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts) + name = try container.decodeIfPresent(String.self, forKey: .name) + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) + + if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { + external = ExternalVolume(isExternal: externalBool, name: nil) + } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { + external = ExternalVolume(isExternal: true, name: externalDict["name"]) + } else { + external = nil + } + } + + init(driver: String? = nil, driver_opts: [String : String]? = nil, name: String? = nil, labels: [String : String]? = nil, external: ExternalVolume? = nil) { + self.driver = driver + self.driver_opts = driver_opts + self.name = name + self.labels = labels + self.external = external + } +} diff --git a/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift b/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift new file mode 100644 index 000000000..8993f8ddb --- /dev/null +++ b/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift @@ -0,0 +1,105 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ComposeDown.swift +// Container-Compose +// +// Created by Morris Richman on 6/19/25. +// + +import ArgumentParser +import ContainerClient +import Foundation +import Yams + +extension Application { + public struct ComposeDown: AsyncParsableCommand { + public init() {} + + public static let configuration: CommandConfiguration = .init( + commandName: "down", + abstract: "Stop containers with compose" + ) + + @Argument(help: "Specify the services to stop") + var services: [String] = [] + + @OptionGroup + var process: Flags.Process + + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + + private var fileManager: FileManager { FileManager.default } + private var projectName: String? + + public mutating func run() async throws { + // Read docker-compose.yml content + guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { + throw YamlError.dockerfileNotFound(dockerComposePath) + } + + // Decode the YAML file into the DockerCompose struct + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + + // Determine project name for container naming + if let name = dockerCompose.name { + projectName = name + print("Info: Docker Compose project name parsed as: \(name)") + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + } else { + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") + } + + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) + services = try Service.topoSortConfiguredServices(services) + + // Filter for specified services + if !self.services.isEmpty { + services = services.filter({ serviceName, service in + self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) + }) + } + + try await stopOldStuff(services.map({ $0.serviceName }), remove: false) + } + + private func stopOldStuff(_ services: [String], remove: Bool) async throws { + guard let projectName else { return } + let containers = services.map { "\(projectName)-\($0)" } + + for container in containers { + print("Stopping container: \(container)") + guard let container = try? await ClientContainer.get(id: container) else { continue } + + do { + try await container.stop() + } catch { + } + if remove { + do { + try await container.delete() + } catch { + } + } + } + } + } +} diff --git a/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift b/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift new file mode 100644 index 000000000..6b1053670 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift @@ -0,0 +1,749 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// ComposeUp.swift +// Container-Compose +// +// Created by Morris Richman on 6/19/25. +// + +import ArgumentParser +import ContainerClient +import Foundation +@preconcurrency import Rainbow +import Yams +import ContainerizationExtras + +extension Application { + public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { + public init() {} + + public static let configuration: CommandConfiguration = .init( + commandName: "up", + abstract: "Start containers with compose" + ) + + @Argument(help: "Specify the services to start") + var services: [String] = [] + + @Flag( + name: [.customShort("d"), .customLong("detach")], + help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") + var detatch: Bool = false + + @Flag(name: [.customShort("b"), .customLong("build")]) + var rebuild: Bool = false + + @Flag(name: .long, help: "Do not use cache") + var noCache: Bool = false + + @OptionGroup + var process: Flags.Process + + @OptionGroup + var global: Flags.Global + + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file + // + private var fileManager: FileManager { FileManager.default } + private var projectName: String? + private var environmentVariables: [String: String] = [:] + private var containerIps: [String: String] = [:] + private var containerConsoleColors: [String: NamedColor] = [:] + + private static let availableContainerConsoleColors: Set = [ + .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, + ] + + public mutating func run() async throws { + // Read docker-compose.yml content + guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { + throw YamlError.dockerfileNotFound(dockerComposePath) + } + + // Decode the YAML file into the DockerCompose struct + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + + // Load environment variables from .env file + environmentVariables = loadEnvFile(path: envFilePath) + + // Handle 'version' field + if let version = dockerCompose.version { + print("Info: Docker Compose file version parsed as: \(version)") + print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") + } + + // Determine project name for container naming + if let name = dockerCompose.name { + projectName = name + print("Info: Docker Compose project name parsed as: \(name)") + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + } else { + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") + } + + // Get Services to use + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) + services = try Service.topoSortConfiguredServices(services) + + // Filter for specified services + if !self.services.isEmpty { + services = services.filter({ serviceName, service in + self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) + }) + } + + // Stop Services + try await stopOldStuff(services.map({ $0.serviceName }), remove: true) + + // Process top-level networks + // This creates named networks defined in the docker-compose.yml + if let networks = dockerCompose.networks { + print("\n--- Processing Networks ---") + for (networkName, networkConfig) in networks { + try await setupNetwork(name: networkName, config: networkConfig) + } + print("--- Networks Processed ---\n") + } + + // Process top-level volumes + // This creates named volumes defined in the docker-compose.yml + if let volumes = dockerCompose.volumes { + print("\n--- Processing Volumes ---") + for (volumeName, volumeConfig) in volumes { + await createVolumeHardLink(name: volumeName, config: volumeConfig) + } + print("--- Volumes Processed ---\n") + } + + // Process each service defined in the docker-compose.yml + print("\n--- Processing Services ---") + + print(services.map(\.serviceName)) + for (serviceName, service) in services { + try await configService(service, serviceName: serviceName, from: dockerCompose) + } + + if !detatch { + await waitForever() + } + } + + func waitForever() async -> Never { + for await _ in AsyncStream(unfolding: {}) { + // This will never run + } + fatalError("unreachable") + } + + private func getIPForRunningService(_ serviceName: String) async throws -> String? { + guard let projectName else { return nil } + + let containerName = "\(projectName)-\(serviceName)" + + let container = try await ClientContainer.get(id: containerName) + let ip = container.networks.compactMap { try? CIDRAddress($0.address).address.description }.first + + return ip + } + + /// Repeatedly checks `container list -a` until the given container is listed as `running`. + /// - Parameters: + /// - containerName: The exact name of the container (e.g. "Assignment-Manager-API-db"). + /// - timeout: Max seconds to wait before failing. + /// - interval: How often to poll (in seconds). + /// - Returns: `true` if the container reached "running" state within the timeout. + private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { + guard let projectName else { return } + let containerName = "\(projectName)-\(serviceName)" + + let deadline = Date().addingTimeInterval(timeout) + + while Date() < deadline { + let container = try? await ClientContainer.get(id: containerName) + if container?.status == .running { + return + } + + try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + + throw NSError( + domain: "ContainerWait", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." + ]) + } + + private func stopOldStuff(_ services: [String], remove: Bool) async throws { + guard let projectName else { return } + let containers = services.map { "\(projectName)-\($0)" } + + for container in containers { + print("Stopping container: \(container)") + guard let container = try? await ClientContainer.get(id: container) else { continue } + + do { + try await container.stop() + } catch { + } + if remove { + do { + try await container.delete() + } catch { + } + } + } + } + + // MARK: Compose Top Level Functions + + private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { + let ip = try await getIPForRunningService(serviceName) + self.containerIps[serviceName] = ip + for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { + self.environmentVariables[key] = ip ?? value + } + } + + private func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { + guard let projectName else { return } + let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name + + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") + let volumePath = volumeUrl.path(percentEncoded: false) + + print( + "Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + ) + try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + } + + private func setupNetwork(name networkName: String, config networkConfig: Network) async throws { + let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name + + if let externalNetwork = networkConfig.external, externalNetwork.isExternal { + print("Info: Network '\(networkName)' is declared as external.") + print("This tool assumes external network '\(externalNetwork.name ?? actualNetworkName)' already exists and will not attempt to create it.") + } else { + var networkCreateArgs: [String] = ["network", "create"] + + #warning("Docker Compose Network Options Not Supported") + // Add driver and driver options + if let driver = networkConfig.driver, !driver.isEmpty { +// networkCreateArgs.append("--driver") +// networkCreateArgs.append(driver) + print("Network Driver Detected, But Not Supported") + } + if let driverOpts = networkConfig.driver_opts, !driverOpts.isEmpty { +// for (optKey, optValue) in driverOpts { +// networkCreateArgs.append("--opt") +// networkCreateArgs.append("\(optKey)=\(optValue)") +// } + print("Network Options Detected, But Not Supported") + } + // Add various network flags + if networkConfig.attachable == true { +// networkCreateArgs.append("--attachable") + print("Network Attachable Flag Detected, But Not Supported") + } + if networkConfig.enable_ipv6 == true { +// networkCreateArgs.append("--ipv6") + print("Network IPv6 Flag Detected, But Not Supported") + } + if networkConfig.isInternal == true { +// networkCreateArgs.append("--internal") + print("Network Internal Flag Detected, But Not Supported") + } // CORRECTED: Use isInternal + + // Add labels + if let labels = networkConfig.labels, !labels.isEmpty { + print("Network Labels Detected, But Not Supported") +// for (labelKey, labelValue) in labels { +// networkCreateArgs.append("--label") +// networkCreateArgs.append("\(labelKey)=\(labelValue)") +// } + } + + print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") + print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") + guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else { + print("Network '\(networkName)' already exists") + return + } + var networkCreate = NetworkCreate() + networkCreate.global = global + networkCreate.name = actualNetworkName + + try await networkCreate.run() + print("Network '\(networkName)' created") + } + } + + // MARK: Compose Service Level Functions + private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { + guard let projectName else { throw ComposeError.invalidProjectName } + + var imageToRun: String + + // Handle 'build' configuration + if let buildConfig = service.build { + imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) + } else if let img = service.image { + // Use specified image if no build config + // Pull image if necessary + try await pullImage(img, platform: service.container_name) + imageToRun = img + } else { + // Should not happen due to Service init validation, but as a fallback + throw ComposeError.imageNotFound(serviceName) + } + + // Handle 'deploy' configuration (note that this tool doesn't fully support it) + if service.deploy != nil { + print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") + print( + "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." + ) + print("The service will be run as a single container based on other configurations.") + } + + var runCommandArgs: [String] = [] + + // Add detach flag if specified on the CLI + if detatch { + runCommandArgs.append("-d") + } + + // Determine container name + let containerName: String + if let explicitContainerName = service.container_name { + containerName = explicitContainerName + print("Info: Using explicit container_name: \(containerName)") + } else { + // Default container name based on project and service name + containerName = "\(projectName)-\(serviceName)" + } + runCommandArgs.append("--name") + runCommandArgs.append(containerName) + + // REMOVED: Restart policy is not supported by `container run` + // if let restart = service.restart { + // runCommandArgs.append("--restart") + // runCommandArgs.append(restart) + // } + + // Add user + if let user = service.user { + runCommandArgs.append("--user") + runCommandArgs.append(user) + } + + // Add volume mounts + if let volumes = service.volumes { + for volume in volumes { + let args = try await configVolume(volume) + runCommandArgs.append(contentsOf: args) + } + } + + // Combine environment variables from .env files and service environment + var combinedEnv: [String: String] = environmentVariables + + if let envFiles = service.env_file { + for envFile in envFiles { + let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") + combinedEnv.merge(additionalEnvVars) { (current, _) in current } + } + } + + if let serviceEnv = service.environment { + combinedEnv.merge(serviceEnv) { (old, new) in + guard !new.contains("${") else { + return old + } + return new + } // Service env overrides .env files + } + + // Fill in variables + combinedEnv = combinedEnv.mapValues({ value in + guard value.contains("${") else { return value } + + let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) + return combinedEnv[variableName] ?? value + }) + + // Fill in IPs + combinedEnv = combinedEnv.mapValues({ value in + containerIps[value] ?? value + }) + + // MARK: Spinning Spot + // Add environment variables to run command + for (key, value) in combinedEnv { + runCommandArgs.append("-e") + runCommandArgs.append("\(key)=\(value)") + } + + // REMOVED: Port mappings (-p) are not supported by `container run` + // if let ports = service.ports { + // for port in ports { + // let resolvedPort = resolveVariable(port, with: envVarsFromFile) + // runCommandArgs.append("-p") + // runCommandArgs.append(resolvedPort) + // } + // } + + // Connect to specified networks + if let serviceNetworks = service.networks { + for network in serviceNetworks { + let resolvedNetwork = resolveVariable(network, with: environmentVariables) + // Use the explicit network name from top-level definition if available, otherwise resolved name + let networkToConnect = dockerCompose.networks?[network]?.name ?? resolvedNetwork + runCommandArgs.append("--network") + runCommandArgs.append(networkToConnect) + } + print( + "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml." + ) + print( + "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." + ) + } else { + print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.") + } + + // Add hostname + if let hostname = service.hostname { + let resolvedHostname = resolveVariable(hostname, with: environmentVariables) + runCommandArgs.append("--hostname") + runCommandArgs.append(resolvedHostname) + } + + // Add working directory + if let workingDir = service.working_dir { + let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) + runCommandArgs.append("--workdir") + runCommandArgs.append(resolvedWorkingDir) + } + + // Add privileged flag + if service.privileged == true { + runCommandArgs.append("--privileged") + } + + // Add read-only flag + if service.read_only == true { + runCommandArgs.append("--read-only") + } + + // Handle service-level configs (note: still only parsing/logging, not attaching) + if let serviceConfigs = service.configs { + print( + "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) + print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.") + for serviceConfig in serviceConfigs { + print( + " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" + ) + } + } + // + // Handle service-level secrets (note: still only parsing/logging, not attaching) + if let serviceSecrets = service.secrets { + print( + "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) + print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.") + for serviceSecret in serviceSecrets { + print( + " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" + ) + } + } + + // Add interactive and TTY flags + if service.stdin_open == true { + runCommandArgs.append("-i") // --interactive + } + if service.tty == true { + runCommandArgs.append("-t") // --tty + } + + runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint + + // Add entrypoint or command + if let entrypointParts = service.entrypoint { + runCommandArgs.append("--entrypoint") + runCommandArgs.append(contentsOf: entrypointParts) + } else if let commandParts = service.command { + runCommandArgs.append(contentsOf: commandParts) + } + + var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! + + if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { + while containerConsoleColors.values.contains(serviceColor) { + serviceColor = Self.availableContainerConsoleColors.randomElement()! + } + } + + self.containerConsoleColors[serviceName] = serviceColor + + Task { [self, serviceColor] in + @Sendable + func handleOutput(_ output: String) { + print("\(serviceName): \(output)".applyingColor(serviceColor)) + } + + print("\nStarting service: \(serviceName)") + print("Starting \(serviceName)") + print("----------------------------------------\n") + let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) + } + + do { + try await waitUntilServiceIsRunning(serviceName) + try await updateEnvironmentWithServiceIP(serviceName) + } catch { + print(error) + } + } + + private func pullImage(_ imageName: String, platform: String?) async throws { + let imageList = try await ClientImage.list() + guard !imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageName }) else { + return + } + + print("Pulling Image \(imageName)...") + var registry = Flags.Registry() + registry.scheme = "auto" // Set or SwiftArgumentParser gets mad + + var progress = Flags.Progress() + progress.disableProgressUpdates = false + + var imagePull = ImagePull() + imagePull.progressFlags = progress + imagePull.registry = registry + imagePull.global = global + imagePull.reference = imageName + imagePull.platform = platform + try await imagePull.run() + } + + /// Builds Docker Service + /// + /// - Parameters: + /// - buildConfig: The configuration for the build + /// - service: The service you would like to build + /// - serviceName: The fallback name for the image + /// + /// - Returns: Image Name (`String`) + private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { + // Determine image tag for built image + let imageToRun = service.image ?? "\(serviceName):latest" + let imageList = try await ClientImage.list() + if !rebuild, imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageToRun }) { + return imageToRun + } + + var buildCommand = BuildCommand() + + // Set Build Commands + buildCommand.buildArg = (buildConfig.args ?? [:]).map({ "\($0.key)=\(resolveVariable($0.value, with: environmentVariables))" }) + + // Locate Dockerfile and context + buildCommand.contextDir = "\(self.cwd)/\(buildConfig.context)" + buildCommand.file = "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")" + + // Handle Caching + buildCommand.noCache = noCache + buildCommand.cacheIn = [] + buildCommand.cacheOut = [] + + // Handle OS/Arch + let split = service.platform?.split(separator: "/") + buildCommand.os = [String(split?.first ?? "linux")] + buildCommand.arch = [String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64")] + + // Set Image Name + buildCommand.targetImageName = imageToRun + + // Set CPU & Memory + buildCommand.cpus = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 + buildCommand.memory = service.deploy?.resources?.limits?.memory ?? "2048MB" + + // Set Miscelaneous + buildCommand.label = [] // No Label Equivalent? + buildCommand.progress = "auto" + buildCommand.vsockPort = 8088 + buildCommand.quiet = false + buildCommand.target = "" + buildCommand.output = ["type=oci"] + print("\n----------------------------------------") + print("Building image for service: \(serviceName) (Tag: \(imageToRun))") + try buildCommand.validate() + try await buildCommand.run() + print("Image build for \(serviceName) completed.") + print("----------------------------------------") + + return imageToRun + } + + private func configVolume(_ volume: String) async throws -> [String] { + let resolvedVolume = resolveVariable(volume, with: environmentVariables) + + var runCommandArgs: [String] = [] + + // Parse the volume string: destination[:mode] + let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) + + guard components.count >= 2 else { + print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") + return [] + } + + let source = components[0] + let destination = components[1] + + // Check if the source looks like a host path (contains '/' or starts with '.') + // This heuristic helps distinguish bind mounts from named volume references. + if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { + // This is likely a bind mount (local path to container path) + var isDirectory: ObjCBool = false + // Ensure the path is absolute or relative to the current directory for FileManager + let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) + + if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { + if isDirectory.boolValue { + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + } else { + // Host path exists but is a file + print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") + } + } else { + // Host path does not exist, assume it's meant to be a directory and try to create it. + do { + try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) + print("Info: Created missing host directory for volume: \(fullHostPath)") + runCommandArgs.append("-v") + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + } catch { + print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") + } + } + } else { + guard let projectName else { return [] } + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") + let volumePath = volumeUrl.path(percentEncoded: false) + + let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() + let destinationPath = destinationUrl.path(percentEncoded: false) + + print( + "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + ) + try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument + } + + return runCommandArgs + } + } +} + +// MARK: CommandLine Functions +extension Application.ComposeUp { + + /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. + /// + /// - Parameters: + /// - command: The name of the command to run (e.g., `"container"`). + /// - args: Command-line arguments to pass to the command. + /// - onStdout: Closure called with streamed stdout data. + /// - onStderr: Closure called with streamed stderr data. + /// - Returns: The process's exit code. + /// - Throws: If the process fails to launch. + @discardableResult + func streamCommand( + _ command: String, + args: [String] = [], + onStdout: @escaping (@Sendable (String) -> Void), + onStderr: @escaping (@Sendable (String) -> Void) + ) async throws -> Int32 { + try await withCheckedThrowingContinuation { continuation in + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [command] + args + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + process.environment = ProcessInfo.processInfo.environment.merging([ + "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + ]) { _, new in new } + + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + + stdoutHandle.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let string = String(data: data, encoding: .utf8) { + onStdout(string) + } + } + + stderrHandle.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let string = String(data: data, encoding: .utf8) { + onStderr(string) + } + } + + process.terminationHandler = { proc in + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil + continuation.resume(returning: proc.terminationStatus) + } + + do { + try process.run() + } catch { + continuation.resume(throwing: error) + } + } + } +} diff --git a/Sources/ContainerCLI/Compose/ComposeCommand.swift b/Sources/ContainerCLI/Compose/ComposeCommand.swift new file mode 100644 index 000000000..03e940332 --- /dev/null +++ b/Sources/ContainerCLI/Compose/ComposeCommand.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// File.swift +// Container-Compose +// +// Created by Morris Richman on 6/18/25. +// + +import ArgumentParser +import Foundation +import Rainbow +import Yams + +extension Application { + struct ComposeCommand: AsyncParsableCommand { + static let configuration: CommandConfiguration = .init( + commandName: "compose", + abstract: "Manage containers with Docker Compose files", + subcommands: [ + ComposeUp.self, + ComposeDown.self, + ]) + } +} + +/// A structure representing the result of a command-line process execution. +struct CommandResult { + /// The standard output captured from the process. + let stdout: String + + /// The standard error output captured from the process. + let stderr: String + + /// The exit code returned by the process upon termination. + let exitCode: Int32 +} + +extension NamedColor: Codable { + +} diff --git a/Sources/ContainerCLI/Compose/Errors.swift b/Sources/ContainerCLI/Compose/Errors.swift new file mode 100644 index 000000000..c5b375aa2 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Errors.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Errors.swift +// Container-Compose +// +// Created by Morris Richman on 6/18/25. +// + +import Foundation + +extension Application { + internal enum YamlError: Error, LocalizedError { + case dockerfileNotFound(String) + + var errorDescription: String? { + switch self { + case .dockerfileNotFound(let path): + return "docker-compose.yml not found at \(path)" + } + } + } + + internal enum ComposeError: Error, LocalizedError { + case imageNotFound(String) + case invalidProjectName + + var errorDescription: String? { + switch self { + case .imageNotFound(let name): + return "Service \(name) must define either 'image' or 'build'." + case .invalidProjectName: + return "Could not find project name." + } + } + } + + internal enum TerminalError: Error, LocalizedError { + case commandFailed(String) + + var errorDescription: String? { + "Command failed: \(self)" + } + } + + /// An enum representing streaming output from either `stdout` or `stderr`. + internal enum CommandOutput { + case stdout(String) + case stderr(String) + case exitCode(Int32) + } +} diff --git a/Sources/ContainerCLI/Compose/Helper Functions.swift b/Sources/ContainerCLI/Compose/Helper Functions.swift new file mode 100644 index 000000000..e1068ad94 --- /dev/null +++ b/Sources/ContainerCLI/Compose/Helper Functions.swift @@ -0,0 +1,96 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// Helper Functions.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + +import Foundation +import Yams + +extension Application { + /// Loads environment variables from a .env file. + /// - Parameter path: The full path to the .env file. + /// - Returns: A dictionary of key-value pairs representing environment variables. + internal static func loadEnvFile(path: String) -> [String: String] { + var envVars: [String: String] = [:] + let fileURL = URL(fileURLWithPath: path) + do { + let content = try String(contentsOf: fileURL, encoding: .utf8) + let lines = content.split(separator: "\n") + for line in lines { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + // Ignore empty lines and comments + if !trimmedLine.isEmpty && !trimmedLine.starts(with: "#") { + // Parse key=value pairs + if let eqIndex = trimmedLine.firstIndex(of: "=") { + let key = String(trimmedLine[.. String { + var resolvedValue = value + // Regex to find ${VAR}, ${VAR:-default}, ${VAR:?error} + let regex = try! NSRegularExpression(pattern: "\\$\\{([A-Z0-9_]+)(:?-(.*?))?(:\\?(.*?))?\\}", options: []) + + // Combine process environment with loaded .env file variables, prioritizing process environment + let combinedEnv = ProcessInfo.processInfo.environment.merging(envVars) { (current, _) in current } + + // Loop to resolve all occurrences of variables in the string + while let match = regex.firstMatch(in: resolvedValue, options: [], range: NSRange(resolvedValue.startIndex.. 0 && all { + throw ContainerizationError( + .invalidArgument, + message: "explicitly supplied container ID(s) conflict with the --all flag" + ) + } + } + + mutating func run() async throws { + let set = Set(containerIDs) + var containers = [ClientContainer]() + + if all { + containers = try await ClientContainer.list() + } else { + let ctrs = try await ClientContainer.list() + containers = ctrs.filter { c in + set.contains(c.id) + } + // If one of the containers requested isn't present, let's throw. We don't need to do + // this for --all as --all should be perfectly usable with no containers to remove; otherwise, + // it'd be quite clunky. + if containers.count != set.count { + let missing = set.filter { id in + !containers.contains { c in + c.id == id + } + } + throw ContainerizationError( + .notFound, + message: "failed to delete one or more containers: \(missing)" + ) + } + } + + var failed = [String]() + let force = self.force + let all = self.all + try await withThrowingTaskGroup(of: ClientContainer?.self) { group in + for container in containers { + group.addTask { + do { + // First we need to find if the container supports auto-remove + // and if so we need to skip deletion. + if container.status == .running { + if !force { + // We don't want to error if the user just wants all containers deleted. + // It's implied we'll skip containers we can't actually delete. + if all { + return nil + } + throw ContainerizationError(.invalidState, message: "container is running") + } + let stopOpts = ContainerStopOptions( + timeoutInSeconds: 5, + signal: SIGKILL + ) + try await container.stop(opts: stopOpts) + } + try await container.delete() + print(container.id) + return nil + } catch { + log.error("failed to delete container \(container.id): \(error)") + return container + } + } + } + + for try await ctr in group { + guard let ctr else { + continue + } + failed.append(ctr.id) + } + } + + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "delete failed for one or more containers: \(failed)") + } + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerExec.swift b/Sources/ContainerCLI/Container/ContainerExec.swift new file mode 100644 index 000000000..de3969585 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerExec.swift @@ -0,0 +1,96 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import Foundation + +extension Application { + struct ContainerExec: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "exec", + abstract: "Run a new command in a running container") + + @OptionGroup + var processFlags: Flags.Process + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Running containers ID") + var containerID: String + + @Argument(parsing: .captureForPassthrough, help: "New process arguments") + var arguments: [String] + + func run() async throws { + var exitCode: Int32 = 127 + let container = try await ClientContainer.get(id: containerID) + try ensureRunning(container: container) + + let stdin = self.processFlags.interactive + let tty = self.processFlags.tty + + var config = container.configuration.initProcess + config.executable = arguments.first! + config.arguments = [String](self.arguments.dropFirst()) + config.terminal = tty + config.environment.append( + contentsOf: try Parser.allEnv( + imageEnvs: [], + envFiles: self.processFlags.envFile, + envs: self.processFlags.env + )) + + if let cwd = self.processFlags.cwd { + config.workingDirectory = cwd + } + + let defaultUser = config.user + let (user, additionalGroups) = Parser.user( + user: processFlags.user, uid: processFlags.uid, + gid: processFlags.gid, defaultUser: defaultUser) + config.user = user + config.supplementalGroups.append(contentsOf: additionalGroups) + + do { + let io = try ProcessIO.create(tty: tty, interactive: stdin, detach: false) + + if !self.processFlags.tty { + var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) + handler.start { + print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") + Darwin.exit(1) + } + } + + let process = try await container.createProcess( + id: UUID().uuidString.lowercased(), + configuration: config) + + exitCode = try await Application.handleProcess(io: io, process: process) + } catch { + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to exec process \(error)") + } + throw ArgumentParser.ExitCode(exitCode) + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerInspect.swift b/Sources/ContainerCLI/Container/ContainerInspect.swift new file mode 100644 index 000000000..43bda51a1 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerInspect.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import SwiftProtobuf + +extension Application { + struct ContainerInspect: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display information about one or more containers") + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Containers to inspect") + var containers: [String] + + func run() async throws { + let objects: [any Codable] = try await ClientContainer.list().filter { + containers.contains($0.id) + }.map { + PrintableContainer($0) + } + print(try objects.jsonArray()) + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerKill.swift b/Sources/ContainerCLI/Container/ContainerKill.swift new file mode 100644 index 000000000..9b9ef4ed4 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerKill.swift @@ -0,0 +1,79 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import Darwin + +extension Application { + struct ContainerKill: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "kill", + abstract: "Kill one or more running containers") + + @Option(name: .shortAndLong, help: "Signal to send the container(s)") + var signal: String = "KILL" + + @Flag(name: .shortAndLong, help: "Kill all running containers") + var all = false + + @Argument(help: "Container IDs") + var containerIDs: [String] = [] + + @OptionGroup + var global: Flags.Global + + func validate() throws { + if containerIDs.count == 0 && !all { + throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") + } + if containerIDs.count > 0 && all { + throw ContainerizationError(.invalidArgument, message: "explicitly supplied container IDs conflicts with the --all flag") + } + } + + mutating func run() async throws { + let set = Set(containerIDs) + + var containers = try await ClientContainer.list().filter { c in + c.status == .running + } + if !self.all { + containers = containers.filter { c in + set.contains(c.id) + } + } + + let signalNumber = try Signals.parseSignal(signal) + + var failed: [String] = [] + for container in containers { + do { + try await container.kill(signalNumber) + print(container.id) + } catch { + log.error("failed to kill container \(container.id): \(error)") + failed.append(container.id) + } + } + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "kill failed for one or more containers") + } + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerList.swift b/Sources/ContainerCLI/Container/ContainerList.swift new file mode 100644 index 000000000..43e5a4cec --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerList.swift @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkService +import ContainerizationExtras +import Foundation +import SwiftProtobuf + +extension Application { + struct ContainerList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List containers", + aliases: ["ls"]) + + @Flag(name: .shortAndLong, help: "Show stopped containers as well") + var all = false + + @Flag(name: .shortAndLong, help: "Only output the container ID") + 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 containers = try await ClientContainer.list() + try printContainers(containers: containers, format: format) + } + + private func createHeader() -> [[String]] { + [["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR"]] + } + + private func printContainers(containers: [ClientContainer], format: ListFormat) throws { + if format == .json { + let printables = containers.map { + PrintableContainer($0) + } + let data = try JSONEncoder().encode(printables) + print(String(data: data, encoding: .utf8)!) + + return + } + + if self.quiet { + containers.forEach { + if !self.all && $0.status != .running { + return + } + print($0.id) + } + return + } + + var rows = createHeader() + for container in containers { + if !self.all && container.status != .running { + continue + } + rows.append(container.asRow) + } + + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + } +} + +extension ClientContainer { + var asRow: [String] { + [ + self.id, + self.configuration.image.reference, + self.configuration.platform.os, + self.configuration.platform.architecture, + self.status.rawValue, + self.networks.compactMap { try? CIDRAddress($0.address).address.description }.joined(separator: ","), + ] + } +} + +struct PrintableContainer: Codable { + let status: RuntimeStatus + let configuration: ContainerConfiguration + let networks: [Attachment] + + init(_ container: ClientContainer) { + self.status = container.status + self.configuration = container.configuration + self.networks = container.networks + } +} diff --git a/Sources/ContainerCLI/Container/ContainerLogs.swift b/Sources/ContainerCLI/Container/ContainerLogs.swift new file mode 100644 index 000000000..d70e80323 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerLogs.swift @@ -0,0 +1,144 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Dispatch +import Foundation + +extension Application { + struct ContainerLogs: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "logs", + abstract: "Fetch container stdio or boot logs" + ) + + @OptionGroup + var global: Flags.Global + + @Flag(name: .shortAndLong, help: "Follow log output") + var follow: Bool = false + + @Flag(name: .long, help: "Display the boot log for the container instead of stdio") + var boot: Bool = false + + @Option(name: [.customShort("n")], help: "Number of lines to show from the end of the logs. If not provided this will print all of the logs") + var numLines: Int? + + @Argument(help: "Container to fetch logs for") + var container: String + + func run() async throws { + do { + let container = try await ClientContainer.get(id: container) + let fhs = try await container.logs() + let fileHandle = boot ? fhs[1] : fhs[0] + + try await Self.tail( + fh: fileHandle, + n: numLines, + follow: follow + ) + } catch { + throw ContainerizationError( + .invalidArgument, + message: "failed to fetch container logs for \(container): \(error)" + ) + } + } + + private static func tail( + fh: FileHandle, + n: Int?, + follow: Bool + ) async throws { + if let n { + var buffer = Data() + let size = try fh.seekToEnd() + var offset = size + var lines: [String] = [] + + while offset > 0, lines.count < n { + let readSize = min(1024, offset) + offset -= readSize + try fh.seek(toOffset: offset) + + let data = fh.readData(ofLength: Int(readSize)) + buffer.insert(contentsOf: data, at: 0) + + if let chunk = String(data: buffer, encoding: .utf8) { + lines = chunk.components(separatedBy: .newlines) + lines = lines.filter { !$0.isEmpty } + } + } + + lines = Array(lines.suffix(n)) + for line in lines { + print(line) + } + } else { + // Fast path if all they want is the full file. + guard let data = try fh.readToEnd() else { + // Seems you get nil if it's a zero byte read, or you + // try and read from dev/null. + return + } + guard let str = String(data: data, encoding: .utf8) else { + throw ContainerizationError( + .internalError, + message: "failed to convert container logs to utf8" + ) + } + print(str.trimmingCharacters(in: .newlines)) + } + + if follow { + try await Self.followFile(fh: fh) + } + } + + private static func followFile(fh: FileHandle) async throws { + _ = try fh.seekToEnd() + let stream = AsyncStream { cont in + fh.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + // Triggers on container restart - can exit here as well + do { + _ = try fh.seekToEnd() // To continue streaming existing truncated log files + } catch { + fh.readabilityHandler = nil + cont.finish() + return + } + } + if let str = String(data: data, encoding: .utf8), !str.isEmpty { + var lines = str.components(separatedBy: .newlines) + lines = lines.filter { !$0.isEmpty } + for line in lines { + cont.yield(line) + } + } + } + } + + for await line in stream { + print(line) + } + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerStart.swift b/Sources/ContainerCLI/Container/ContainerStart.swift new file mode 100644 index 000000000..a804b9c20 --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerStart.swift @@ -0,0 +1,87 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import TerminalProgress + +extension Application { + struct ContainerStart: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Start a container") + + @Flag(name: .shortAndLong, help: "Attach STDOUT/STDERR") + var attach = false + + @Flag(name: .shortAndLong, help: "Attach container's STDIN") + var interactive = false + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Container's ID") + var containerID: String + + func run() async throws { + var exitCode: Int32 = 127 + + let progressConfig = try ProgressConfig( + description: "Starting container" + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + let container = try await ClientContainer.get(id: containerID) + let process = try await container.bootstrap() + + progress.set(description: "Starting init process") + let detach = !self.attach && !self.interactive + do { + let io = try ProcessIO.create( + tty: container.configuration.initProcess.terminal, + interactive: self.interactive, + detach: detach + ) + progress.finish() + if detach { + try await process.start(io.stdio) + defer { + try? io.close() + } + try io.closeAfterStart() + print(self.containerID) + return + } + + exitCode = try await Application.handleProcess(io: io, process: process) + } catch { + try? await container.stop() + + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to start container: \(error)") + } + throw ArgumentParser.ExitCode(exitCode) + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainerStop.swift b/Sources/ContainerCLI/Container/ContainerStop.swift new file mode 100644 index 000000000..78f69090e --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainerStop.swift @@ -0,0 +1,102 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import Foundation + +extension Application { + struct ContainerStop: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "stop", + abstract: "Stop one or more running containers") + + @Flag(name: .shortAndLong, help: "Stop all running containers") + var all = false + + @Option(name: .shortAndLong, help: "Signal to send the container(s)") + var signal: String = "SIGTERM" + + @Option(name: .shortAndLong, help: "Seconds to wait before killing the container(s)") + var time: Int32 = 5 + + @Argument + var containerIDs: [String] = [] + + @OptionGroup + var global: Flags.Global + + func validate() throws { + if containerIDs.count == 0 && !all { + throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") + } + if containerIDs.count > 0 && all { + throw ContainerizationError( + .invalidArgument, message: "explicitly supplied container IDs conflicts with the --all flag") + } + } + + mutating func run() async throws { + let set = Set(containerIDs) + var containers = [ClientContainer]() + if self.all { + containers = try await ClientContainer.list() + } else { + containers = try await ClientContainer.list().filter { c in + set.contains(c.id) + } + } + + let opts = ContainerStopOptions( + timeoutInSeconds: self.time, + signal: try Signals.parseSignal(self.signal) + ) + let failed = try await Self.stopContainers(containers: containers, stopOptions: opts) + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "stop failed for one or more containers \(failed.joined(separator: ","))") + } + } + + static func stopContainers(containers: [ClientContainer], stopOptions: ContainerStopOptions) async throws -> [String] { + var failed: [String] = [] + try await withThrowingTaskGroup(of: ClientContainer?.self) { group in + for container in containers { + group.addTask { + do { + try await container.stop(opts: stopOptions) + print(container.id) + return nil + } catch { + log.error("failed to stop container \(container.id): \(error)") + return container + } + } + } + + for try await ctr in group { + guard let ctr else { + continue + } + failed.append(ctr.id) + } + } + + return failed + } + } +} diff --git a/Sources/ContainerCLI/Container/ContainersCommand.swift b/Sources/ContainerCLI/Container/ContainersCommand.swift new file mode 100644 index 000000000..ef6aff93e --- /dev/null +++ b/Sources/ContainerCLI/Container/ContainersCommand.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct ContainersCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "containers", + abstract: "Manage containers", + subcommands: [ + ContainerCreate.self, + ContainerDelete.self, + ContainerExec.self, + ContainerInspect.self, + ContainerKill.self, + ContainerList.self, + ContainerLogs.self, + ContainerStart.self, + ContainerStop.self, + ], + aliases: ["container", "c"] + ) + } +} diff --git a/Sources/ContainerCLI/Container/ProcessUtils.swift b/Sources/ContainerCLI/Container/ProcessUtils.swift new file mode 100644 index 000000000..d4dda6a27 --- /dev/null +++ b/Sources/ContainerCLI/Container/ProcessUtils.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerClient +import Containerization +import ContainerizationError +import ContainerizationOS +import Foundation + +extension Application { + static func ensureRunning(container: ClientContainer) throws { + if container.status != .running { + throw ContainerizationError(.invalidState, message: "container \(container.id) is not running") + } + } +} diff --git a/Sources/ContainerCLI/DefaultCommand.swift b/Sources/ContainerCLI/DefaultCommand.swift new file mode 100644 index 000000000..ef88aaaa3 --- /dev/null +++ b/Sources/ContainerCLI/DefaultCommand.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin + +struct DefaultCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: nil, + shouldDisplay: false + ) + + @OptionGroup(visibility: .hidden) + var global: Flags.Global + + @Argument(parsing: .captureForPassthrough) + var remaining: [String] = [] + + func run() async throws { + // See if we have a possible plugin command. + guard let command = remaining.first else { + Application.printModifiedHelpText() + return + } + + // Check for edge cases and unknown options to match the behavior in the absence of plugins. + if command.isEmpty { + throw ValidationError("Unknown argument '\(command)'") + } else if command.starts(with: "-") { + throw ValidationError("Unknown option '\(command)'") + } + + let pluginLoader = Application.pluginLoader + guard let plugin = pluginLoader.findPlugin(name: command), plugin.config.isCLI else { + throw ValidationError("failed to find plugin named container-\(command)") + } + // Exec performs execvp (with no fork). + try plugin.exec(args: remaining) + } +} diff --git a/Sources/ContainerCLI/Image/ImageInspect.swift b/Sources/ContainerCLI/Image/ImageInspect.swift new file mode 100644 index 000000000..cea356867 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageInspect.swift @@ -0,0 +1,53 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation +import SwiftProtobuf + +extension Application { + struct ImageInspect: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display information about one or more images") + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Images to inspect") + var images: [String] + + func run() async throws { + var printable = [any Codable]() + let result = try await ClientImage.get(names: images) + let notFound = result.error + for image in result.images { + guard !Utility.isInfraImage(name: image.reference) else { + continue + } + printable.append(try await image.details()) + } + if printable.count > 0 { + print(try printable.jsonArray()) + } + if notFound.count > 0 { + throw ContainerizationError(.notFound, message: "Images: \(notFound.joined(separator: "\n"))") + } + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageList.swift b/Sources/ContainerCLI/Image/ImageList.swift new file mode 100644 index 000000000..e666feca7 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageList.swift @@ -0,0 +1,175 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationOCI +import Foundation +import SwiftProtobuf + +extension Application { + struct ListImageOptions: ParsableArguments { + @Flag(name: .shortAndLong, help: "Only output the image name") + var quiet = false + + @Flag(name: .shortAndLong, help: "Verbose output") + var verbose = false + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @OptionGroup + var global: Flags.Global + } + + struct ListImageImplementation { + static private func createHeader() -> [[String]] { + [["NAME", "TAG", "DIGEST"]] + } + + static private func createVerboseHeader() -> [[String]] { + [["NAME", "TAG", "INDEX DIGEST", "OS", "ARCH", "VARIANT", "SIZE", "CREATED", "MANIFEST DIGEST"]] + } + + static private func printImagesVerbose(images: [ClientImage]) async throws { + + var rows = createVerboseHeader() + for image in images { + let formatter = ByteCountFormatter() + for descriptor in try await image.index().manifests { + // Don't list attestation manifests + if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], + referenceType == "attestation-manifest" + { + continue + } + + guard let platform = descriptor.platform else { + continue + } + + let os = platform.os + let arch = platform.architecture + let variant = platform.variant ?? "" + + var config: ContainerizationOCI.Image + var manifest: ContainerizationOCI.Manifest + do { + config = try await image.config(for: platform) + manifest = try await image.manifest(for: platform) + } catch { + continue + } + + let created = config.created ?? "" + let size = descriptor.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size }) + let formattedSize = formatter.string(fromByteCount: size) + + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) + let row = [ + reference.name, + reference.tag ?? "", + Utility.trimDigest(digest: image.descriptor.digest), + os, + arch, + variant, + formattedSize, + created, + Utility.trimDigest(digest: descriptor.digest), + ] + rows.append(row) + } + } + + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + + static private func printImages(images: [ClientImage], format: ListFormat, options: ListImageOptions) async throws { + var images = images + images.sort { + $0.reference < $1.reference + } + + if format == .json { + let data = try JSONEncoder().encode(images.map { $0.description }) + print(String(data: data, encoding: .utf8)!) + return + } + + if options.quiet { + try images.forEach { image in + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + print(processedReferenceString) + } + return + } + + if options.verbose { + try await Self.printImagesVerbose(images: images) + return + } + + var rows = createHeader() + for image in images { + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) + rows.append([ + reference.name, + reference.tag ?? "", + Utility.trimDigest(digest: image.descriptor.digest), + ]) + } + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + + static func validate(options: ListImageOptions) throws { + if options.quiet && options.verbose { + throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite and --verbose together") + } + let modifier = options.quiet || options.verbose + if modifier && options.format == .json { + throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite or --verbose along with --format json") + } + } + + static func listImages(options: ListImageOptions) async throws { + let images = try await ClientImage.list().filter { img in + !Utility.isInfraImage(name: img.reference) + } + try await printImages(images: images, format: options.format, options: options) + } + } + + struct ImageList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List images", + aliases: ["ls"]) + + @OptionGroup + var options: ListImageOptions + + mutating func run() async throws { + try ListImageImplementation.validate(options: options) + try await ListImageImplementation.listImages(options: options) + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageLoad.swift b/Sources/ContainerCLI/Image/ImageLoad.swift new file mode 100644 index 000000000..719fd19ec --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageLoad.swift @@ -0,0 +1,76 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import Foundation +import TerminalProgress + +extension Application { + struct ImageLoad: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "load", + abstract: "Load images from an OCI compatible tar archive" + ) + + @OptionGroup + var global: Flags.Global + + @Option( + name: .shortAndLong, help: "Path to the tar archive to load images from", completion: .file(), + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) + }) + var input: String + + func run() async throws { + guard FileManager.default.fileExists(atPath: input) else { + print("File does not exist \(input)") + Application.exit(withError: ArgumentParser.ExitCode(1)) + } + + let progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + totalTasks: 2 + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + progress.set(description: "Loading tar archive") + let loaded = try await ClientImage.load(from: input) + + let taskManager = ProgressTaskCoordinator() + let unpackTask = await taskManager.startTask() + progress.set(description: "Unpacking image") + progress.set(itemsName: "entries") + for image in loaded { + try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) + } + await taskManager.finish() + progress.finish() + print("Loaded images:") + for image in loaded { + print(image.reference) + } + } + } +} diff --git a/Sources/ContainerCLI/Image/ImagePrune.swift b/Sources/ContainerCLI/Image/ImagePrune.swift new file mode 100644 index 000000000..d233247f1 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImagePrune.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension Application { + struct ImagePrune: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "prune", + abstract: "Remove unreferenced and dangling images") + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let (_, size) = try await ClientImage.pruneImages() + let formatter = ByteCountFormatter() + let freed = formatter.string(fromByteCount: Int64(size)) + print("Cleaned unreferenced images and snapshots") + print("Reclaimed \(freed) in disk space") + } + } +} diff --git a/Sources/ContainerCLI/Image/ImagePull.swift b/Sources/ContainerCLI/Image/ImagePull.swift new file mode 100644 index 000000000..58f6dc2c6 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImagePull.swift @@ -0,0 +1,98 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationOCI +import TerminalProgress + +extension Application { + struct ImagePull: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "pull", + abstract: "Pull an image" + ) + + @OptionGroup + var global: Flags.Global + + @OptionGroup + var registry: Flags.Registry + + @OptionGroup + var progressFlags: Flags.Progress + + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? + + @Argument var reference: String + + init() {} + + init(platform: String? = nil, scheme: String = "auto", reference: String, disableProgress: Bool = false) { + self.global = Flags.Global() + self.registry = Flags.Registry(scheme: scheme) + self.progressFlags = Flags.Progress(disableProgressUpdates: disableProgress) + self.platform = platform + self.reference = reference + } + + func run() async throws { + var p: Platform? + if let platform { + p = try Platform(from: platform) + } + + let scheme = try RequestScheme(registry.scheme) + + let processedReference = try ClientImage.normalizeReference(reference) + + var progressConfig: ProgressConfig + if self.progressFlags.disableProgressUpdates { + progressConfig = try ProgressConfig(disableProgressUpdates: self.progressFlags.disableProgressUpdates) + } else { + progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + ignoreSmallSize: true, + totalTasks: 2 + ) + } + + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + progress.set(description: "Fetching image") + progress.set(itemsName: "blobs") + let taskManager = ProgressTaskCoordinator() + let fetchTask = await taskManager.startTask() + let image = try await ClientImage.pull( + reference: processedReference, platform: p, scheme: scheme, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler) + ) + + progress.set(description: "Unpacking image") + progress.set(itemsName: "entries") + let unpackTask = await taskManager.startTask() + try await image.unpack(platform: p, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) + await taskManager.finish() + progress.finish() + } + } +} diff --git a/Sources/ContainerCLI/Image/ImagePush.swift b/Sources/ContainerCLI/Image/ImagePush.swift new file mode 100644 index 000000000..e61d162de --- /dev/null +++ b/Sources/ContainerCLI/Image/ImagePush.swift @@ -0,0 +1,73 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationOCI +import TerminalProgress + +extension Application { + struct ImagePush: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "push", + abstract: "Push an image" + ) + + @OptionGroup + var global: Flags.Global + + @OptionGroup + var registry: Flags.Registry + + @OptionGroup + var progressFlags: Flags.Progress + + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? + + @Argument var reference: String + + func run() async throws { + var p: Platform? + if let platform { + p = try Platform(from: platform) + } + + let scheme = try RequestScheme(registry.scheme) + let image = try await ClientImage.get(reference: reference) + + var progressConfig: ProgressConfig + if progressFlags.disableProgressUpdates { + progressConfig = try ProgressConfig(disableProgressUpdates: progressFlags.disableProgressUpdates) + } else { + progressConfig = try ProgressConfig( + description: "Pushing image \(image.reference)", + itemsName: "blobs", + showItems: true, + showSpeed: false, + ignoreSmallSize: true + ) + } + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + _ = try await image.push(platform: p, scheme: scheme, progressUpdate: progress.handler) + progress.finish() + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageRemove.swift b/Sources/ContainerCLI/Image/ImageRemove.swift new file mode 100644 index 000000000..2f0c86c22 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageRemove.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import Foundation + +extension Application { + struct RemoveImageOptions: ParsableArguments { + @Flag(name: .shortAndLong, help: "Remove all images") + var all: Bool = false + + @Argument + var images: [String] = [] + + @OptionGroup + var global: Flags.Global + } + + struct RemoveImageImplementation { + static func validate(options: RemoveImageOptions) throws { + if options.images.count == 0 && !options.all { + throw ContainerizationError(.invalidArgument, message: "no image specified and --all not supplied") + } + if options.images.count > 0 && options.all { + throw ContainerizationError(.invalidArgument, message: "explicitly supplied images conflict with the --all flag") + } + } + + static func removeImage(options: RemoveImageOptions) async throws { + let (found, notFound) = try await { + if options.all { + let found = try await ClientImage.list() + let notFound: [String] = [] + return (found, notFound) + } + return try await ClientImage.get(names: options.images) + }() + var failures: [String] = notFound + var didDeleteAnyImage = false + for image in found { + guard !Utility.isInfraImage(name: image.reference) else { + continue + } + do { + try await ClientImage.delete(reference: image.reference, garbageCollect: false) + print(image.reference) + didDeleteAnyImage = true + } catch { + log.error("failed to remove \(image.reference): \(error)") + failures.append(image.reference) + } + } + let (_, size) = try await ClientImage.pruneImages() + let formatter = ByteCountFormatter() + let freed = formatter.string(fromByteCount: Int64(size)) + + if didDeleteAnyImage { + print("Reclaimed \(freed) in disk space") + } + if failures.count > 0 { + throw ContainerizationError(.internalError, message: "failed to delete one or more images: \(failures)") + } + } + } + + struct ImageRemove: AsyncParsableCommand { + @OptionGroup + var options: RemoveImageOptions + + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Remove one or more images", + aliases: ["rm"]) + + func validate() throws { + try RemoveImageImplementation.validate(options: options) + } + + mutating func run() async throws { + try await RemoveImageImplementation.removeImage(options: options) + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageSave.swift b/Sources/ContainerCLI/Image/ImageSave.swift new file mode 100644 index 000000000..8c0b6eac4 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageSave.swift @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationOCI +import Foundation +import TerminalProgress + +extension Application { + struct ImageSave: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "save", + abstract: "Save an image as an OCI compatible tar archive" + ) + + @OptionGroup + var global: Flags.Global + + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? + + @Option( + name: .shortAndLong, help: "Path to save the image tar archive", completion: .file(), + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) + }) + var output: String + + @Argument var reference: String + + func run() async throws { + var p: Platform? + if let platform { + p = try Platform(from: platform) + } + + let progressConfig = try ProgressConfig( + description: "Saving image" + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + let image = try await ClientImage.get(reference: reference) + try await image.save(out: output, platform: p) + + progress.finish() + print("Image saved") + } + } +} diff --git a/Sources/ContainerCLI/Image/ImageTag.swift b/Sources/ContainerCLI/Image/ImageTag.swift new file mode 100644 index 000000000..01a76190f --- /dev/null +++ b/Sources/ContainerCLI/Image/ImageTag.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct ImageTag: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "tag", + abstract: "Tag an image") + + @Argument(help: "SOURCE_IMAGE[:TAG]") + var source: String + + @Argument(help: "TARGET_IMAGE[:TAG]") + var target: String + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let existing = try await ClientImage.get(reference: source) + let targetReference = try ClientImage.normalizeReference(target) + try await existing.tag(new: targetReference) + print("Image \(source) tagged as \(target)") + } + } +} diff --git a/Sources/ContainerCLI/Image/ImagesCommand.swift b/Sources/ContainerCLI/Image/ImagesCommand.swift new file mode 100644 index 000000000..968dfd239 --- /dev/null +++ b/Sources/ContainerCLI/Image/ImagesCommand.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct ImagesCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "images", + abstract: "Manage images", + subcommands: [ + ImageInspect.self, + ImageList.self, + ImageLoad.self, + ImagePrune.self, + ImagePull.self, + ImagePush.self, + ImageRemove.self, + ImageSave.self, + ImageTag.self, + ], + aliases: ["image", "i"] + ) + } +} diff --git a/Sources/ContainerCLI/Network/NetworkCommand.swift b/Sources/ContainerCLI/Network/NetworkCommand.swift new file mode 100644 index 000000000..7e502431b --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkCommand.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct NetworkCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "network", + abstract: "Manage container networks", + subcommands: [ + NetworkCreate.self, + NetworkDelete.self, + NetworkList.self, + NetworkInspect.self, + ], + aliases: ["n"] + ) + } +} diff --git a/Sources/ContainerCLI/Network/NetworkCreate.swift b/Sources/ContainerCLI/Network/NetworkCreate.swift new file mode 100644 index 000000000..535e029ed --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkCreate.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkService +import ContainerizationError +import Foundation +import TerminalProgress + +extension Application { + struct NetworkCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a new network") + + @Argument(help: "Network name") + var name: String + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let config = NetworkConfiguration(id: self.name, mode: .nat) + let state = try await ClientNetwork.create(configuration: config) + print(state.id) + } + } +} diff --git a/Sources/ContainerCLI/Network/NetworkDelete.swift b/Sources/ContainerCLI/Network/NetworkDelete.swift new file mode 100644 index 000000000..836d6c8ca --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkDelete.swift @@ -0,0 +1,116 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkService +import ContainerizationError +import Foundation + +extension Application { + struct NetworkDelete: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete one or more networks", + aliases: ["rm"]) + + @Flag(name: .shortAndLong, help: "Remove all networks") + var all = false + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Network names") + var networkNames: [String] = [] + + func validate() throws { + if networkNames.count == 0 && !all { + throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied") + } + if networkNames.count > 0 && all { + throw ContainerizationError( + .invalidArgument, + message: "explicitly supplied network name(s) conflict with the --all flag" + ) + } + } + + mutating func run() async throws { + let uniqueNetworkNames = Set(networkNames) + let networks: [NetworkState] + + if all { + networks = try await ClientNetwork.list() + } else { + networks = try await ClientNetwork.list() + .filter { c in + uniqueNetworkNames.contains(c.id) + } + + // If one of the networks requested isn't present lets throw. We don't need to do + // this for --all as --all should be perfectly usable with no networks to remove, + // otherwise it'd be quite clunky. + if networks.count != uniqueNetworkNames.count { + let missing = uniqueNetworkNames.filter { id in + !networks.contains { n in + n.id == id + } + } + throw ContainerizationError( + .notFound, + message: "failed to delete one or more networks: \(missing)" + ) + } + } + + if uniqueNetworkNames.contains(ClientNetwork.defaultNetworkName) { + throw ContainerizationError( + .invalidArgument, + message: "cannot delete the default network" + ) + } + + var failed = [String]() + try await withThrowingTaskGroup(of: NetworkState?.self) { group in + for network in networks { + group.addTask { + do { + // delete atomically disables the IP allocator, then deletes + // the allocator disable fails if any IPs are still in use + try await ClientNetwork.delete(id: network.id) + print(network.id) + return nil + } catch { + log.error("failed to delete network \(network.id): \(error)") + return network + } + } + } + + for try await network in group { + guard let network else { + continue + } + failed.append(network.id) + } + } + + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "delete failed for one or more networks: \(failed)") + } + } + } +} diff --git a/Sources/ContainerCLI/Network/NetworkInspect.swift b/Sources/ContainerCLI/Network/NetworkInspect.swift new file mode 100644 index 000000000..614c8b111 --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkInspect.swift @@ -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 ContainerNetworkService +import Foundation +import SwiftProtobuf + +extension Application { + struct NetworkInspect: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display information about one or more networks") + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Networks to inspect") + var networks: [String] + + func run() async throws { + let objects: [any Codable] = try await ClientNetwork.list().filter { + networks.contains($0.id) + }.map { + PrintableNetwork($0) + } + print(try objects.jsonArray()) + } + } +} diff --git a/Sources/ContainerCLI/Network/NetworkList.swift b/Sources/ContainerCLI/Network/NetworkList.swift new file mode 100644 index 000000000..9fb44dcb4 --- /dev/null +++ b/Sources/ContainerCLI/Network/NetworkList.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkService +import ContainerizationExtras +import Foundation +import SwiftProtobuf + +extension Application { + struct NetworkList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List networks", + 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 networks = try await ClientNetwork.list() + try printNetworks(networks: networks, format: format) + } + + private func createHeader() -> [[String]] { + [["NETWORK", "STATE", "SUBNET"]] + } + + private func printNetworks(networks: [NetworkState], format: ListFormat) throws { + if format == .json { + let printables = networks.map { + PrintableNetwork($0) + } + let data = try JSONEncoder().encode(printables) + print(String(data: data, encoding: .utf8)!) + + return + } + + if self.quiet { + networks.forEach { + print($0.id) + } + return + } + + var rows = createHeader() + for network in networks { + rows.append(network.asRow) + } + + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + } +} + +extension NetworkState { + var asRow: [String] { + switch self { + case .created(_): + return [self.id, self.state, "none"] + case .running(_, let status): + return [self.id, self.state, status.address] + } + } +} + +struct PrintableNetwork: Codable { + let id: String + let state: String + let config: NetworkConfiguration + let status: NetworkStatus? + + init(_ network: NetworkState) { + self.id = network.id + self.state = network.state + switch network { + case .created(let config): + self.config = config + self.status = nil + case .running(let config, let status): + self.config = config + self.status = status + } + } +} diff --git a/Sources/ContainerCLI/Registry/Login.swift b/Sources/ContainerCLI/Registry/Login.swift new file mode 100644 index 000000000..7de7fe7e4 --- /dev/null +++ b/Sources/ContainerCLI/Registry/Login.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationOCI +import Foundation + +extension Application { + struct Login: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Login to a registry" + ) + + @Option(name: .shortAndLong, help: "Username") + var username: String = "" + + @Flag(help: "Take the password from stdin") + var passwordStdin: Bool = false + + @Argument(help: "Registry server name") + var server: String + + @OptionGroup + var registry: Flags.Registry + + func run() async throws { + var username = self.username + var password = "" + if passwordStdin { + if username == "" { + throw ContainerizationError( + .invalidArgument, message: "must provide --username with --password-stdin") + } + guard let passwordData = try FileHandle.standardInput.readToEnd() else { + throw ContainerizationError(.invalidArgument, message: "failed to read password from stdin") + } + password = String(decoding: passwordData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + } + let keychain = KeychainHelper(id: Constants.keychainID) + if username == "" { + username = try keychain.userPrompt(domain: server) + } + if password == "" { + password = try keychain.passwordPrompt() + print() + } + + let server = Reference.resolveDomain(domain: server) + let scheme = try RequestScheme(registry.scheme).schemeFor(host: server) + let _url = "\(scheme)://\(server)" + guard let url = URL(string: _url) else { + throw ContainerizationError(.invalidArgument, message: "Cannot convert \(_url) to URL") + } + guard let host = url.host else { + throw ContainerizationError(.invalidArgument, message: "Invalid host \(server)") + } + + let client = RegistryClient( + host: host, + scheme: scheme.rawValue, + port: url.port, + authentication: BasicAuthentication(username: username, password: password), + retryOptions: .init( + maxRetries: 10, + retryInterval: 300_000_000, + shouldRetry: ({ response in + response.status.code >= 500 + }) + ) + ) + try await client.ping() + try keychain.save(domain: server, username: username, password: password) + print("Login succeeded") + } + } +} diff --git a/Sources/ContainerCLI/Registry/Logout.swift b/Sources/ContainerCLI/Registry/Logout.swift new file mode 100644 index 000000000..a24996e12 --- /dev/null +++ b/Sources/ContainerCLI/Registry/Logout.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationOCI + +extension Application { + struct Logout: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Log out from a registry") + + @Argument(help: "Registry server name") + var registry: String + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let keychain = KeychainHelper(id: Constants.keychainID) + let r = Reference.resolveDomain(domain: registry) + try keychain.delete(domain: r) + } + } +} diff --git a/Sources/ContainerCLI/Registry/RegistryCommand.swift b/Sources/ContainerCLI/Registry/RegistryCommand.swift new file mode 100644 index 000000000..c160c9469 --- /dev/null +++ b/Sources/ContainerCLI/Registry/RegistryCommand.swift @@ -0,0 +1,32 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct RegistryCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "registry", + abstract: "Manage registry configurations", + subcommands: [ + Login.self, + Logout.self, + RegistryDefault.self, + ], + aliases: ["r"] + ) + } +} diff --git a/Sources/ContainerCLI/Registry/RegistryDefault.swift b/Sources/ContainerCLI/Registry/RegistryDefault.swift new file mode 100644 index 000000000..593d41e27 --- /dev/null +++ b/Sources/ContainerCLI/Registry/RegistryDefault.swift @@ -0,0 +1,98 @@ +//===----------------------------------------------------------------------===// +// 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 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 + } + } + ClientDefaults.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 { + ClientDefaults.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(ClientDefaults.get(key: .defaultRegistryDomain)) + } + } +} diff --git a/Sources/ContainerCLI/RunCommand.swift b/Sources/ContainerCLI/RunCommand.swift new file mode 100644 index 000000000..3a818e939 --- /dev/null +++ b/Sources/ContainerCLI/RunCommand.swift @@ -0,0 +1,317 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOS +import Foundation +import NIOCore +import NIOPosix +import TerminalProgress + +extension Application { + struct ContainerRunCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "run", + abstract: "Run a container") + + @OptionGroup + var processFlags: Flags.Process + + @OptionGroup + var resourceFlags: Flags.Resource + + @OptionGroup + var managementFlags: Flags.Management + + @OptionGroup + var registryFlags: Flags.Registry + + @OptionGroup + var global: Flags.Global + + @OptionGroup + var progressFlags: Flags.Progress + + @Argument(help: "Image name") + var image: String + + @Argument(parsing: .captureForPassthrough, help: "Container init process arguments") + var arguments: [String] = [] + + func run() async throws { + var exitCode: Int32 = 127 + let id = Utility.createContainerID(name: self.managementFlags.name) + + var progressConfig: ProgressConfig + if progressFlags.disableProgressUpdates { + progressConfig = try ProgressConfig(disableProgressUpdates: progressFlags.disableProgressUpdates) + } else { + progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + ignoreSmallSize: true, + totalTasks: 6 + ) + } + + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + try Utility.validEntityName(id) + + // Check if container with id already exists. + let existing = try? await ClientContainer.get(id: id) + guard existing == nil else { + throw ContainerizationError( + .exists, + message: "container with id \(id) already exists" + ) + } + + let ck = try await Utility.containerConfigFromFlags( + id: id, + image: image, + arguments: arguments, + process: processFlags, + management: managementFlags, + resource: resourceFlags, + registry: registryFlags, + progressUpdate: progress.handler + ) + + progress.set(description: "Starting container") + + let options = ContainerCreateOptions(autoRemove: managementFlags.remove) + let container = try await ClientContainer.create( + configuration: ck.0, + options: options, + kernel: ck.1 + ) + + let detach = self.managementFlags.detach + + let process = try await container.bootstrap() + progress.finish() + + do { + let io = try ProcessIO.create( + tty: self.processFlags.tty, + interactive: self.processFlags.interactive, + detach: detach + ) + + if !self.managementFlags.cidfile.isEmpty { + let path = self.managementFlags.cidfile + let data = id.data(using: .utf8) + var attributes = [FileAttributeKey: Any]() + attributes[.posixPermissions] = 0o644 + let success = FileManager.default.createFile( + atPath: path, + contents: data, + attributes: attributes + ) + guard success else { + throw ContainerizationError( + .internalError, message: "failed to create cidfile at \(path): \(errno)") + } + } + + if detach { + try await process.start(io.stdio) + defer { + try? io.close() + } + try io.closeAfterStart() + print(id) + return + } + + if !self.processFlags.tty { + var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) + handler.start { + print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") + Darwin.exit(1) + } + } + + exitCode = try await Application.handleProcess(io: io, process: process) + } catch { + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to run container: \(error)") + } + throw ArgumentParser.ExitCode(exitCode) + } + } +} + +struct ProcessIO { + let stdin: Pipe? + let stdout: Pipe? + let stderr: Pipe? + var ioTracker: IoTracker? + + struct IoTracker { + let stream: AsyncStream + let cont: AsyncStream.Continuation + let configuredStreams: Int + } + + let stdio: [FileHandle?] + + let console: Terminal? + + func closeAfterStart() throws { + try stdin?.fileHandleForReading.close() + try stdout?.fileHandleForWriting.close() + try stderr?.fileHandleForWriting.close() + } + + func close() throws { + try console?.reset() + } + + static func create(tty: Bool, interactive: Bool, detach: Bool) throws -> ProcessIO { + let current: Terminal? = try { + if !tty { + return nil + } + let current = try Terminal.current + try current.setraw() + return current + }() + + var stdio = [FileHandle?](repeating: nil, count: 3) + + let stdin: Pipe? = { + if !interactive && !tty { + return nil + } + return Pipe() + }() + + if let stdin { + if interactive { + let pin = FileHandle.standardInput + pin.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + pin.readabilityHandler = nil + return + } + try! stdin.fileHandleForWriting.write(contentsOf: data) + } + } + stdio[0] = stdin.fileHandleForReading + } + + let stdout: Pipe? = { + if detach { + return nil + } + return Pipe() + }() + + var configuredStreams = 0 + let (stream, cc) = AsyncStream.makeStream() + if let stdout { + configuredStreams += 1 + let pout: FileHandle = { + if let current { + return current.handle + } + return .standardOutput + }() + + let rout = stdout.fileHandleForReading + rout.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + rout.readabilityHandler = nil + cc.yield() + return + } + try! pout.write(contentsOf: data) + } + stdio[1] = stdout.fileHandleForWriting + } + + let stderr: Pipe? = { + if detach || tty { + return nil + } + return Pipe() + }() + if let stderr { + configuredStreams += 1 + let perr: FileHandle = .standardError + let rerr = stderr.fileHandleForReading + rerr.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + rerr.readabilityHandler = nil + cc.yield() + return + } + try! perr.write(contentsOf: data) + } + stdio[2] = stderr.fileHandleForWriting + } + + var ioTracker: IoTracker? = nil + if configuredStreams > 0 { + ioTracker = .init(stream: stream, cont: cc, configuredStreams: configuredStreams) + } + + return .init( + stdin: stdin, + stdout: stdout, + stderr: stderr, + ioTracker: ioTracker, + stdio: stdio, + console: current + ) + } + + public func wait() async throws { + guard let ioTracker = self.ioTracker else { + return + } + do { + try await Timeout.run(seconds: 3) { + var counter = ioTracker.configuredStreams + for await _ in ioTracker.stream { + counter -= 1 + if counter == 0 { + ioTracker.cont.finish() + break + } + } + } + } catch { + log.error("Timeout waiting for IO to complete : \(error)") + throw error + } + } +} diff --git a/Sources/ContainerCLI/System/DNS/DNSCreate.swift b/Sources/ContainerCLI/System/DNS/DNSCreate.swift new file mode 100644 index 000000000..2dbe2d8ac --- /dev/null +++ b/Sources/ContainerCLI/System/DNS/DNSCreate.swift @@ -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 ContainerizationError +import ContainerizationExtras +import Foundation + +extension Application { + struct DNSCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a local DNS domain for containers (must run as an administrator)" + ) + + @Argument(help: "the local domain name") + var domainName: String + + func run() async throws { + let resolver: HostDNSResolver = HostDNSResolver() + do { + try resolver.createDomain(name: domainName) + print(domainName) + } catch let error as ContainerizationError { + throw error + } catch { + throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") + } + + do { + try HostDNSResolver.reinitialize() + } catch { + throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") + } + } + } +} diff --git a/Sources/ContainerCLI/System/DNS/DNSDefault.swift b/Sources/ContainerCLI/System/DNS/DNSDefault.swift new file mode 100644 index 000000000..5a746eab5 --- /dev/null +++ b/Sources/ContainerCLI/System/DNS/DNSDefault.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// 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 + +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 { + ClientDefaults.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 { + ClientDefaults.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(ClientDefaults.getOptional(key: .defaultDNSDomain) ?? "") + } + } + } +} diff --git a/Sources/ContainerCLI/System/DNS/DNSDelete.swift b/Sources/ContainerCLI/System/DNS/DNSDelete.swift new file mode 100644 index 000000000..b3360bb57 --- /dev/null +++ b/Sources/ContainerCLI/System/DNS/DNSDelete.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct DNSDelete: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete a local DNS domain (must run as an administrator)", + aliases: ["rm"] + ) + + @Argument(help: "the local domain name") + var domainName: String + + func run() async throws { + let resolver = HostDNSResolver() + do { + try resolver.deleteDomain(name: domainName) + print(domainName) + } catch { + throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") + } + + do { + try HostDNSResolver.reinitialize() + } catch { + throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") + } + } + } +} diff --git a/Sources/ContainerCLI/System/DNS/DNSList.swift b/Sources/ContainerCLI/System/DNS/DNSList.swift new file mode 100644 index 000000000..616415775 --- /dev/null +++ b/Sources/ContainerCLI/System/DNS/DNSList.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension Application { + struct DNSList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List local DNS domains", + aliases: ["ls"] + ) + + func run() async throws { + let resolver: HostDNSResolver = HostDNSResolver() + let domains = resolver.listDomains() + print(domains.joined(separator: "\n")) + } + + } +} diff --git a/Sources/ContainerCLI/System/Kernel/KernelSet.swift b/Sources/ContainerCLI/System/Kernel/KernelSet.swift new file mode 100644 index 000000000..6a1ac1790 --- /dev/null +++ b/Sources/ContainerCLI/System/Kernel/KernelSet.swift @@ -0,0 +1,114 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Foundation +import TerminalProgress + +extension Application { + struct KernelSet: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "set", + abstract: "Set the default kernel" + ) + + @Option(name: .customLong("binary"), help: "Path to the binary to set as the default kernel. If used with --tar, this points to a location inside the tar") + var binaryPath: String? = nil + + @Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar ball that contains the kernel to use") + var tarPath: String? = nil + + @Option(name: .customLong("arch"), help: "The architecture of the kernel binary. One of (amd64, arm64)") + var architecture: String = ContainerizationOCI.Platform.current.architecture.description + + @Flag(name: .customLong("recommended"), help: "Download and install the recommended kernel as the default. This flag ignores any other arguments") + var recommended: Bool = false + + func run() async throws { + if recommended { + let url = ClientDefaults.get(key: .defaultKernelURL) + let path = ClientDefaults.get(key: .defaultKernelBinaryPath) + print("Installing the recommended kernel from \(url)...") + try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path) + return + } + guard tarPath != nil else { + return try await self.setKernelFromBinary() + } + try await self.setKernelFromTar() + } + + private func setKernelFromBinary() async throws { + guard let binaryPath else { + throw ArgumentParser.ValidationError("Missing argument '--binary'") + } + let absolutePath = URL(fileURLWithPath: binaryPath, relativeTo: .currentDirectory()).absoluteURL.absoluteString + let platform = try getSystemPlatform() + try await ClientKernel.installKernel(kernelFilePath: absolutePath, platform: platform) + } + + private func setKernelFromTar() async throws { + guard let binaryPath else { + throw ArgumentParser.ValidationError("Missing argument '--binary'") + } + guard let tarPath else { + throw ArgumentParser.ValidationError("Missing argument '--tar") + } + let platform = try getSystemPlatform() + let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).absoluteString + let fm = FileManager.default + if fm.fileExists(atPath: localTarPath) { + try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform) + return + } + guard let remoteURL = URL(string: tarPath) else { + throw ContainerizationError(.invalidArgument, message: "Invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?") + } + try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL.absoluteString, kernelFilePath: binaryPath, platform: platform) + } + + private func getSystemPlatform() throws -> SystemPlatform { + switch architecture { + case "arm64": + return .linuxArm + case "amd64": + return .linuxAmd + default: + throw ContainerizationError(.unsupported, message: "Unsupported architecture \(architecture)") + } + } + + public static func downloadAndInstallWithProgressBar(tarRemoteURL: String, kernelFilePath: String, platform: SystemPlatform = .current) async throws { + let progressConfig = try ProgressConfig( + showTasks: true, + totalTasks: 2 + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + try await ClientKernel.installKernelFromTar(tarFile: tarRemoteURL, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler) + progress.finish() + } + + } +} diff --git a/Sources/ContainerCLI/System/SystemCommand.swift b/Sources/ContainerCLI/System/SystemCommand.swift new file mode 100644 index 000000000..3a92bfb92 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemCommand.swift @@ -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 + +extension Application { + struct SystemCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "system", + abstract: "Manage system components", + subcommands: [ + SystemDNS.self, + SystemLogs.self, + SystemStart.self, + SystemStop.self, + SystemStatus.self, + SystemKernel.self, + ], + aliases: ["s"] + ) + } +} diff --git a/Sources/ContainerCLI/System/SystemDNS.swift b/Sources/ContainerCLI/System/SystemDNS.swift new file mode 100644 index 000000000..4f9b3e3b3 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemDNS.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +extension Application { + struct SystemDNS: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "dns", + abstract: "Manage local DNS domains", + subcommands: [ + DNSCreate.self, + DNSDelete.self, + DNSList.self, + DNSDefault.self, + ] + ) + } +} diff --git a/Sources/ContainerCLI/System/SystemKernel.swift b/Sources/ContainerCLI/System/SystemKernel.swift new file mode 100644 index 000000000..942bd6965 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemKernel.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + struct SystemKernel: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "kernel", + abstract: "Manage the default kernel configuration", + subcommands: [ + KernelSet.self + ] + ) + } +} diff --git a/Sources/ContainerCLI/System/SystemLogs.swift b/Sources/ContainerCLI/System/SystemLogs.swift new file mode 100644 index 000000000..e2b87ffb9 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemLogs.swift @@ -0,0 +1,82 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import Foundation +import OSLog + +extension Application { + struct SystemLogs: AsyncParsableCommand { + static let subsystem = "com.apple.container" + + static let configuration = CommandConfiguration( + commandName: "logs", + abstract: "Fetch system logs for `container` services" + ) + + @OptionGroup + var global: Flags.Global + + @Option( + name: .long, + help: "Fetch logs starting from the specified time period (minus the current time); supported formats: m, h, d" + ) + var last: String = "5m" + + @Flag(name: .shortAndLong, help: "Follow log output") + var follow: Bool = false + + func run() async throws { + let process = Process() + let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM]) + + Task { + for await _ in sigHandler.signals { + process.terminate() + Darwin.exit(0) + } + } + + do { + var args = ["log"] + args.append(self.follow ? "stream" : "show") + args.append(contentsOf: ["--info", "--debug"]) + if !self.follow { + args.append(contentsOf: ["--last", last]) + } + args.append(contentsOf: ["--predicate", "subsystem = 'com.apple.container'"]) + + process.launchPath = "/usr/bin/env" + process.arguments = args + + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + + try process.run() + process.waitUntilExit() + } catch { + throw ContainerizationError( + .invalidArgument, + message: "failed to system logs: \(error)" + ) + } + throw ArgumentParser.ExitCode(process.terminationStatus) + } + } +} diff --git a/Sources/ContainerCLI/System/SystemStart.swift b/Sources/ContainerCLI/System/SystemStart.swift new file mode 100644 index 000000000..acce91391 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemStart.swift @@ -0,0 +1,170 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin +import ContainerizationError +import Foundation +import TerminalProgress + +extension Application { + struct SystemStart: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Start `container` services" + ) + + @Option(name: .shortAndLong, help: "Path to the `container-apiserver` binary") + var path: String = Bundle.main.executablePath ?? "" + + @Flag(name: .long, help: "Enable debug logging for the runtime daemon.") + var debug = false + + @Flag( + name: .long, inversion: .prefixedEnableDisable, + help: "Specify whether the default kernel should be installed or not. The default behavior is to prompt the user for a response.") + var kernelInstall: Bool? + + func run() async throws { + // Without the true path to the binary in the plist, `container-apiserver` won't launch properly. + let executableUrl = URL(filePath: path) + .resolvingSymlinksInPath() + .deletingLastPathComponent() + .appendingPathComponent("container-apiserver") + + var args = [executableUrl.absolutePath()] + if debug { + args.append("--debug") + } + + let apiServerDataUrl = appRoot.appending(path: "apiserver") + try! FileManager.default.createDirectory(at: apiServerDataUrl, withIntermediateDirectories: true) + let env = ProcessInfo.processInfo.environment.filter { key, _ in + key.hasPrefix("CONTAINER_") + } + + let logURL = apiServerDataUrl.appending(path: "apiserver.log") + let plist = LaunchPlist( + label: "com.apple.container.apiserver", + arguments: args, + environment: env, + limitLoadToSessionType: [.Aqua, .Background, .System], + runAtLoad: true, + stdout: logURL.path, + stderr: logURL.path, + machServices: ["com.apple.container.apiserver"] + ) + + let plistURL = apiServerDataUrl.appending(path: "apiserver.plist") + let data = try plist.encode() + try data.write(to: plistURL) + + try ServiceManager.register(plistPath: plistURL.path) + + // Now ping our friendly daemon. Fail if we don't get a response. + do { + print("Verifying apiserver is running...") + try await ClientHealthCheck.ping(timeout: .seconds(10)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get a response from apiserver: \(error)" + ) + } + + if await !initImageExists() { + try? await installInitialFilesystem() + } + + guard await !kernelExists() else { + return + } + try await installDefaultKernel() + } + + private func installInitialFilesystem() async throws { + let dep = Dependencies.initFs + let pullCommand = ImagePull(reference: dep.source) + print("Installing base container filesystem...") + do { + try await pullCommand.run() + } catch { + log.error("Failed to install base container filesystem: \(error)") + } + } + + private func installDefaultKernel() async throws { + let kernelDependency = Dependencies.kernel + let defaultKernelURL = kernelDependency.source + let defaultKernelBinaryPath = ClientDefaults.get(key: .defaultKernelBinaryPath) + + var shouldInstallKernel = false + if kernelInstall == nil { + print("No default kernel configured.") + print("Install the recommended default kernel from [\(kernelDependency.source)]? [Y/n]: ", terminator: "") + guard let read = readLine(strippingNewline: true) else { + throw ContainerizationError(.internalError, message: "Failed to read user input") + } + guard read.lowercased() == "y" || read.count == 0 else { + print("Please use the `container system kernel set --recommended` command to configure the default kernel") + return + } + shouldInstallKernel = true + } else { + shouldInstallKernel = kernelInstall ?? false + } + guard shouldInstallKernel else { + return + } + print("Installing kernel...") + try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: defaultKernelURL, kernelFilePath: defaultKernelBinaryPath) + } + + private func initImageExists() async -> Bool { + do { + let img = try await ClientImage.get(reference: Dependencies.initFs.source) + let _ = try await img.getSnapshot(platform: .current) + return true + } catch { + return false + } + } + + private func kernelExists() async -> Bool { + do { + try await ClientKernel.getDefaultKernel(for: .current) + return true + } catch { + return false + } + } + } + + private enum Dependencies: String { + case kernel + case initFs + + var source: String { + switch self { + case .initFs: + return ClientDefaults.get(key: .defaultInitImage) + case .kernel: + return ClientDefaults.get(key: .defaultKernelURL) + } + } + } +} diff --git a/Sources/ContainerCLI/System/SystemStatus.swift b/Sources/ContainerCLI/System/SystemStatus.swift new file mode 100644 index 000000000..132607681 --- /dev/null +++ b/Sources/ContainerCLI/System/SystemStatus.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin +import ContainerizationError +import Foundation +import Logging + +extension Application { + struct SystemStatus: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "status", + abstract: "Show the status of `container` services" + ) + + @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") + var prefix: String = "com.apple.container." + + func run() async throws { + let isRegistered = try ServiceManager.isRegistered(fullServiceLabel: "\(prefix)apiserver") + if !isRegistered { + print("apiserver is not running and not registered with launchd") + Application.exit(withError: ExitCode(1)) + } + + // Now ping our friendly daemon. Fail after 10 seconds with no response. + do { + print("Verifying apiserver is running...") + try await ClientHealthCheck.ping(timeout: .seconds(10)) + print("apiserver is running") + } catch { + print("apiserver is not running") + Application.exit(withError: ExitCode(1)) + } + } + } +} diff --git a/Sources/ContainerCLI/System/SystemStop.swift b/Sources/ContainerCLI/System/SystemStop.swift new file mode 100644 index 000000000..32824dd0c --- /dev/null +++ b/Sources/ContainerCLI/System/SystemStop.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin +import ContainerizationOS +import Foundation +import Logging + +extension Application { + struct SystemStop: AsyncParsableCommand { + private static let stopTimeoutSeconds: Int32 = 5 + private static let shutdownTimeoutSeconds: Int32 = 20 + + static let configuration = CommandConfiguration( + commandName: "stop", + abstract: "Stop all `container` services" + ) + + @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") + var prefix: String = "com.apple.container." + + func run() async throws { + let log = Logger( + label: "com.apple.container.cli", + factory: { label in + StreamLogHandler.standardOutput(label: label) + } + ) + + let launchdDomainString = try ServiceManager.getDomainString() + let fullLabel = "\(launchdDomainString)/\(prefix)apiserver" + + log.info("stopping containers", metadata: ["stopTimeoutSeconds": "\(Self.stopTimeoutSeconds)"]) + do { + let containers = try await ClientContainer.list() + let signal = try Signals.parseSignal("SIGTERM") + let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: signal) + let failed = try await ContainerStop.stopContainers(containers: containers, stopOptions: opts) + if !failed.isEmpty { + log.warning("some containers could not be stopped gracefully", metadata: ["ids": "\(failed)"]) + } + + } catch { + log.warning("failed to stop all containers", metadata: ["error": "\(error)"]) + } + + log.info("waiting for containers to exit") + do { + for _ in 0.. Date: Tue, 15 Jul 2025 22:48:16 -0600 Subject: [PATCH 58/80] Reapply "Update Package.swift" This reverts commit 909f1733c5b0e0538ee094f8539dc553211d0a7d. --- Package.swift | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/Package.swift b/Package.swift index 3cdd99578..189c77e6b 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,7 @@ let package = Package( .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService"]), .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerClient", targets: ["ContainerClient"]), - .library(name: "ContainerCLI", targets: ["ContainerCLI"]), +// .library(name: "ContainerCLI", targets: ["ContainerCLI"]), .library(name: "ContainerBuild", targets: ["ContainerBuild"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), @@ -73,26 +73,26 @@ let package = Package( ], path: "Sources/CLI" ), - .target( - name: "ContainerCLI", - dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "Logging", package: "swift-log"), - .product(name: "SwiftProtobuf", package: "swift-protobuf"), - .product(name: "Containerization", package: "containerization"), - .product(name: "ContainerizationOCI", package: "containerization"), - .product(name: "ContainerizationOS", package: "containerization"), - "CVersion", - "TerminalProgress", - "ContainerBuild", - "ContainerClient", - "ContainerPlugin", - "ContainerLog", - "Yams", - "Rainbow", - ], - path: "Sources/ContainerCLI" - ), +// .target( +// name: "ContainerCLI", +// dependencies: [ +// .product(name: "ArgumentParser", package: "swift-argument-parser"), +// .product(name: "Logging", package: "swift-log"), +// .product(name: "SwiftProtobuf", package: "swift-protobuf"), +// .product(name: "Containerization", package: "containerization"), +// .product(name: "ContainerizationOCI", package: "containerization"), +// .product(name: "ContainerizationOS", package: "containerization"), +// "CVersion", +// "TerminalProgress", +// "ContainerBuild", +// "ContainerClient", +// "ContainerPlugin", +// "ContainerLog", +// "Yams", +// "Rainbow", +// ], +// path: "Sources/ContainerCLI" +// ), .executableTarget( name: "container-apiserver", dependencies: [ From 7f690ef360a18599c2857644cddea33606721c81 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 22:48:19 -0600 Subject: [PATCH 59/80] Revert "Reapply "copy cli to container cli"" This reverts commit 2924fc013f5040a43d6e8e27f7f72a66159c3c9e. --- Sources/ContainerCLI | Bin 0 -> 916 bytes Sources/ContainerCLI/Application.swift | 335 -------- Sources/ContainerCLI/BuildCommand.swift | 313 -------- Sources/ContainerCLI/Builder/Builder.swift | 31 - .../ContainerCLI/Builder/BuilderDelete.swift | 57 -- .../ContainerCLI/Builder/BuilderStart.swift | 262 ------ .../ContainerCLI/Builder/BuilderStatus.swift | 71 -- .../ContainerCLI/Builder/BuilderStop.swift | 49 -- Sources/ContainerCLI/Codable+JSON.swift | 25 - .../Compose/Codable Structs/Build.swift | 52 -- .../Compose/Codable Structs/Config.swift | 55 -- .../Compose/Codable Structs/Deploy.swift | 35 - .../Codable Structs/DeployResources.swift | 31 - .../Codable Structs/DeployRestartPolicy.swift | 35 - .../Codable Structs/DeviceReservation.swift | 35 - .../Codable Structs/DockerCompose.swift | 60 -- .../Codable Structs/ExternalConfig.swift | 31 - .../Codable Structs/ExternalNetwork.swift | 31 - .../Codable Structs/ExternalSecret.swift | 31 - .../Codable Structs/ExternalVolume.swift | 31 - .../Compose/Codable Structs/Healthcheck.swift | 37 - .../Compose/Codable Structs/Network.swift | 68 -- .../Codable Structs/ResourceLimits.swift | 31 - .../ResourceReservations.swift | 34 - .../Compose/Codable Structs/Secret.swift | 58 -- .../Compose/Codable Structs/Service.swift | 203 ----- .../Codable Structs/ServiceConfig.swift | 64 -- .../Codable Structs/ServiceSecret.swift | 64 -- .../Compose/Codable Structs/Volume.swift | 70 -- .../Compose/Commands/ComposeDown.swift | 105 --- .../Compose/Commands/ComposeUp.swift | 749 ------------------ .../ContainerCLI/Compose/ComposeCommand.swift | 55 -- Sources/ContainerCLI/Compose/Errors.swift | 66 -- .../Compose/Helper Functions.swift | 96 --- .../Container/ContainerCreate.swift | 100 --- .../Container/ContainerDelete.swift | 127 --- .../Container/ContainerExec.swift | 96 --- .../Container/ContainerInspect.swift | 43 - .../Container/ContainerKill.swift | 79 -- .../Container/ContainerList.swift | 110 --- .../Container/ContainerLogs.swift | 144 ---- .../Container/ContainerStart.swift | 87 -- .../Container/ContainerStop.swift | 102 --- .../Container/ContainersCommand.swift | 38 - .../ContainerCLI/Container/ProcessUtils.swift | 31 - Sources/ContainerCLI/DefaultCommand.swift | 54 -- Sources/ContainerCLI/Image/ImageInspect.swift | 53 -- Sources/ContainerCLI/Image/ImageList.swift | 175 ---- Sources/ContainerCLI/Image/ImageLoad.swift | 76 -- Sources/ContainerCLI/Image/ImagePrune.swift | 38 - Sources/ContainerCLI/Image/ImagePull.swift | 98 --- Sources/ContainerCLI/Image/ImagePush.swift | 73 -- Sources/ContainerCLI/Image/ImageRemove.swift | 99 --- Sources/ContainerCLI/Image/ImageSave.swift | 67 -- Sources/ContainerCLI/Image/ImageTag.swift | 42 - .../ContainerCLI/Image/ImagesCommand.swift | 38 - .../ContainerCLI/Network/NetworkCommand.swift | 33 - .../ContainerCLI/Network/NetworkCreate.swift | 42 - .../ContainerCLI/Network/NetworkDelete.swift | 116 --- .../ContainerCLI/Network/NetworkInspect.swift | 44 - .../ContainerCLI/Network/NetworkList.swift | 107 --- Sources/ContainerCLI/Registry/Login.swift | 92 --- Sources/ContainerCLI/Registry/Logout.swift | 39 - .../Registry/RegistryCommand.swift | 32 - .../Registry/RegistryDefault.swift | 98 --- Sources/ContainerCLI/RunCommand.swift | 317 -------- .../ContainerCLI/System/DNS/DNSCreate.swift | 51 -- .../ContainerCLI/System/DNS/DNSDefault.swift | 72 -- .../ContainerCLI/System/DNS/DNSDelete.swift | 49 -- Sources/ContainerCLI/System/DNS/DNSList.swift | 36 - .../System/Kernel/KernelSet.swift | 114 --- .../ContainerCLI/System/SystemCommand.swift | 35 - Sources/ContainerCLI/System/SystemDNS.swift | 34 - .../ContainerCLI/System/SystemKernel.swift | 29 - Sources/ContainerCLI/System/SystemLogs.swift | 82 -- Sources/ContainerCLI/System/SystemStart.swift | 170 ---- .../ContainerCLI/System/SystemStatus.swift | 52 -- Sources/ContainerCLI/System/SystemStop.swift | 91 --- 78 files changed, 6775 deletions(-) create mode 100644 Sources/ContainerCLI delete mode 100644 Sources/ContainerCLI/Application.swift delete mode 100644 Sources/ContainerCLI/BuildCommand.swift delete mode 100644 Sources/ContainerCLI/Builder/Builder.swift delete mode 100644 Sources/ContainerCLI/Builder/BuilderDelete.swift delete mode 100644 Sources/ContainerCLI/Builder/BuilderStart.swift delete mode 100644 Sources/ContainerCLI/Builder/BuilderStatus.swift delete mode 100644 Sources/ContainerCLI/Builder/BuilderStop.swift delete mode 100644 Sources/ContainerCLI/Codable+JSON.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Build.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Config.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Network.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Secret.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Service.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift delete mode 100644 Sources/ContainerCLI/Compose/Codable Structs/Volume.swift delete mode 100644 Sources/ContainerCLI/Compose/Commands/ComposeDown.swift delete mode 100644 Sources/ContainerCLI/Compose/Commands/ComposeUp.swift delete mode 100644 Sources/ContainerCLI/Compose/ComposeCommand.swift delete mode 100644 Sources/ContainerCLI/Compose/Errors.swift delete mode 100644 Sources/ContainerCLI/Compose/Helper Functions.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerCreate.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerDelete.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerExec.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerInspect.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerKill.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerList.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerLogs.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerStart.swift delete mode 100644 Sources/ContainerCLI/Container/ContainerStop.swift delete mode 100644 Sources/ContainerCLI/Container/ContainersCommand.swift delete mode 100644 Sources/ContainerCLI/Container/ProcessUtils.swift delete mode 100644 Sources/ContainerCLI/DefaultCommand.swift delete mode 100644 Sources/ContainerCLI/Image/ImageInspect.swift delete mode 100644 Sources/ContainerCLI/Image/ImageList.swift delete mode 100644 Sources/ContainerCLI/Image/ImageLoad.swift delete mode 100644 Sources/ContainerCLI/Image/ImagePrune.swift delete mode 100644 Sources/ContainerCLI/Image/ImagePull.swift delete mode 100644 Sources/ContainerCLI/Image/ImagePush.swift delete mode 100644 Sources/ContainerCLI/Image/ImageRemove.swift delete mode 100644 Sources/ContainerCLI/Image/ImageSave.swift delete mode 100644 Sources/ContainerCLI/Image/ImageTag.swift delete mode 100644 Sources/ContainerCLI/Image/ImagesCommand.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkCommand.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkCreate.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkDelete.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkInspect.swift delete mode 100644 Sources/ContainerCLI/Network/NetworkList.swift delete mode 100644 Sources/ContainerCLI/Registry/Login.swift delete mode 100644 Sources/ContainerCLI/Registry/Logout.swift delete mode 100644 Sources/ContainerCLI/Registry/RegistryCommand.swift delete mode 100644 Sources/ContainerCLI/Registry/RegistryDefault.swift delete mode 100644 Sources/ContainerCLI/RunCommand.swift delete mode 100644 Sources/ContainerCLI/System/DNS/DNSCreate.swift delete mode 100644 Sources/ContainerCLI/System/DNS/DNSDefault.swift delete mode 100644 Sources/ContainerCLI/System/DNS/DNSDelete.swift delete mode 100644 Sources/ContainerCLI/System/DNS/DNSList.swift delete mode 100644 Sources/ContainerCLI/System/Kernel/KernelSet.swift delete mode 100644 Sources/ContainerCLI/System/SystemCommand.swift delete mode 100644 Sources/ContainerCLI/System/SystemDNS.swift delete mode 100644 Sources/ContainerCLI/System/SystemKernel.swift delete mode 100644 Sources/ContainerCLI/System/SystemLogs.swift delete mode 100644 Sources/ContainerCLI/System/SystemStart.swift delete mode 100644 Sources/ContainerCLI/System/SystemStatus.swift delete mode 100644 Sources/ContainerCLI/System/SystemStop.swift diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI new file mode 100644 index 0000000000000000000000000000000000000000..ce9b7e01c3c1838dc5198f48c2a927a02ac3d673 GIT binary patch literal 916 zcmZvayG{a85Qfis#YR^#YUjq-5|G=16&H;fO)L~bVS~#OiNKOYV`GfI0TyN?bbnot|+K=eGCL%gLVKhi6YPCaIXpJU4ma;2bxp~)HIHT!3#7@5p{B1!!f z3@u+Mn(JP#*YOcAWsUs<%^#NUE5uXa0=50~ynH?1!C$5Qs80lfn+cz;dxCm2=n2O4 zkSCh-M?Hz8KN1eDg*L(oU7r1h_CRm$;gw?4%Zru1gMxR9ZIv)~5v z9Jt2p2G^N2@GG+qEc+`>+(C}df{}7;u8Em&Txk#LRj~ZNi|@U=OB+_eE*{P=_%T-2 literal 0 HcmV?d00001 diff --git a/Sources/ContainerCLI/Application.swift b/Sources/ContainerCLI/Application.swift deleted file mode 100644 index ba002a74c..000000000 --- a/Sources/ContainerCLI/Application.swift +++ /dev/null @@ -1,335 +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 CVersion -import ContainerClient -import ContainerLog -import ContainerPlugin -import ContainerizationError -import ContainerizationOS -import Foundation -import Logging -import TerminalProgress - -// `log` is updated only once in the `validate()` method. -nonisolated(unsafe) var log = { - LoggingSystem.bootstrap { label in - OSLogHandler( - label: label, - category: "CLI" - ) - } - var log = Logger(label: "com.apple.container") - log.logLevel = .debug - return log -}() - -@main -public struct Application: AsyncParsableCommand { - public init() {} - - @OptionGroup - var global: Flags.Global - - public static let configuration = CommandConfiguration( - commandName: "container", - abstract: "A container platform for macOS", - version: releaseVersion(), - subcommands: [ - DefaultCommand.self - ], - groupedSubcommands: [ - CommandGroup( - name: "Container", - subcommands: [ - ComposeCommand.self, - ContainerCreate.self, - ContainerDelete.self, - ContainerExec.self, - ContainerInspect.self, - ContainerKill.self, - ContainerList.self, - ContainerLogs.self, - ContainerRunCommand.self, - ContainerStart.self, - ContainerStop.self, - ] - ), - CommandGroup( - name: "Image", - subcommands: [ - BuildCommand.self, - ImagesCommand.self, - RegistryCommand.self, - ] - ), - CommandGroup( - name: "Other", - subcommands: Self.otherCommands() - ), - ], - // Hidden command to handle plugins on unrecognized input. - defaultSubcommand: DefaultCommand.self - ) - - static let appRoot: URL = { - FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first! - .appendingPathComponent("com.apple.container") - }() - - static let pluginLoader: PluginLoader = { - // create user-installed plugins directory if it doesn't exist - let pluginsURL = PluginLoader.userPluginsDir(root: Self.appRoot) - try! FileManager.default.createDirectory(at: pluginsURL, withIntermediateDirectories: true) - let pluginDirectories = [ - pluginsURL - ] - let pluginFactories = [ - DefaultPluginFactory() - ] - - let statePath = PluginLoader.defaultPluginResourcePath(root: Self.appRoot) - try! FileManager.default.createDirectory(at: statePath, withIntermediateDirectories: true) - return PluginLoader(pluginDirectories: pluginDirectories, pluginFactories: pluginFactories, defaultResourcePath: statePath, log: log) - }() - - public static func main() async throws { - restoreCursorAtExit() - - #if DEBUG - let warning = "Running debug build. Performance may be degraded." - let formattedWarning = "\u{001B}[33mWarning!\u{001B}[0m \(warning)\n" - let warningData = Data(formattedWarning.utf8) - FileHandle.standardError.write(warningData) - #endif - - let fullArgs = CommandLine.arguments - let args = Array(fullArgs.dropFirst()) - - do { - // container -> defaultHelpCommand - var command = try Application.parseAsRoot(args) - if var asyncCommand = command as? AsyncParsableCommand { - try await asyncCommand.run() - } else { - try command.run() - } - } catch { - // Regular ol `command` with no args will get caught by DefaultCommand. --help - // on the root command will land here. - let containsHelp = fullArgs.contains("-h") || fullArgs.contains("--help") - if fullArgs.count <= 2 && containsHelp { - Self.printModifiedHelpText() - return - } - let errorAsString: String = String(describing: error) - if errorAsString.contains("XPC connection error") { - let modifiedError = ContainerizationError(.interrupted, message: "\(error)\nEnsure container system service has been started with `container system start`.") - Application.exit(withError: modifiedError) - } else { - Application.exit(withError: error) - } - } - } - - static func handleProcess(io: ProcessIO, process: ClientProcess) async throws -> Int32 { - let signals = AsyncSignalHandler.create(notify: Application.signalSet) - return try await withThrowingTaskGroup(of: Int32?.self, returning: Int32.self) { group in - let waitAdded = group.addTaskUnlessCancelled { - let code = try await process.wait() - try await io.wait() - return code - } - - guard waitAdded else { - group.cancelAll() - return -1 - } - - try await process.start(io.stdio) - defer { - try? io.close() - } - try io.closeAfterStart() - - if let current = io.console { - let size = try current.size - // It's supremely possible the process could've exited already. We shouldn't treat - // this as fatal. - try? await process.resize(size) - _ = group.addTaskUnlessCancelled { - let winchHandler = AsyncSignalHandler.create(notify: [SIGWINCH]) - for await _ in winchHandler.signals { - do { - try await process.resize(try current.size) - } catch { - log.error( - "failed to send terminal resize event", - metadata: [ - "error": "\(error)" - ] - ) - } - } - return nil - } - } else { - _ = group.addTaskUnlessCancelled { - for await sig in signals.signals { - do { - try await process.kill(sig) - } catch { - log.error( - "failed to send signal", - metadata: [ - "signal": "\(sig)", - "error": "\(error)", - ] - ) - } - } - return nil - } - } - - while true { - let result = try await group.next() - if result == nil { - return -1 - } - let status = result! - if let status { - group.cancelAll() - return status - } - } - return -1 - } - } - - public func validate() throws { - // Not really a "validation", but a cheat to run this before - // any of the commands do their business. - let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"] - if self.global.debug || debugEnvVar != nil { - log.logLevel = .debug - } - // Ensure we're not running under Rosetta. - if try isTranslated() { - throw ValidationError( - """ - `container` is currently running under Rosetta Translation, which could be - caused by your terminal application. Please ensure this is turned off. - """ - ) - } - } - - private static func otherCommands() -> [any ParsableCommand.Type] { - guard #available(macOS 26, *) else { - return [ - BuilderCommand.self, - SystemCommand.self, - ] - } - - return [ - BuilderCommand.self, - NetworkCommand.self, - SystemCommand.self, - ] - } - - private static func restoreCursorAtExit() { - let signalHandler: @convention(c) (Int32) -> Void = { signal in - let exitCode = ExitCode(signal + 128) - Application.exit(withError: exitCode) - } - // Termination by Ctrl+C. - signal(SIGINT, signalHandler) - // Termination using `kill`. - signal(SIGTERM, signalHandler) - // Normal and explicit exit. - atexit { - if let progressConfig = try? ProgressConfig() { - let progressBar = ProgressBar(config: progressConfig) - progressBar.resetCursor() - } - } - } -} - -extension Application { - // Because we support plugins, we need to modify the help text to display - // any if we found some. - static func printModifiedHelpText() { - let altered = Self.pluginLoader.alterCLIHelpText( - original: Application.helpMessage(for: Application.self) - ) - print(altered) - } - - enum ListFormat: String, CaseIterable, ExpressibleByArgument { - case json - case table - } - - static let signalSet: [Int32] = [ - SIGTERM, - SIGINT, - SIGUSR1, - SIGUSR2, - SIGWINCH, - ] - - func isTranslated() throws -> Bool { - do { - return try Sysctl.byName("sysctl.proc_translated") == 1 - } catch let posixErr as POSIXError { - if posixErr.code == .ENOENT { - return false - } - throw posixErr - } - } - - private static func releaseVersion() -> String { - var versionDetails: [String: String] = ["build": "release"] - #if DEBUG - versionDetails["build"] = "debug" - #endif - let gitCommit = { - let sha = get_git_commit().map { String(cString: $0) } - guard let sha else { - return "unspecified" - } - return String(sha.prefix(7)) - }() - versionDetails["commit"] = gitCommit - let extras: String = versionDetails.map { "\($0): \($1)" }.sorted().joined(separator: ", ") - - let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) - let releaseVersion = bundleVersion ?? get_release_version().map { String(cString: $0) } ?? "0.0.0" - - return "container CLI version \(releaseVersion) (\(extras))" - } -} diff --git a/Sources/ContainerCLI/BuildCommand.swift b/Sources/ContainerCLI/BuildCommand.swift deleted file mode 100644 index a1e84c258..000000000 --- a/Sources/ContainerCLI/BuildCommand.swift +++ /dev/null @@ -1,313 +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 ContainerBuild -import ContainerClient -import ContainerImagesServiceClient -import Containerization -import ContainerizationError -import ContainerizationOCI -import ContainerizationOS -import Foundation -import NIO -import TerminalProgress - -extension Application { - struct BuildCommand: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "build" - config.abstract = "Build an image from a Dockerfile" - config._superCommandName = "container" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") - public var cpus: Int64 = 2 - - @Option( - name: [.customLong("memory"), .customShort("m")], - help: - "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" - ) - var memory: String = "2048MB" - - @Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val")) - var buildArg: [String] = [] - - @Argument(help: "Build directory") - var contextDir: String = "." - - @Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path")) - var file: String = "Dockerfile" - - @Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val")) - var label: [String] = [] - - @Flag(name: .long, help: "Do not use cache") - var noCache: Bool = false - - @Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build", valueName: "value")) - var output: [String] = { - ["type=oci"] - }() - - @Option(name: .long, help: ArgumentHelp("Cache imports for the build", valueName: "value", visibility: .hidden)) - var cacheIn: [String] = { - [] - }() - - @Option(name: .long, help: ArgumentHelp("Cache exports for the build", valueName: "value", visibility: .hidden)) - var cacheOut: [String] = { - [] - }() - - @Option(name: .long, help: ArgumentHelp("set the build architecture", valueName: "value")) - var arch: [String] = { - ["arm64"] - }() - - @Option(name: .long, help: ArgumentHelp("set the build os", valueName: "value")) - var os: [String] = { - ["linux"] - }() - - @Option(name: .long, help: ArgumentHelp("Progress type - one of [auto|plain|tty]", valueName: "type")) - var progress: String = "auto" - - @Option(name: .long, help: ArgumentHelp("Builder-shim vsock port", valueName: "port")) - var vsockPort: UInt32 = 8088 - - @Option(name: [.customShort("t"), .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) - var targetImageName: String = UUID().uuidString.lowercased() - - @Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage")) - var target: String = "" - - @Flag(name: .shortAndLong, help: "Suppress build output") - var quiet: Bool = false - - func run() async throws { - do { - let timeout: Duration = .seconds(300) - let progressConfig = try ProgressConfig( - showTasks: true, - showItems: true - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - progress.set(description: "Dialing builder") - - let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { group in - defer { - group.cancelAll() - } - - group.addTask { - while true { - do { - let container = try await ClientContainer.get(id: "buildkit") - let fh = try await container.dial(self.vsockPort) - - let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let b = try Builder(socket: fh, group: threadGroup) - - // If this call succeeds, then BuildKit is running. - let _ = try await b.info() - return b - } catch { - // If we get here, "Dialing builder" is shown for such a short period - // of time that it's invisible to the user. - progress.set(tasks: 0) - progress.set(totalTasks: 3) - - try await BuilderStart.start( - cpus: self.cpus, - memory: self.memory, - progressUpdate: progress.handler - ) - - // wait (seconds) for builder to start listening on vsock - try await Task.sleep(for: .seconds(5)) - continue - } - } - } - - group.addTask { - try await Task.sleep(for: timeout) - throw ValidationError( - """ - Timeout waiting for connection to builder - """ - ) - } - - return try await group.next() - } - - guard let builder else { - throw ValidationError("builder is not running") - } - - let dockerfile = try Data(contentsOf: URL(filePath: file)) - let exportPath = Application.appRoot.appendingPathComponent(".build") - - let buildID = UUID().uuidString - let tempURL = exportPath.appendingPathComponent(buildID) - try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil) - defer { - try? FileManager.default.removeItem(at: tempURL) - } - - let imageName: String = try { - let parsedReference = try Reference.parse(targetImageName) - parsedReference.normalize() - return parsedReference.description - }() - - var terminal: Terminal? - switch self.progress { - case "tty": - terminal = try Terminal(descriptor: STDERR_FILENO) - case "auto": - terminal = try? Terminal(descriptor: STDERR_FILENO) - case "plain": - terminal = nil - default: - throw ContainerizationError(.invalidArgument, message: "invalid progress mode \(self.progress)") - } - - defer { terminal?.tryReset() } - - let exports: [Builder.BuildExport] = try output.map { output in - var exp = try Builder.BuildExport(from: output) - if exp.destination == nil { - exp.destination = tempURL.appendingPathComponent("out.tar") - } - return exp - } - - try await withThrowingTaskGroup(of: Void.self) { [terminal] group in - defer { - group.cancelAll() - } - group.addTask { - let handler = AsyncSignalHandler.create(notify: [SIGTERM, SIGINT, SIGUSR1, SIGUSR2]) - for await sig in handler.signals { - throw ContainerizationError(.interrupted, message: "exiting on signal \(sig)") - } - } - let platforms: [Platform] = try { - var results: [Platform] = [] - for o in self.os { - for a in self.arch { - guard let platform = try? Platform(from: "\(o)/\(a)") else { - throw ValidationError("invalid os/architecture combination \(o)/\(a)") - } - results.append(platform) - } - } - return results - }() - group.addTask { [terminal] in - let config = ContainerBuild.Builder.BuildConfig( - buildID: buildID, - contentStore: RemoteContentStoreClient(), - buildArgs: buildArg, - contextDir: contextDir, - dockerfile: dockerfile, - labels: label, - noCache: noCache, - platforms: platforms, - terminal: terminal, - tag: imageName, - target: target, - quiet: quiet, - exports: exports, - cacheIn: cacheIn, - cacheOut: cacheOut - ) - progress.finish() - - try await builder.build(config) - } - - try await group.next() - } - - let unpackProgressConfig = try ProgressConfig( - description: "Unpacking built image", - itemsName: "entries", - showTasks: exports.count > 1, - totalTasks: exports.count - ) - let unpackProgress = ProgressBar(config: unpackProgressConfig) - defer { - unpackProgress.finish() - } - unpackProgress.start() - - let taskManager = ProgressTaskCoordinator() - // Currently, only a single export can be specified. - for exp in exports { - unpackProgress.add(tasks: 1) - let unpackTask = await taskManager.startTask() - switch exp.type { - case "oci": - try Task.checkCancellation() - guard let dest = exp.destination else { - throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)") - } - let loaded = try await ClientImage.load(from: dest.absolutePath()) - - for image in loaded { - try Task.checkCancellation() - try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler)) - } - case "tar": - break - default: - throw ContainerizationError(.invalidArgument, message: "invalid exporter \(exp.rawValue)") - } - } - await taskManager.finish() - unpackProgress.finish() - print("Successfully built \(imageName)") - } catch { - throw NSError(domain: "Build", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(error)"]) - } - } - - func validate() throws { - guard FileManager.default.fileExists(atPath: file) else { - throw ValidationError("Dockerfile does not exist at path: \(file)") - } - guard FileManager.default.fileExists(atPath: contextDir) else { - throw ValidationError("context dir does not exist \(contextDir)") - } - guard let _ = try? Reference.parse(targetImageName) else { - throw ValidationError("invalid reference \(targetImageName)") - } - } - } -} diff --git a/Sources/ContainerCLI/Builder/Builder.swift b/Sources/ContainerCLI/Builder/Builder.swift deleted file mode 100644 index ad9eb6c97..000000000 --- a/Sources/ContainerCLI/Builder/Builder.swift +++ /dev/null @@ -1,31 +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 - -extension Application { - struct BuilderCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "builder", - abstract: "Manage an image builder instance", - subcommands: [ - BuilderStart.self, - BuilderStatus.self, - BuilderStop.self, - BuilderDelete.self, - ]) - } -} diff --git a/Sources/ContainerCLI/Builder/BuilderDelete.swift b/Sources/ContainerCLI/Builder/BuilderDelete.swift deleted file mode 100644 index e848da95e..000000000 --- a/Sources/ContainerCLI/Builder/BuilderDelete.swift +++ /dev/null @@ -1,57 +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 ContainerizationError -import Foundation - -extension Application { - struct BuilderDelete: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "delete" - config._superCommandName = "builder" - config.abstract = "Delete builder" - config.usage = "\n\t builder delete [command options]" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - @Flag(name: .shortAndLong, help: "Force delete builder even if it is running") - var force = false - - func run() async throws { - do { - let container = try await ClientContainer.get(id: "buildkit") - if container.status != .stopped { - guard force else { - throw ContainerizationError(.invalidState, message: "BuildKit container is not stopped, use --force to override") - } - try await container.stop() - } - try await container.delete() - } catch { - if error is ContainerizationError { - if (error as? ContainerizationError)?.code == .notFound { - return - } - } - throw error - } - } - } -} diff --git a/Sources/ContainerCLI/Builder/BuilderStart.swift b/Sources/ContainerCLI/Builder/BuilderStart.swift deleted file mode 100644 index 5800b712e..000000000 --- a/Sources/ContainerCLI/Builder/BuilderStart.swift +++ /dev/null @@ -1,262 +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 ContainerBuild -import ContainerClient -import ContainerNetworkService -import Containerization -import ContainerizationError -import ContainerizationExtras -import ContainerizationOCI -import Foundation -import TerminalProgress - -extension Application { - struct BuilderStart: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "start" - config._superCommandName = "builder" - config.abstract = "Start builder" - config.usage = "\nbuilder start [command options]" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") - public var cpus: Int64 = 2 - - @Option( - name: [.customLong("memory"), .customShort("m")], - help: - "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" - ) - public var memory: String = "2048MB" - - func run() async throws { - let progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - totalTasks: 4 - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - try await Self.start(cpus: self.cpus, memory: self.memory, progressUpdate: progress.handler) - progress.finish() - } - - static func start(cpus: Int64?, memory: String?, progressUpdate: @escaping ProgressUpdateHandler) async throws { - await progressUpdate([ - .setDescription("Fetching BuildKit image"), - .setItemsName("blobs"), - ]) - let taskManager = ProgressTaskCoordinator() - let fetchTask = await taskManager.startTask() - - let builderImage: String = ClientDefaults.get(key: .defaultBuilderImage) - let exportsMount: String = Application.appRoot.appendingPathComponent(".build").absolutePath() - - if !FileManager.default.fileExists(atPath: exportsMount) { - try FileManager.default.createDirectory( - atPath: exportsMount, - withIntermediateDirectories: true, - attributes: nil - ) - } - - let builderPlatform = ContainerizationOCI.Platform(arch: "arm64", os: "linux", variant: "v8") - - let existingContainer = try? await ClientContainer.get(id: "buildkit") - if let existingContainer { - let existingImage = existingContainer.configuration.image.reference - let existingResources = existingContainer.configuration.resources - - // Check if we need to recreate the builder due to different image - let imageChanged = existingImage != builderImage - let cpuChanged = { - if let cpus { - if existingResources.cpus != cpus { - return true - } - } - return false - }() - let memChanged = try { - if let memory { - let memoryInBytes = try Parser.resources(cpus: nil, memory: memory).memoryInBytes - if existingResources.memoryInBytes != memoryInBytes { - return true - } - } - return false - }() - - switch existingContainer.status { - case .running: - guard imageChanged || cpuChanged || memChanged else { - // If image, mem and cpu are the same, continue using the existing builder - return - } - // If they changed, stop and delete the existing builder - try await existingContainer.stop() - try await existingContainer.delete() - case .stopped: - // If the builder is stopped and matches our requirements, start it - // Otherwise, delete it and create a new one - guard imageChanged || cpuChanged || memChanged else { - try await existingContainer.startBuildKit(progressUpdate, nil) - return - } - try await existingContainer.delete() - case .stopping: - throw ContainerizationError( - .invalidState, - message: "builder is stopping, please wait until it is fully stopped before proceeding" - ) - case .unknown: - break - } - } - - let shimArguments: [String] = [ - "--debug", - "--vsock", - ] - - let id = "buildkit" - try ContainerClient.Utility.validEntityName(id) - - let processConfig = ProcessConfiguration( - executable: "/usr/local/bin/container-builder-shim", - arguments: shimArguments, - environment: [], - workingDirectory: "/", - terminal: false, - user: .id(uid: 0, gid: 0) - ) - - let resources = try Parser.resources( - cpus: cpus, - memory: memory - ) - - let image = try await ClientImage.fetch( - reference: builderImage, - platform: builderPlatform, - progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate) - ) - // Unpack fetched image before use - await progressUpdate([ - .setDescription("Unpacking BuildKit image"), - .setItemsName("entries"), - ]) - - let unpackTask = await taskManager.startTask() - _ = try await image.getCreateSnapshot( - platform: builderPlatform, - progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate) - ) - let imageConfig = ImageDescription( - reference: builderImage, - descriptor: image.descriptor - ) - - var config = ContainerConfiguration(id: id, image: imageConfig, process: processConfig) - config.resources = resources - config.mounts = [ - .init( - type: .tmpfs, - source: "", - destination: "/run", - options: [] - ), - .init( - type: .virtiofs, - source: exportsMount, - destination: "/var/lib/container-builder-shim/exports", - options: [] - ), - ] - // Enable Rosetta only if the user didn't ask to disable it - config.rosetta = ClientDefaults.getBool(key: .buildRosetta) ?? true - - let network = try await ClientNetwork.get(id: ClientNetwork.defaultNetworkName) - guard case .running(_, let networkStatus) = network else { - throw ContainerizationError(.invalidState, message: "default network is not running") - } - config.networks = [network.id] - let subnet = try CIDRAddress(networkStatus.address) - let nameserver = IPv4Address(fromValue: subnet.lower.value + 1).description - let nameservers = [nameserver] - config.dns = ContainerConfiguration.DNSConfiguration(nameservers: nameservers) - - let kernel = try await { - await progressUpdate([ - .setDescription("Fetching kernel"), - .setItemsName("binary"), - ]) - - let kernel = try await ClientKernel.getDefaultKernel(for: .current) - return kernel - }() - - await progressUpdate([ - .setDescription("Starting BuildKit container") - ]) - - let container = try await ClientContainer.create( - configuration: config, - options: .default, - kernel: kernel - ) - - try await container.startBuildKit(progressUpdate, taskManager) - } - } -} - -// MARK: - ClientContainer Extension for BuildKit - -extension ClientContainer { - /// Starts the BuildKit process within the container - /// This method handles bootstrapping the container and starting the BuildKit process - fileprivate func startBuildKit(_ progress: @escaping ProgressUpdateHandler, _ taskManager: ProgressTaskCoordinator? = nil) async throws { - do { - let io = try ProcessIO.create( - tty: false, - interactive: false, - detach: true - ) - defer { try? io.close() } - let process = try await bootstrap() - _ = try await process.start(io.stdio) - await taskManager?.finish() - try io.closeAfterStart() - log.debug("starting BuildKit and BuildKit-shim") - } catch { - try? await stop() - try? await delete() - if error is ContainerizationError { - throw error - } - throw ContainerizationError(.internalError, message: "failed to start BuildKit: \(error)") - } - } -} diff --git a/Sources/ContainerCLI/Builder/BuilderStatus.swift b/Sources/ContainerCLI/Builder/BuilderStatus.swift deleted file mode 100644 index b1210a3dd..000000000 --- a/Sources/ContainerCLI/Builder/BuilderStatus.swift +++ /dev/null @@ -1,71 +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 ContainerizationError -import Foundation - -extension Application { - struct BuilderStatus: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "status" - config._superCommandName = "builder" - config.abstract = "Print builder status" - config.usage = "\n\t builder status [command options]" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - @Flag(name: .long, help: ArgumentHelp("Display detailed status in json format")) - var json: Bool = false - - func run() async throws { - do { - let container = try await ClientContainer.get(id: "buildkit") - if json { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let jsonData = try encoder.encode(container) - - guard let jsonString = String(data: jsonData, encoding: .utf8) else { - throw ContainerizationError(.internalError, message: "failed to encode BuildKit container as json") - } - print(jsonString) - return - } - - let image = container.configuration.image.reference - let resources = container.configuration.resources - let cpus = resources.cpus - let memory = resources.memoryInBytes / (1024 * 1024) // bytes to MB - let addr = "" - - print("ID IMAGE STATE ADDR CPUS MEMORY") - print("\(container.id) \(image) \(container.status.rawValue.uppercased()) \(addr) \(cpus) \(memory) MB") - } catch { - if error is ContainerizationError { - if (error as? ContainerizationError)?.code == .notFound { - print("builder is not running") - return - } - } - throw error - } - } - } -} diff --git a/Sources/ContainerCLI/Builder/BuilderStop.swift b/Sources/ContainerCLI/Builder/BuilderStop.swift deleted file mode 100644 index e7484c9c1..000000000 --- a/Sources/ContainerCLI/Builder/BuilderStop.swift +++ /dev/null @@ -1,49 +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 ContainerizationError -import Foundation - -extension Application { - struct BuilderStop: AsyncParsableCommand { - public static var configuration: CommandConfiguration { - var config = CommandConfiguration() - config.commandName = "stop" - config._superCommandName = "builder" - config.abstract = "Stop builder" - config.usage = "\n\t builder stop" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) - return config - } - - func run() async throws { - do { - let container = try await ClientContainer.get(id: "buildkit") - try await container.stop() - } catch { - if error is ContainerizationError { - if (error as? ContainerizationError)?.code == .notFound { - print("builder is not running") - return - } - } - throw error - } - } - } -} diff --git a/Sources/ContainerCLI/Codable+JSON.swift b/Sources/ContainerCLI/Codable+JSON.swift deleted file mode 100644 index 60cbd04d7..000000000 --- a/Sources/ContainerCLI/Codable+JSON.swift +++ /dev/null @@ -1,25 +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 Foundation - -extension [any Codable] { - func jsonArray() throws -> String { - "[\(try self.map { String(data: try JSONEncoder().encode($0), encoding: .utf8)! }.joined(separator: ","))]" - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Build.swift b/Sources/ContainerCLI/Compose/Codable Structs/Build.swift deleted file mode 100644 index 5dc9a7ffa..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Build.swift +++ /dev/null @@ -1,52 +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. -//===----------------------------------------------------------------------===// - -// -// Build.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents the `build` configuration for a service. -struct Build: Codable, Hashable { - /// Path to the build context - let context: String - /// Optional path to the Dockerfile within the context - let dockerfile: String? - /// Build arguments - let args: [String: String]? - - /// Custom initializer to handle `build: .` (string) or `build: { context: . }` (object) - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let contextString = try? container.decode(String.self) { - self.context = contextString - self.dockerfile = nil - self.args = nil - } else { - let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) - self.context = try keyedContainer.decode(String.self, forKey: .context) - self.dockerfile = try keyedContainer.decodeIfPresent(String.self, forKey: .dockerfile) - self.args = try keyedContainer.decodeIfPresent([String: String].self, forKey: .args) - } - } - - enum CodingKeys: String, CodingKey { - case context, dockerfile, args - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Config.swift b/Sources/ContainerCLI/Compose/Codable Structs/Config.swift deleted file mode 100644 index 6b982bfdb..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Config.swift +++ /dev/null @@ -1,55 +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. -//===----------------------------------------------------------------------===// - -// -// Config.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a top-level config definition (primarily for Swarm). -struct Config: Codable { - /// Path to the file containing the config content - let file: String? - /// Indicates if the config is external (pre-existing) - let external: ExternalConfig? - /// Explicit name for the config - let name: String? - /// Labels for the config - let labels: [String: String]? - - enum CodingKeys: String, CodingKey { - case file, external, name, labels - } - - /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_cfg" }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - file = try container.decodeIfPresent(String.self, forKey: .file) - name = try container.decodeIfPresent(String.self, forKey: .name) - labels = try container.decodeIfPresent([String: String].self, forKey: .labels) - - if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { - external = ExternalConfig(isExternal: externalBool, name: nil) - } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { - external = ExternalConfig(isExternal: true, name: externalDict["name"]) - } else { - external = nil - } - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift b/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift deleted file mode 100644 index d30f9ffa8..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Deploy.swift +++ /dev/null @@ -1,35 +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. -//===----------------------------------------------------------------------===// - -// -// Deploy.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents the `deploy` configuration for a service (primarily for Swarm orchestration). -struct Deploy: Codable, Hashable { - /// Deployment mode (e.g., 'replicated', 'global') - let mode: String? - /// Number of replicated service tasks - let replicas: Int? - /// Resource constraints (limits, reservations) - let resources: DeployResources? - /// Restart policy for tasks - let restart_policy: DeployRestartPolicy? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift deleted file mode 100644 index 370e61a46..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/DeployResources.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// DeployResources.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Resource constraints for deployment. -struct DeployResources: Codable, Hashable { - /// Hard limits on resources - let limits: ResourceLimits? - /// Guarantees for resources - let reservations: ResourceReservations? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift deleted file mode 100644 index 56daa6573..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/DeployRestartPolicy.swift +++ /dev/null @@ -1,35 +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. -//===----------------------------------------------------------------------===// - -// -// DeployRestartPolicy.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Restart policy for deployed tasks. -struct DeployRestartPolicy: Codable, Hashable { - /// Condition to restart on (e.g., 'on-failure', 'any') - let condition: String? - /// Delay before attempting restart - let delay: String? - /// Maximum number of restart attempts - let max_attempts: Int? - /// Window to evaluate restart policy - let window: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift b/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift deleted file mode 100644 index 47a58acad..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/DeviceReservation.swift +++ /dev/null @@ -1,35 +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. -//===----------------------------------------------------------------------===// - -// -// DeviceReservation.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Device reservations for GPUs or other devices. -struct DeviceReservation: Codable, Hashable { - /// Device capabilities - let capabilities: [String]? - /// Device driver - let driver: String? - /// Number of devices - let count: String? - /// Specific device IDs - let device_ids: [String]? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift b/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift deleted file mode 100644 index 503d98664..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/DockerCompose.swift +++ /dev/null @@ -1,60 +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. -//===----------------------------------------------------------------------===// - -// -// DockerCompose.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents the top-level structure of a docker-compose.yml file. -struct DockerCompose: Codable { - /// The Compose file format version (e.g., '3.8') - let version: String? - /// Optional project name - let name: String? - /// Dictionary of service definitions, keyed by service name - let services: [String: Service] - /// Optional top-level volume definitions - let volumes: [String: Volume]? - /// Optional top-level network definitions - let networks: [String: Network]? - /// Optional top-level config definitions (primarily for Swarm) - let configs: [String: Config]? - /// Optional top-level secret definitions (primarily for Swarm) - let secrets: [String: Secret]? - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - version = try container.decodeIfPresent(String.self, forKey: .version) - name = try container.decodeIfPresent(String.self, forKey: .name) - services = try container.decode([String: Service].self, forKey: .services) - - if let volumes = try container.decodeIfPresent([String: Optional].self, forKey: .volumes) { - let safeVolumes: [String : Volume] = volumes.mapValues { value in - value ?? Volume() - } - self.volumes = safeVolumes - } else { - self.volumes = nil - } - networks = try container.decodeIfPresent([String: Network].self, forKey: .networks) - configs = try container.decodeIfPresent([String: Config].self, forKey: .configs) - secrets = try container.decodeIfPresent([String: Secret].self, forKey: .secrets) - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift deleted file mode 100644 index d05ccd461..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ExternalConfig.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ExternalConfig.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents an external config reference. -struct ExternalConfig: Codable { - /// True if the config is external - let isExternal: Bool - /// Optional name of the external config if different from key - let name: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift deleted file mode 100644 index 07d6c8ce9..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ExternalNetwork.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ExternalNetwork.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents an external network reference. -struct ExternalNetwork: Codable { - /// True if the network is external - let isExternal: Bool - // Optional name of the external network if different from key - let name: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift deleted file mode 100644 index ce4411362..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ExternalSecret.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ExternalSecret.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents an external secret reference. -struct ExternalSecret: Codable { - /// True if the secret is external - let isExternal: Bool - /// Optional name of the external secret if different from key - let name: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift b/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift deleted file mode 100644 index 04cfe4f92..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ExternalVolume.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ExternalVolume.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents an external volume reference. -struct ExternalVolume: Codable { - /// True if the volume is external - let isExternal: Bool - /// Optional name of the external volume if different from key - let name: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift b/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift deleted file mode 100644 index 27f5aa912..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Healthcheck.swift +++ /dev/null @@ -1,37 +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. -//===----------------------------------------------------------------------===// - -// -// Healthcheck.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Healthcheck configuration for a service. -struct Healthcheck: Codable, Hashable { - /// Command to run to check health - let test: [String]? - /// Grace period for the container to start - let start_period: String? - /// How often to run the check - let interval: String? - /// Number of consecutive failures to consider unhealthy - let retries: Int? - /// Timeout for each check - let timeout: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Network.swift b/Sources/ContainerCLI/Compose/Codable Structs/Network.swift deleted file mode 100644 index 44752aecc..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Network.swift +++ /dev/null @@ -1,68 +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. -//===----------------------------------------------------------------------===// - -// -// Network.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a top-level network definition. -struct Network: Codable { - /// Network driver (e.g., 'bridge', 'overlay') - let driver: String? - /// Driver-specific options - let driver_opts: [String: String]? - /// Allow standalone containers to attach to this network - let attachable: Bool? - /// Enable IPv6 networking - let enable_ipv6: Bool? - /// RENAMED: from `internal` to `isInternal` to avoid keyword clash - let isInternal: Bool? - /// Labels for the network - let labels: [String: String]? - /// Explicit name for the network - let name: String? - /// Indicates if the network is external (pre-existing) - let external: ExternalNetwork? - - /// Updated CodingKeys to map 'internal' from YAML to 'isInternal' Swift property - enum CodingKeys: String, CodingKey { - case driver, driver_opts, attachable, enable_ipv6, isInternal = "internal", labels, name, external - } - - /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_net" }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - driver = try container.decodeIfPresent(String.self, forKey: .driver) - driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts) - attachable = try container.decodeIfPresent(Bool.self, forKey: .attachable) - enable_ipv6 = try container.decodeIfPresent(Bool.self, forKey: .enable_ipv6) - isInternal = try container.decodeIfPresent(Bool.self, forKey: .isInternal) // Use isInternal here - labels = try container.decodeIfPresent([String: String].self, forKey: .labels) - name = try container.decodeIfPresent(String.self, forKey: .name) - - if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { - external = ExternalNetwork(isExternal: externalBool, name: nil) - } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { - external = ExternalNetwork(isExternal: true, name: externalDict["name"]) - } else { - external = nil - } - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift b/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift deleted file mode 100644 index 4643d961b..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ResourceLimits.swift +++ /dev/null @@ -1,31 +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. -//===----------------------------------------------------------------------===// - -// -// ResourceLimits.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// CPU and memory limits. -struct ResourceLimits: Codable, Hashable { - /// CPU limit (e.g., "0.5") - let cpus: String? - /// Memory limit (e.g., "512M") - let memory: String? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift b/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift deleted file mode 100644 index 26052e6b3..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ResourceReservations.swift +++ /dev/null @@ -1,34 +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. -//===----------------------------------------------------------------------===// - -// -// ResourceReservations.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// **FIXED**: Renamed from `ResourceReservables` to `ResourceReservations` and made `Codable`. -/// CPU and memory reservations. -struct ResourceReservations: Codable, Hashable { - /// CPU reservation (e.g., "0.25") - let cpus: String? - /// Memory reservation (e.g., "256M") - let memory: String? - /// Device reservations for GPUs or other devices - let devices: [DeviceReservation]? -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift b/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift deleted file mode 100644 index ff464c671..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Secret.swift +++ /dev/null @@ -1,58 +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. -//===----------------------------------------------------------------------===// - -// -// Secret.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a top-level secret definition (primarily for Swarm). -struct Secret: Codable { - /// Path to the file containing the secret content - let file: String? - /// Environment variable to populate with the secret content - let environment: String? - /// Indicates if the secret is external (pre-existing) - let external: ExternalSecret? - /// Explicit name for the secret - let name: String? - /// Labels for the secret - let labels: [String: String]? - - enum CodingKeys: String, CodingKey { - case file, environment, external, name, labels - } - - /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_sec" }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - file = try container.decodeIfPresent(String.self, forKey: .file) - environment = try container.decodeIfPresent(String.self, forKey: .environment) - name = try container.decodeIfPresent(String.self, forKey: .name) - labels = try container.decodeIfPresent([String: String].self, forKey: .labels) - - if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { - external = ExternalSecret(isExternal: externalBool, name: nil) - } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { - external = ExternalSecret(isExternal: true, name: externalDict["name"]) - } else { - external = nil - } - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Service.swift b/Sources/ContainerCLI/Compose/Codable Structs/Service.swift deleted file mode 100644 index 1c5aeb528..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Service.swift +++ /dev/null @@ -1,203 +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. -//===----------------------------------------------------------------------===// - -// -// Service.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - -import Foundation - - -/// Represents a single service definition within the `services` section. -struct Service: Codable, Hashable { - /// Docker image name - let image: String? - - /// Build configuration if the service is built from a Dockerfile - let build: Build? - - /// Deployment configuration (primarily for Swarm) - let deploy: Deploy? - - /// Restart policy (e.g., 'unless-stopped', 'always') - let restart: String? - - /// Healthcheck configuration - let healthcheck: Healthcheck? - - /// List of volume mounts (e.g., "hostPath:containerPath", "namedVolume:/path") - let volumes: [String]? - - /// Environment variables to set in the container - let environment: [String: String]? - - /// List of .env files to load environment variables from - let env_file: [String]? - - /// Port mappings (e.g., "hostPort:containerPort") - let ports: [String]? - - /// Command to execute in the container, overriding the image's default - let command: [String]? - - /// Services this service depends on (for startup order) - let depends_on: [String]? - - /// User or UID to run the container as - let user: String? - - /// Explicit name for the container instance - let container_name: String? - - /// List of networks the service will connect to - let networks: [String]? - - /// Container hostname - let hostname: String? - - /// Entrypoint to execute in the container, overriding the image's default - let entrypoint: [String]? - - /// Run container in privileged mode - let privileged: Bool? - - /// Mount container's root filesystem as read-only - let read_only: Bool? - - /// Working directory inside the container - let working_dir: String? - - /// Platform architecture for the service - let platform: String? - - /// Service-specific config usage (primarily for Swarm) - let configs: [ServiceConfig]? - - /// Service-specific secret usage (primarily for Swarm) - let secrets: [ServiceSecret]? - - /// Keep STDIN open (-i flag for `container run`) - let stdin_open: Bool? - - /// Allocate a pseudo-TTY (-t flag for `container run`) - let tty: Bool? - - /// Other services that depend on this service - var dependedBy: [String] = [] - - // Defines custom coding keys to map YAML keys to Swift properties - enum CodingKeys: String, CodingKey { - case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, - container_name, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform - } - - /// Custom initializer to handle decoding and basic validation. - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - image = try container.decodeIfPresent(String.self, forKey: .image) - build = try container.decodeIfPresent(Build.self, forKey: .build) - deploy = try container.decodeIfPresent(Deploy.self, forKey: .deploy) - - // Ensure that a service has either an image or a build context. - guard image != nil || build != nil else { - throw DecodingError.dataCorruptedError(forKey: .image, in: container, debugDescription: "Service must have either 'image' or 'build' specified.") - } - - restart = try container.decodeIfPresent(String.self, forKey: .restart) - healthcheck = try container.decodeIfPresent(Healthcheck.self, forKey: .healthcheck) - volumes = try container.decodeIfPresent([String].self, forKey: .volumes) - environment = try container.decodeIfPresent([String: String].self, forKey: .environment) - env_file = try container.decodeIfPresent([String].self, forKey: .env_file) - ports = try container.decodeIfPresent([String].self, forKey: .ports) - - // Decode 'command' which can be either a single string or an array of strings. - if let cmdArray = try? container.decodeIfPresent([String].self, forKey: .command) { - command = cmdArray - } else if let cmdString = try? container.decodeIfPresent(String.self, forKey: .command) { - command = [cmdString] - } else { - command = nil - } - - depends_on = try container.decodeIfPresent([String].self, forKey: .depends_on) - user = try container.decodeIfPresent(String.self, forKey: .user) - - container_name = try container.decodeIfPresent(String.self, forKey: .container_name) - networks = try container.decodeIfPresent([String].self, forKey: .networks) - hostname = try container.decodeIfPresent(String.self, forKey: .hostname) - - // Decode 'entrypoint' which can be either a single string or an array of strings. - if let entrypointArray = try? container.decodeIfPresent([String].self, forKey: .entrypoint) { - entrypoint = entrypointArray - } else if let entrypointString = try? container.decodeIfPresent(String.self, forKey: .entrypoint) { - entrypoint = [entrypointString] - } else { - entrypoint = nil - } - - privileged = try container.decodeIfPresent(Bool.self, forKey: .privileged) - read_only = try container.decodeIfPresent(Bool.self, forKey: .read_only) - working_dir = try container.decodeIfPresent(String.self, forKey: .working_dir) - configs = try container.decodeIfPresent([ServiceConfig].self, forKey: .configs) - secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets) - stdin_open = try container.decodeIfPresent(Bool.self, forKey: .stdin_open) - tty = try container.decodeIfPresent(Bool.self, forKey: .tty) - platform = try container.decodeIfPresent(String.self, forKey: .platform) - } - - /// Returns the services in topological order based on `depends_on` relationships. - static func topoSortConfiguredServices( - _ services: [(serviceName: String, service: Service)] - ) throws -> [(serviceName: String, service: Service)] { - - var visited = Set() - var visiting = Set() - var sorted: [(String, Service)] = [] - - func visit(_ name: String, from service: String? = nil) throws { - guard var serviceTuple = services.first(where: { $0.serviceName == name }) else { return } - if let service { - serviceTuple.service.dependedBy.append(service) - } - - if visiting.contains(name) { - throw NSError(domain: "ComposeError", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" - ]) - } - guard !visited.contains(name) else { return } - - visiting.insert(name) - for depName in serviceTuple.service.depends_on ?? [] { - try visit(depName, from: name) - } - visiting.remove(name) - visited.insert(name) - sorted.append(serviceTuple) - } - - for (serviceName, _) in services { - if !visited.contains(serviceName) { - try visit(serviceName) - } - } - - return sorted - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift b/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift deleted file mode 100644 index 712d42b7b..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ServiceConfig.swift +++ /dev/null @@ -1,64 +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. -//===----------------------------------------------------------------------===// - -// -// ServiceConfig.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a service's usage of a config. -struct ServiceConfig: Codable, Hashable { - /// Name of the config being used - let source: String - - /// Path in the container where the config will be mounted - let target: String? - - /// User ID for the mounted config file - let uid: String? - - /// Group ID for the mounted config file - let gid: String? - - /// Permissions mode for the mounted config file - let mode: Int? - - /// Custom initializer to handle `config_name` (string) or `{ source: config_name, target: /path }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let sourceName = try? container.decode(String.self) { - self.source = sourceName - self.target = nil - self.uid = nil - self.gid = nil - self.mode = nil - } else { - let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) - self.source = try keyedContainer.decode(String.self, forKey: .source) - self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) - self.uid = try keyedContainer.decodeIfPresent(String.self, forKey: .uid) - self.gid = try keyedContainer.decodeIfPresent(String.self, forKey: .gid) - self.mode = try keyedContainer.decodeIfPresent(Int.self, forKey: .mode) - } - } - - enum CodingKeys: String, CodingKey { - case source, target, uid, gid, mode - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift b/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift deleted file mode 100644 index 1849c495c..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/ServiceSecret.swift +++ /dev/null @@ -1,64 +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. -//===----------------------------------------------------------------------===// - -// -// ServiceSecret.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a service's usage of a secret. -struct ServiceSecret: Codable, Hashable { - /// Name of the secret being used - let source: String - - /// Path in the container where the secret will be mounted - let target: String? - - /// User ID for the mounted secret file - let uid: String? - - /// Group ID for the mounted secret file - let gid: String? - - /// Permissions mode for the mounted secret file - let mode: Int? - - /// Custom initializer to handle `secret_name` (string) or `{ source: secret_name, target: /path }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let sourceName = try? container.decode(String.self) { - self.source = sourceName - self.target = nil - self.uid = nil - self.gid = nil - self.mode = nil - } else { - let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) - self.source = try keyedContainer.decode(String.self, forKey: .source) - self.target = try keyedContainer.decodeIfPresent(String.self, forKey: .target) - self.uid = try keyedContainer.decodeIfPresent(String.self, forKey: .uid) - self.gid = try keyedContainer.decodeIfPresent(String.self, forKey: .gid) - self.mode = try keyedContainer.decodeIfPresent(Int.self, forKey: .mode) - } - } - - enum CodingKeys: String, CodingKey { - case source, target, uid, gid, mode - } -} diff --git a/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift b/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift deleted file mode 100644 index b43a1cca5..000000000 --- a/Sources/ContainerCLI/Compose/Codable Structs/Volume.swift +++ /dev/null @@ -1,70 +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. -//===----------------------------------------------------------------------===// - -// -// Volume.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - - -/// Represents a top-level volume definition. -struct Volume: Codable { - /// Volume driver (e.g., 'local') - let driver: String? - - /// Driver-specific options - let driver_opts: [String: String]? - - /// Explicit name for the volume - let name: String? - - /// Labels for the volume - let labels: [String: String]? - - /// Indicates if the volume is external (pre-existing) - let external: ExternalVolume? - - enum CodingKeys: String, CodingKey { - case driver, driver_opts, name, labels, external - } - - /// Custom initializer to handle `external: true` (boolean) or `external: { name: "my_vol" }` (object). - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - driver = try container.decodeIfPresent(String.self, forKey: .driver) - driver_opts = try container.decodeIfPresent([String: String].self, forKey: .driver_opts) - name = try container.decodeIfPresent(String.self, forKey: .name) - labels = try container.decodeIfPresent([String: String].self, forKey: .labels) - - if let externalBool = try? container.decodeIfPresent(Bool.self, forKey: .external) { - external = ExternalVolume(isExternal: externalBool, name: nil) - } else if let externalDict = try? container.decodeIfPresent([String: String].self, forKey: .external) { - external = ExternalVolume(isExternal: true, name: externalDict["name"]) - } else { - external = nil - } - } - - init(driver: String? = nil, driver_opts: [String : String]? = nil, name: String? = nil, labels: [String : String]? = nil, external: ExternalVolume? = nil) { - self.driver = driver - self.driver_opts = driver_opts - self.name = name - self.labels = labels - self.external = external - } -} diff --git a/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift b/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift deleted file mode 100644 index 8993f8ddb..000000000 --- a/Sources/ContainerCLI/Compose/Commands/ComposeDown.swift +++ /dev/null @@ -1,105 +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. -//===----------------------------------------------------------------------===// - -// -// ComposeDown.swift -// Container-Compose -// -// Created by Morris Richman on 6/19/25. -// - -import ArgumentParser -import ContainerClient -import Foundation -import Yams - -extension Application { - public struct ComposeDown: AsyncParsableCommand { - public init() {} - - public static let configuration: CommandConfiguration = .init( - commandName: "down", - abstract: "Stop containers with compose" - ) - - @Argument(help: "Specify the services to stop") - var services: [String] = [] - - @OptionGroup - var process: Flags.Process - - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - - var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml - - private var fileManager: FileManager { FileManager.default } - private var projectName: String? - - public mutating func run() async throws { - // Read docker-compose.yml content - guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { - throw YamlError.dockerfileNotFound(dockerComposePath) - } - - // Decode the YAML file into the DockerCompose struct - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - - // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") - } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") - } - - var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) - services = try Service.topoSortConfiguredServices(services) - - // Filter for specified services - if !self.services.isEmpty { - services = services.filter({ serviceName, service in - self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) - }) - } - - try await stopOldStuff(services.map({ $0.serviceName }), remove: false) - } - - private func stopOldStuff(_ services: [String], remove: Bool) async throws { - guard let projectName else { return } - let containers = services.map { "\(projectName)-\($0)" } - - for container in containers { - print("Stopping container: \(container)") - guard let container = try? await ClientContainer.get(id: container) else { continue } - - do { - try await container.stop() - } catch { - } - if remove { - do { - try await container.delete() - } catch { - } - } - } - } - } -} diff --git a/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift b/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift deleted file mode 100644 index 6b1053670..000000000 --- a/Sources/ContainerCLI/Compose/Commands/ComposeUp.swift +++ /dev/null @@ -1,749 +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. -//===----------------------------------------------------------------------===// - -// -// ComposeUp.swift -// Container-Compose -// -// Created by Morris Richman on 6/19/25. -// - -import ArgumentParser -import ContainerClient -import Foundation -@preconcurrency import Rainbow -import Yams -import ContainerizationExtras - -extension Application { - public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { - public init() {} - - public static let configuration: CommandConfiguration = .init( - commandName: "up", - abstract: "Start containers with compose" - ) - - @Argument(help: "Specify the services to start") - var services: [String] = [] - - @Flag( - name: [.customShort("d"), .customLong("detach")], - help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") - var detatch: Bool = false - - @Flag(name: [.customShort("b"), .customLong("build")]) - var rebuild: Bool = false - - @Flag(name: .long, help: "Do not use cache") - var noCache: Bool = false - - @OptionGroup - var process: Flags.Process - - @OptionGroup - var global: Flags.Global - - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml - var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file - // - private var fileManager: FileManager { FileManager.default } - private var projectName: String? - private var environmentVariables: [String: String] = [:] - private var containerIps: [String: String] = [:] - private var containerConsoleColors: [String: NamedColor] = [:] - - private static let availableContainerConsoleColors: Set = [ - .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, - ] - - public mutating func run() async throws { - // Read docker-compose.yml content - guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { - throw YamlError.dockerfileNotFound(dockerComposePath) - } - - // Decode the YAML file into the DockerCompose struct - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - - // Load environment variables from .env file - environmentVariables = loadEnvFile(path: envFilePath) - - // Handle 'version' field - if let version = dockerCompose.version { - print("Info: Docker Compose file version parsed as: \(version)") - print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") - } - - // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") - } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") - } - - // Get Services to use - var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) - services = try Service.topoSortConfiguredServices(services) - - // Filter for specified services - if !self.services.isEmpty { - services = services.filter({ serviceName, service in - self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) - }) - } - - // Stop Services - try await stopOldStuff(services.map({ $0.serviceName }), remove: true) - - // Process top-level networks - // This creates named networks defined in the docker-compose.yml - if let networks = dockerCompose.networks { - print("\n--- Processing Networks ---") - for (networkName, networkConfig) in networks { - try await setupNetwork(name: networkName, config: networkConfig) - } - print("--- Networks Processed ---\n") - } - - // Process top-level volumes - // This creates named volumes defined in the docker-compose.yml - if let volumes = dockerCompose.volumes { - print("\n--- Processing Volumes ---") - for (volumeName, volumeConfig) in volumes { - await createVolumeHardLink(name: volumeName, config: volumeConfig) - } - print("--- Volumes Processed ---\n") - } - - // Process each service defined in the docker-compose.yml - print("\n--- Processing Services ---") - - print(services.map(\.serviceName)) - for (serviceName, service) in services { - try await configService(service, serviceName: serviceName, from: dockerCompose) - } - - if !detatch { - await waitForever() - } - } - - func waitForever() async -> Never { - for await _ in AsyncStream(unfolding: {}) { - // This will never run - } - fatalError("unreachable") - } - - private func getIPForRunningService(_ serviceName: String) async throws -> String? { - guard let projectName else { return nil } - - let containerName = "\(projectName)-\(serviceName)" - - let container = try await ClientContainer.get(id: containerName) - let ip = container.networks.compactMap { try? CIDRAddress($0.address).address.description }.first - - return ip - } - - /// Repeatedly checks `container list -a` until the given container is listed as `running`. - /// - Parameters: - /// - containerName: The exact name of the container (e.g. "Assignment-Manager-API-db"). - /// - timeout: Max seconds to wait before failing. - /// - interval: How often to poll (in seconds). - /// - Returns: `true` if the container reached "running" state within the timeout. - private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { - guard let projectName else { return } - let containerName = "\(projectName)-\(serviceName)" - - let deadline = Date().addingTimeInterval(timeout) - - while Date() < deadline { - let container = try? await ClientContainer.get(id: containerName) - if container?.status == .running { - return - } - - try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - } - - throw NSError( - domain: "ContainerWait", code: 1, - userInfo: [ - NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." - ]) - } - - private func stopOldStuff(_ services: [String], remove: Bool) async throws { - guard let projectName else { return } - let containers = services.map { "\(projectName)-\($0)" } - - for container in containers { - print("Stopping container: \(container)") - guard let container = try? await ClientContainer.get(id: container) else { continue } - - do { - try await container.stop() - } catch { - } - if remove { - do { - try await container.delete() - } catch { - } - } - } - } - - // MARK: Compose Top Level Functions - - private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { - let ip = try await getIPForRunningService(serviceName) - self.containerIps[serviceName] = ip - for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { - self.environmentVariables[key] = ip ?? value - } - } - - private func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { - guard let projectName else { return } - let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name - - let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") - let volumePath = volumeUrl.path(percentEncoded: false) - - print( - "Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." - ) - try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) - } - - private func setupNetwork(name networkName: String, config networkConfig: Network) async throws { - let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name - - if let externalNetwork = networkConfig.external, externalNetwork.isExternal { - print("Info: Network '\(networkName)' is declared as external.") - print("This tool assumes external network '\(externalNetwork.name ?? actualNetworkName)' already exists and will not attempt to create it.") - } else { - var networkCreateArgs: [String] = ["network", "create"] - - #warning("Docker Compose Network Options Not Supported") - // Add driver and driver options - if let driver = networkConfig.driver, !driver.isEmpty { -// networkCreateArgs.append("--driver") -// networkCreateArgs.append(driver) - print("Network Driver Detected, But Not Supported") - } - if let driverOpts = networkConfig.driver_opts, !driverOpts.isEmpty { -// for (optKey, optValue) in driverOpts { -// networkCreateArgs.append("--opt") -// networkCreateArgs.append("\(optKey)=\(optValue)") -// } - print("Network Options Detected, But Not Supported") - } - // Add various network flags - if networkConfig.attachable == true { -// networkCreateArgs.append("--attachable") - print("Network Attachable Flag Detected, But Not Supported") - } - if networkConfig.enable_ipv6 == true { -// networkCreateArgs.append("--ipv6") - print("Network IPv6 Flag Detected, But Not Supported") - } - if networkConfig.isInternal == true { -// networkCreateArgs.append("--internal") - print("Network Internal Flag Detected, But Not Supported") - } // CORRECTED: Use isInternal - - // Add labels - if let labels = networkConfig.labels, !labels.isEmpty { - print("Network Labels Detected, But Not Supported") -// for (labelKey, labelValue) in labels { -// networkCreateArgs.append("--label") -// networkCreateArgs.append("\(labelKey)=\(labelValue)") -// } - } - - print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") - print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") - guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else { - print("Network '\(networkName)' already exists") - return - } - var networkCreate = NetworkCreate() - networkCreate.global = global - networkCreate.name = actualNetworkName - - try await networkCreate.run() - print("Network '\(networkName)' created") - } - } - - // MARK: Compose Service Level Functions - private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { - guard let projectName else { throw ComposeError.invalidProjectName } - - var imageToRun: String - - // Handle 'build' configuration - if let buildConfig = service.build { - imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) - } else if let img = service.image { - // Use specified image if no build config - // Pull image if necessary - try await pullImage(img, platform: service.container_name) - imageToRun = img - } else { - // Should not happen due to Service init validation, but as a fallback - throw ComposeError.imageNotFound(serviceName) - } - - // Handle 'deploy' configuration (note that this tool doesn't fully support it) - if service.deploy != nil { - print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") - print( - "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." - ) - print("The service will be run as a single container based on other configurations.") - } - - var runCommandArgs: [String] = [] - - // Add detach flag if specified on the CLI - if detatch { - runCommandArgs.append("-d") - } - - // Determine container name - let containerName: String - if let explicitContainerName = service.container_name { - containerName = explicitContainerName - print("Info: Using explicit container_name: \(containerName)") - } else { - // Default container name based on project and service name - containerName = "\(projectName)-\(serviceName)" - } - runCommandArgs.append("--name") - runCommandArgs.append(containerName) - - // REMOVED: Restart policy is not supported by `container run` - // if let restart = service.restart { - // runCommandArgs.append("--restart") - // runCommandArgs.append(restart) - // } - - // Add user - if let user = service.user { - runCommandArgs.append("--user") - runCommandArgs.append(user) - } - - // Add volume mounts - if let volumes = service.volumes { - for volume in volumes { - let args = try await configVolume(volume) - runCommandArgs.append(contentsOf: args) - } - } - - // Combine environment variables from .env files and service environment - var combinedEnv: [String: String] = environmentVariables - - if let envFiles = service.env_file { - for envFile in envFiles { - let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") - combinedEnv.merge(additionalEnvVars) { (current, _) in current } - } - } - - if let serviceEnv = service.environment { - combinedEnv.merge(serviceEnv) { (old, new) in - guard !new.contains("${") else { - return old - } - return new - } // Service env overrides .env files - } - - // Fill in variables - combinedEnv = combinedEnv.mapValues({ value in - guard value.contains("${") else { return value } - - let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) - return combinedEnv[variableName] ?? value - }) - - // Fill in IPs - combinedEnv = combinedEnv.mapValues({ value in - containerIps[value] ?? value - }) - - // MARK: Spinning Spot - // Add environment variables to run command - for (key, value) in combinedEnv { - runCommandArgs.append("-e") - runCommandArgs.append("\(key)=\(value)") - } - - // REMOVED: Port mappings (-p) are not supported by `container run` - // if let ports = service.ports { - // for port in ports { - // let resolvedPort = resolveVariable(port, with: envVarsFromFile) - // runCommandArgs.append("-p") - // runCommandArgs.append(resolvedPort) - // } - // } - - // Connect to specified networks - if let serviceNetworks = service.networks { - for network in serviceNetworks { - let resolvedNetwork = resolveVariable(network, with: environmentVariables) - // Use the explicit network name from top-level definition if available, otherwise resolved name - let networkToConnect = dockerCompose.networks?[network]?.name ?? resolvedNetwork - runCommandArgs.append("--network") - runCommandArgs.append(networkToConnect) - } - print( - "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml." - ) - print( - "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." - ) - } else { - print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.") - } - - // Add hostname - if let hostname = service.hostname { - let resolvedHostname = resolveVariable(hostname, with: environmentVariables) - runCommandArgs.append("--hostname") - runCommandArgs.append(resolvedHostname) - } - - // Add working directory - if let workingDir = service.working_dir { - let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) - runCommandArgs.append("--workdir") - runCommandArgs.append(resolvedWorkingDir) - } - - // Add privileged flag - if service.privileged == true { - runCommandArgs.append("--privileged") - } - - // Add read-only flag - if service.read_only == true { - runCommandArgs.append("--read-only") - } - - // Handle service-level configs (note: still only parsing/logging, not attaching) - if let serviceConfigs = service.configs { - print( - "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." - ) - print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.") - for serviceConfig in serviceConfigs { - print( - " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" - ) - } - } - // - // Handle service-level secrets (note: still only parsing/logging, not attaching) - if let serviceSecrets = service.secrets { - print( - "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." - ) - print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.") - for serviceSecret in serviceSecrets { - print( - " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" - ) - } - } - - // Add interactive and TTY flags - if service.stdin_open == true { - runCommandArgs.append("-i") // --interactive - } - if service.tty == true { - runCommandArgs.append("-t") // --tty - } - - runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint - - // Add entrypoint or command - if let entrypointParts = service.entrypoint { - runCommandArgs.append("--entrypoint") - runCommandArgs.append(contentsOf: entrypointParts) - } else if let commandParts = service.command { - runCommandArgs.append(contentsOf: commandParts) - } - - var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! - - if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { - while containerConsoleColors.values.contains(serviceColor) { - serviceColor = Self.availableContainerConsoleColors.randomElement()! - } - } - - self.containerConsoleColors[serviceName] = serviceColor - - Task { [self, serviceColor] in - @Sendable - func handleOutput(_ output: String) { - print("\(serviceName): \(output)".applyingColor(serviceColor)) - } - - print("\nStarting service: \(serviceName)") - print("Starting \(serviceName)") - print("----------------------------------------\n") - let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) - } - - do { - try await waitUntilServiceIsRunning(serviceName) - try await updateEnvironmentWithServiceIP(serviceName) - } catch { - print(error) - } - } - - private func pullImage(_ imageName: String, platform: String?) async throws { - let imageList = try await ClientImage.list() - guard !imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageName }) else { - return - } - - print("Pulling Image \(imageName)...") - var registry = Flags.Registry() - registry.scheme = "auto" // Set or SwiftArgumentParser gets mad - - var progress = Flags.Progress() - progress.disableProgressUpdates = false - - var imagePull = ImagePull() - imagePull.progressFlags = progress - imagePull.registry = registry - imagePull.global = global - imagePull.reference = imageName - imagePull.platform = platform - try await imagePull.run() - } - - /// Builds Docker Service - /// - /// - Parameters: - /// - buildConfig: The configuration for the build - /// - service: The service you would like to build - /// - serviceName: The fallback name for the image - /// - /// - Returns: Image Name (`String`) - private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { - // Determine image tag for built image - let imageToRun = service.image ?? "\(serviceName):latest" - let imageList = try await ClientImage.list() - if !rebuild, imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageToRun }) { - return imageToRun - } - - var buildCommand = BuildCommand() - - // Set Build Commands - buildCommand.buildArg = (buildConfig.args ?? [:]).map({ "\($0.key)=\(resolveVariable($0.value, with: environmentVariables))" }) - - // Locate Dockerfile and context - buildCommand.contextDir = "\(self.cwd)/\(buildConfig.context)" - buildCommand.file = "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")" - - // Handle Caching - buildCommand.noCache = noCache - buildCommand.cacheIn = [] - buildCommand.cacheOut = [] - - // Handle OS/Arch - let split = service.platform?.split(separator: "/") - buildCommand.os = [String(split?.first ?? "linux")] - buildCommand.arch = [String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64")] - - // Set Image Name - buildCommand.targetImageName = imageToRun - - // Set CPU & Memory - buildCommand.cpus = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 - buildCommand.memory = service.deploy?.resources?.limits?.memory ?? "2048MB" - - // Set Miscelaneous - buildCommand.label = [] // No Label Equivalent? - buildCommand.progress = "auto" - buildCommand.vsockPort = 8088 - buildCommand.quiet = false - buildCommand.target = "" - buildCommand.output = ["type=oci"] - print("\n----------------------------------------") - print("Building image for service: \(serviceName) (Tag: \(imageToRun))") - try buildCommand.validate() - try await buildCommand.run() - print("Image build for \(serviceName) completed.") - print("----------------------------------------") - - return imageToRun - } - - private func configVolume(_ volume: String) async throws -> [String] { - let resolvedVolume = resolveVariable(volume, with: environmentVariables) - - var runCommandArgs: [String] = [] - - // Parse the volume string: destination[:mode] - let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) - - guard components.count >= 2 else { - print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") - return [] - } - - let source = components[0] - let destination = components[1] - - // Check if the source looks like a host path (contains '/' or starts with '.') - // This heuristic helps distinguish bind mounts from named volume references. - if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { - // This is likely a bind mount (local path to container path) - var isDirectory: ObjCBool = false - // Ensure the path is absolute or relative to the current directory for FileManager - let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) - - if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { - if isDirectory.boolValue { - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument - } else { - // Host path exists but is a file - print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") - } - } else { - // Host path does not exist, assume it's meant to be a directory and try to create it. - do { - try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) - print("Info: Created missing host directory for volume: \(fullHostPath)") - runCommandArgs.append("-v") - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument - } catch { - print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") - } - } - } else { - guard let projectName else { return [] } - let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") - let volumePath = volumeUrl.path(percentEncoded: false) - - let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() - let destinationPath = destinationUrl.path(percentEncoded: false) - - print( - "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." - ) - try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) - - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument - } - - return runCommandArgs - } - } -} - -// MARK: CommandLine Functions -extension Application.ComposeUp { - - /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. - /// - /// - Parameters: - /// - command: The name of the command to run (e.g., `"container"`). - /// - args: Command-line arguments to pass to the command. - /// - onStdout: Closure called with streamed stdout data. - /// - onStderr: Closure called with streamed stderr data. - /// - Returns: The process's exit code. - /// - Throws: If the process fails to launch. - @discardableResult - func streamCommand( - _ command: String, - args: [String] = [], - onStdout: @escaping (@Sendable (String) -> Void), - onStderr: @escaping (@Sendable (String) -> Void) - ) async throws -> Int32 { - try await withCheckedThrowingContinuation { continuation in - let process = Process() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [command] + args - process.currentDirectoryURL = URL(fileURLWithPath: cwd) - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - process.environment = ProcessInfo.processInfo.environment.merging([ - "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" - ]) { _, new in new } - - let stdoutHandle = stdoutPipe.fileHandleForReading - let stderrHandle = stderrPipe.fileHandleForReading - - stdoutHandle.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - if let string = String(data: data, encoding: .utf8) { - onStdout(string) - } - } - - stderrHandle.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - if let string = String(data: data, encoding: .utf8) { - onStderr(string) - } - } - - process.terminationHandler = { proc in - stdoutHandle.readabilityHandler = nil - stderrHandle.readabilityHandler = nil - continuation.resume(returning: proc.terminationStatus) - } - - do { - try process.run() - } catch { - continuation.resume(throwing: error) - } - } - } -} diff --git a/Sources/ContainerCLI/Compose/ComposeCommand.swift b/Sources/ContainerCLI/Compose/ComposeCommand.swift deleted file mode 100644 index 03e940332..000000000 --- a/Sources/ContainerCLI/Compose/ComposeCommand.swift +++ /dev/null @@ -1,55 +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. -//===----------------------------------------------------------------------===// - -// -// File.swift -// Container-Compose -// -// Created by Morris Richman on 6/18/25. -// - -import ArgumentParser -import Foundation -import Rainbow -import Yams - -extension Application { - struct ComposeCommand: AsyncParsableCommand { - static let configuration: CommandConfiguration = .init( - commandName: "compose", - abstract: "Manage containers with Docker Compose files", - subcommands: [ - ComposeUp.self, - ComposeDown.self, - ]) - } -} - -/// A structure representing the result of a command-line process execution. -struct CommandResult { - /// The standard output captured from the process. - let stdout: String - - /// The standard error output captured from the process. - let stderr: String - - /// The exit code returned by the process upon termination. - let exitCode: Int32 -} - -extension NamedColor: Codable { - -} diff --git a/Sources/ContainerCLI/Compose/Errors.swift b/Sources/ContainerCLI/Compose/Errors.swift deleted file mode 100644 index c5b375aa2..000000000 --- a/Sources/ContainerCLI/Compose/Errors.swift +++ /dev/null @@ -1,66 +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. -//===----------------------------------------------------------------------===// - -// -// Errors.swift -// Container-Compose -// -// Created by Morris Richman on 6/18/25. -// - -import Foundation - -extension Application { - internal enum YamlError: Error, LocalizedError { - case dockerfileNotFound(String) - - var errorDescription: String? { - switch self { - case .dockerfileNotFound(let path): - return "docker-compose.yml not found at \(path)" - } - } - } - - internal enum ComposeError: Error, LocalizedError { - case imageNotFound(String) - case invalidProjectName - - var errorDescription: String? { - switch self { - case .imageNotFound(let name): - return "Service \(name) must define either 'image' or 'build'." - case .invalidProjectName: - return "Could not find project name." - } - } - } - - internal enum TerminalError: Error, LocalizedError { - case commandFailed(String) - - var errorDescription: String? { - "Command failed: \(self)" - } - } - - /// An enum representing streaming output from either `stdout` or `stderr`. - internal enum CommandOutput { - case stdout(String) - case stderr(String) - case exitCode(Int32) - } -} diff --git a/Sources/ContainerCLI/Compose/Helper Functions.swift b/Sources/ContainerCLI/Compose/Helper Functions.swift deleted file mode 100644 index e1068ad94..000000000 --- a/Sources/ContainerCLI/Compose/Helper Functions.swift +++ /dev/null @@ -1,96 +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. -//===----------------------------------------------------------------------===// - -// -// Helper Functions.swift -// container-compose-app -// -// Created by Morris Richman on 6/17/25. -// - -import Foundation -import Yams - -extension Application { - /// Loads environment variables from a .env file. - /// - Parameter path: The full path to the .env file. - /// - Returns: A dictionary of key-value pairs representing environment variables. - internal static func loadEnvFile(path: String) -> [String: String] { - var envVars: [String: String] = [:] - let fileURL = URL(fileURLWithPath: path) - do { - let content = try String(contentsOf: fileURL, encoding: .utf8) - let lines = content.split(separator: "\n") - for line in lines { - let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) - // Ignore empty lines and comments - if !trimmedLine.isEmpty && !trimmedLine.starts(with: "#") { - // Parse key=value pairs - if let eqIndex = trimmedLine.firstIndex(of: "=") { - let key = String(trimmedLine[.. String { - var resolvedValue = value - // Regex to find ${VAR}, ${VAR:-default}, ${VAR:?error} - let regex = try! NSRegularExpression(pattern: "\\$\\{([A-Z0-9_]+)(:?-(.*?))?(:\\?(.*?))?\\}", options: []) - - // Combine process environment with loaded .env file variables, prioritizing process environment - let combinedEnv = ProcessInfo.processInfo.environment.merging(envVars) { (current, _) in current } - - // Loop to resolve all occurrences of variables in the string - while let match = regex.firstMatch(in: resolvedValue, options: [], range: NSRange(resolvedValue.startIndex.. 0 && all { - throw ContainerizationError( - .invalidArgument, - message: "explicitly supplied container ID(s) conflict with the --all flag" - ) - } - } - - mutating func run() async throws { - let set = Set(containerIDs) - var containers = [ClientContainer]() - - if all { - containers = try await ClientContainer.list() - } else { - let ctrs = try await ClientContainer.list() - containers = ctrs.filter { c in - set.contains(c.id) - } - // If one of the containers requested isn't present, let's throw. We don't need to do - // this for --all as --all should be perfectly usable with no containers to remove; otherwise, - // it'd be quite clunky. - if containers.count != set.count { - let missing = set.filter { id in - !containers.contains { c in - c.id == id - } - } - throw ContainerizationError( - .notFound, - message: "failed to delete one or more containers: \(missing)" - ) - } - } - - var failed = [String]() - let force = self.force - let all = self.all - try await withThrowingTaskGroup(of: ClientContainer?.self) { group in - for container in containers { - group.addTask { - do { - // First we need to find if the container supports auto-remove - // and if so we need to skip deletion. - if container.status == .running { - if !force { - // We don't want to error if the user just wants all containers deleted. - // It's implied we'll skip containers we can't actually delete. - if all { - return nil - } - throw ContainerizationError(.invalidState, message: "container is running") - } - let stopOpts = ContainerStopOptions( - timeoutInSeconds: 5, - signal: SIGKILL - ) - try await container.stop(opts: stopOpts) - } - try await container.delete() - print(container.id) - return nil - } catch { - log.error("failed to delete container \(container.id): \(error)") - return container - } - } - } - - for try await ctr in group { - guard let ctr else { - continue - } - failed.append(ctr.id) - } - } - - if failed.count > 0 { - throw ContainerizationError(.internalError, message: "delete failed for one or more containers: \(failed)") - } - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerExec.swift b/Sources/ContainerCLI/Container/ContainerExec.swift deleted file mode 100644 index de3969585..000000000 --- a/Sources/ContainerCLI/Container/ContainerExec.swift +++ /dev/null @@ -1,96 +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 ContainerizationError -import ContainerizationOS -import Foundation - -extension Application { - struct ContainerExec: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "exec", - abstract: "Run a new command in a running container") - - @OptionGroup - var processFlags: Flags.Process - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Running containers ID") - var containerID: String - - @Argument(parsing: .captureForPassthrough, help: "New process arguments") - var arguments: [String] - - func run() async throws { - var exitCode: Int32 = 127 - let container = try await ClientContainer.get(id: containerID) - try ensureRunning(container: container) - - let stdin = self.processFlags.interactive - let tty = self.processFlags.tty - - var config = container.configuration.initProcess - config.executable = arguments.first! - config.arguments = [String](self.arguments.dropFirst()) - config.terminal = tty - config.environment.append( - contentsOf: try Parser.allEnv( - imageEnvs: [], - envFiles: self.processFlags.envFile, - envs: self.processFlags.env - )) - - if let cwd = self.processFlags.cwd { - config.workingDirectory = cwd - } - - let defaultUser = config.user - let (user, additionalGroups) = Parser.user( - user: processFlags.user, uid: processFlags.uid, - gid: processFlags.gid, defaultUser: defaultUser) - config.user = user - config.supplementalGroups.append(contentsOf: additionalGroups) - - do { - let io = try ProcessIO.create(tty: tty, interactive: stdin, detach: false) - - if !self.processFlags.tty { - var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) - handler.start { - print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") - Darwin.exit(1) - } - } - - let process = try await container.createProcess( - id: UUID().uuidString.lowercased(), - configuration: config) - - exitCode = try await Application.handleProcess(io: io, process: process) - } catch { - if error is ContainerizationError { - throw error - } - throw ContainerizationError(.internalError, message: "failed to exec process \(error)") - } - throw ArgumentParser.ExitCode(exitCode) - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerInspect.swift b/Sources/ContainerCLI/Container/ContainerInspect.swift deleted file mode 100644 index 43bda51a1..000000000 --- a/Sources/ContainerCLI/Container/ContainerInspect.swift +++ /dev/null @@ -1,43 +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 Foundation -import SwiftProtobuf - -extension Application { - struct ContainerInspect: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "inspect", - abstract: "Display information about one or more containers") - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Containers to inspect") - var containers: [String] - - func run() async throws { - let objects: [any Codable] = try await ClientContainer.list().filter { - containers.contains($0.id) - }.map { - PrintableContainer($0) - } - print(try objects.jsonArray()) - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerKill.swift b/Sources/ContainerCLI/Container/ContainerKill.swift deleted file mode 100644 index 9b9ef4ed4..000000000 --- a/Sources/ContainerCLI/Container/ContainerKill.swift +++ /dev/null @@ -1,79 +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 ContainerizationError -import ContainerizationOS -import Darwin - -extension Application { - struct ContainerKill: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "kill", - abstract: "Kill one or more running containers") - - @Option(name: .shortAndLong, help: "Signal to send the container(s)") - var signal: String = "KILL" - - @Flag(name: .shortAndLong, help: "Kill all running containers") - var all = false - - @Argument(help: "Container IDs") - var containerIDs: [String] = [] - - @OptionGroup - var global: Flags.Global - - func validate() throws { - if containerIDs.count == 0 && !all { - throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") - } - if containerIDs.count > 0 && all { - throw ContainerizationError(.invalidArgument, message: "explicitly supplied container IDs conflicts with the --all flag") - } - } - - mutating func run() async throws { - let set = Set(containerIDs) - - var containers = try await ClientContainer.list().filter { c in - c.status == .running - } - if !self.all { - containers = containers.filter { c in - set.contains(c.id) - } - } - - let signalNumber = try Signals.parseSignal(signal) - - var failed: [String] = [] - for container in containers { - do { - try await container.kill(signalNumber) - print(container.id) - } catch { - log.error("failed to kill container \(container.id): \(error)") - failed.append(container.id) - } - } - if failed.count > 0 { - throw ContainerizationError(.internalError, message: "kill failed for one or more containers") - } - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerList.swift b/Sources/ContainerCLI/Container/ContainerList.swift deleted file mode 100644 index 43e5a4cec..000000000 --- a/Sources/ContainerCLI/Container/ContainerList.swift +++ /dev/null @@ -1,110 +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 ContainerNetworkService -import ContainerizationExtras -import Foundation -import SwiftProtobuf - -extension Application { - struct ContainerList: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List containers", - aliases: ["ls"]) - - @Flag(name: .shortAndLong, help: "Show stopped containers as well") - var all = false - - @Flag(name: .shortAndLong, help: "Only output the container ID") - 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 containers = try await ClientContainer.list() - try printContainers(containers: containers, format: format) - } - - private func createHeader() -> [[String]] { - [["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR"]] - } - - private func printContainers(containers: [ClientContainer], format: ListFormat) throws { - if format == .json { - let printables = containers.map { - PrintableContainer($0) - } - let data = try JSONEncoder().encode(printables) - print(String(data: data, encoding: .utf8)!) - - return - } - - if self.quiet { - containers.forEach { - if !self.all && $0.status != .running { - return - } - print($0.id) - } - return - } - - var rows = createHeader() - for container in containers { - if !self.all && container.status != .running { - continue - } - rows.append(container.asRow) - } - - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } - } -} - -extension ClientContainer { - var asRow: [String] { - [ - self.id, - self.configuration.image.reference, - self.configuration.platform.os, - self.configuration.platform.architecture, - self.status.rawValue, - self.networks.compactMap { try? CIDRAddress($0.address).address.description }.joined(separator: ","), - ] - } -} - -struct PrintableContainer: Codable { - let status: RuntimeStatus - let configuration: ContainerConfiguration - let networks: [Attachment] - - init(_ container: ClientContainer) { - self.status = container.status - self.configuration = container.configuration - self.networks = container.networks - } -} diff --git a/Sources/ContainerCLI/Container/ContainerLogs.swift b/Sources/ContainerCLI/Container/ContainerLogs.swift deleted file mode 100644 index d70e80323..000000000 --- a/Sources/ContainerCLI/Container/ContainerLogs.swift +++ /dev/null @@ -1,144 +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 ContainerizationError -import Dispatch -import Foundation - -extension Application { - struct ContainerLogs: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "logs", - abstract: "Fetch container stdio or boot logs" - ) - - @OptionGroup - var global: Flags.Global - - @Flag(name: .shortAndLong, help: "Follow log output") - var follow: Bool = false - - @Flag(name: .long, help: "Display the boot log for the container instead of stdio") - var boot: Bool = false - - @Option(name: [.customShort("n")], help: "Number of lines to show from the end of the logs. If not provided this will print all of the logs") - var numLines: Int? - - @Argument(help: "Container to fetch logs for") - var container: String - - func run() async throws { - do { - let container = try await ClientContainer.get(id: container) - let fhs = try await container.logs() - let fileHandle = boot ? fhs[1] : fhs[0] - - try await Self.tail( - fh: fileHandle, - n: numLines, - follow: follow - ) - } catch { - throw ContainerizationError( - .invalidArgument, - message: "failed to fetch container logs for \(container): \(error)" - ) - } - } - - private static func tail( - fh: FileHandle, - n: Int?, - follow: Bool - ) async throws { - if let n { - var buffer = Data() - let size = try fh.seekToEnd() - var offset = size - var lines: [String] = [] - - while offset > 0, lines.count < n { - let readSize = min(1024, offset) - offset -= readSize - try fh.seek(toOffset: offset) - - let data = fh.readData(ofLength: Int(readSize)) - buffer.insert(contentsOf: data, at: 0) - - if let chunk = String(data: buffer, encoding: .utf8) { - lines = chunk.components(separatedBy: .newlines) - lines = lines.filter { !$0.isEmpty } - } - } - - lines = Array(lines.suffix(n)) - for line in lines { - print(line) - } - } else { - // Fast path if all they want is the full file. - guard let data = try fh.readToEnd() else { - // Seems you get nil if it's a zero byte read, or you - // try and read from dev/null. - return - } - guard let str = String(data: data, encoding: .utf8) else { - throw ContainerizationError( - .internalError, - message: "failed to convert container logs to utf8" - ) - } - print(str.trimmingCharacters(in: .newlines)) - } - - if follow { - try await Self.followFile(fh: fh) - } - } - - private static func followFile(fh: FileHandle) async throws { - _ = try fh.seekToEnd() - let stream = AsyncStream { cont in - fh.readabilityHandler = { handle in - let data = handle.availableData - if data.isEmpty { - // Triggers on container restart - can exit here as well - do { - _ = try fh.seekToEnd() // To continue streaming existing truncated log files - } catch { - fh.readabilityHandler = nil - cont.finish() - return - } - } - if let str = String(data: data, encoding: .utf8), !str.isEmpty { - var lines = str.components(separatedBy: .newlines) - lines = lines.filter { !$0.isEmpty } - for line in lines { - cont.yield(line) - } - } - } - } - - for await line in stream { - print(line) - } - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerStart.swift b/Sources/ContainerCLI/Container/ContainerStart.swift deleted file mode 100644 index a804b9c20..000000000 --- a/Sources/ContainerCLI/Container/ContainerStart.swift +++ /dev/null @@ -1,87 +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 ContainerizationError -import ContainerizationOS -import TerminalProgress - -extension Application { - struct ContainerStart: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "start", - abstract: "Start a container") - - @Flag(name: .shortAndLong, help: "Attach STDOUT/STDERR") - var attach = false - - @Flag(name: .shortAndLong, help: "Attach container's STDIN") - var interactive = false - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Container's ID") - var containerID: String - - func run() async throws { - var exitCode: Int32 = 127 - - let progressConfig = try ProgressConfig( - description: "Starting container" - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - let container = try await ClientContainer.get(id: containerID) - let process = try await container.bootstrap() - - progress.set(description: "Starting init process") - let detach = !self.attach && !self.interactive - do { - let io = try ProcessIO.create( - tty: container.configuration.initProcess.terminal, - interactive: self.interactive, - detach: detach - ) - progress.finish() - if detach { - try await process.start(io.stdio) - defer { - try? io.close() - } - try io.closeAfterStart() - print(self.containerID) - return - } - - exitCode = try await Application.handleProcess(io: io, process: process) - } catch { - try? await container.stop() - - if error is ContainerizationError { - throw error - } - throw ContainerizationError(.internalError, message: "failed to start container: \(error)") - } - throw ArgumentParser.ExitCode(exitCode) - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainerStop.swift b/Sources/ContainerCLI/Container/ContainerStop.swift deleted file mode 100644 index 78f69090e..000000000 --- a/Sources/ContainerCLI/Container/ContainerStop.swift +++ /dev/null @@ -1,102 +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 ContainerizationError -import ContainerizationOS -import Foundation - -extension Application { - struct ContainerStop: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "stop", - abstract: "Stop one or more running containers") - - @Flag(name: .shortAndLong, help: "Stop all running containers") - var all = false - - @Option(name: .shortAndLong, help: "Signal to send the container(s)") - var signal: String = "SIGTERM" - - @Option(name: .shortAndLong, help: "Seconds to wait before killing the container(s)") - var time: Int32 = 5 - - @Argument - var containerIDs: [String] = [] - - @OptionGroup - var global: Flags.Global - - func validate() throws { - if containerIDs.count == 0 && !all { - throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") - } - if containerIDs.count > 0 && all { - throw ContainerizationError( - .invalidArgument, message: "explicitly supplied container IDs conflicts with the --all flag") - } - } - - mutating func run() async throws { - let set = Set(containerIDs) - var containers = [ClientContainer]() - if self.all { - containers = try await ClientContainer.list() - } else { - containers = try await ClientContainer.list().filter { c in - set.contains(c.id) - } - } - - let opts = ContainerStopOptions( - timeoutInSeconds: self.time, - signal: try Signals.parseSignal(self.signal) - ) - let failed = try await Self.stopContainers(containers: containers, stopOptions: opts) - if failed.count > 0 { - throw ContainerizationError(.internalError, message: "stop failed for one or more containers \(failed.joined(separator: ","))") - } - } - - static func stopContainers(containers: [ClientContainer], stopOptions: ContainerStopOptions) async throws -> [String] { - var failed: [String] = [] - try await withThrowingTaskGroup(of: ClientContainer?.self) { group in - for container in containers { - group.addTask { - do { - try await container.stop(opts: stopOptions) - print(container.id) - return nil - } catch { - log.error("failed to stop container \(container.id): \(error)") - return container - } - } - } - - for try await ctr in group { - guard let ctr else { - continue - } - failed.append(ctr.id) - } - } - - return failed - } - } -} diff --git a/Sources/ContainerCLI/Container/ContainersCommand.swift b/Sources/ContainerCLI/Container/ContainersCommand.swift deleted file mode 100644 index ef6aff93e..000000000 --- a/Sources/ContainerCLI/Container/ContainersCommand.swift +++ /dev/null @@ -1,38 +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 - -extension Application { - struct ContainersCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "containers", - abstract: "Manage containers", - subcommands: [ - ContainerCreate.self, - ContainerDelete.self, - ContainerExec.self, - ContainerInspect.self, - ContainerKill.self, - ContainerList.self, - ContainerLogs.self, - ContainerStart.self, - ContainerStop.self, - ], - aliases: ["container", "c"] - ) - } -} diff --git a/Sources/ContainerCLI/Container/ProcessUtils.swift b/Sources/ContainerCLI/Container/ProcessUtils.swift deleted file mode 100644 index d4dda6a27..000000000 --- a/Sources/ContainerCLI/Container/ProcessUtils.swift +++ /dev/null @@ -1,31 +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 ContainerClient -import Containerization -import ContainerizationError -import ContainerizationOS -import Foundation - -extension Application { - static func ensureRunning(container: ClientContainer) throws { - if container.status != .running { - throw ContainerizationError(.invalidState, message: "container \(container.id) is not running") - } - } -} diff --git a/Sources/ContainerCLI/DefaultCommand.swift b/Sources/ContainerCLI/DefaultCommand.swift deleted file mode 100644 index ef88aaaa3..000000000 --- a/Sources/ContainerCLI/DefaultCommand.swift +++ /dev/null @@ -1,54 +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 ContainerPlugin - -struct DefaultCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: nil, - shouldDisplay: false - ) - - @OptionGroup(visibility: .hidden) - var global: Flags.Global - - @Argument(parsing: .captureForPassthrough) - var remaining: [String] = [] - - func run() async throws { - // See if we have a possible plugin command. - guard let command = remaining.first else { - Application.printModifiedHelpText() - return - } - - // Check for edge cases and unknown options to match the behavior in the absence of plugins. - if command.isEmpty { - throw ValidationError("Unknown argument '\(command)'") - } else if command.starts(with: "-") { - throw ValidationError("Unknown option '\(command)'") - } - - let pluginLoader = Application.pluginLoader - guard let plugin = pluginLoader.findPlugin(name: command), plugin.config.isCLI else { - throw ValidationError("failed to find plugin named container-\(command)") - } - // Exec performs execvp (with no fork). - try plugin.exec(args: remaining) - } -} diff --git a/Sources/ContainerCLI/Image/ImageInspect.swift b/Sources/ContainerCLI/Image/ImageInspect.swift deleted file mode 100644 index cea356867..000000000 --- a/Sources/ContainerCLI/Image/ImageInspect.swift +++ /dev/null @@ -1,53 +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 ContainerizationError -import Foundation -import SwiftProtobuf - -extension Application { - struct ImageInspect: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "inspect", - abstract: "Display information about one or more images") - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Images to inspect") - var images: [String] - - func run() async throws { - var printable = [any Codable]() - let result = try await ClientImage.get(names: images) - let notFound = result.error - for image in result.images { - guard !Utility.isInfraImage(name: image.reference) else { - continue - } - printable.append(try await image.details()) - } - if printable.count > 0 { - print(try printable.jsonArray()) - } - if notFound.count > 0 { - throw ContainerizationError(.notFound, message: "Images: \(notFound.joined(separator: "\n"))") - } - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageList.swift b/Sources/ContainerCLI/Image/ImageList.swift deleted file mode 100644 index e666feca7..000000000 --- a/Sources/ContainerCLI/Image/ImageList.swift +++ /dev/null @@ -1,175 +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 Containerization -import ContainerizationError -import ContainerizationOCI -import Foundation -import SwiftProtobuf - -extension Application { - struct ListImageOptions: ParsableArguments { - @Flag(name: .shortAndLong, help: "Only output the image name") - var quiet = false - - @Flag(name: .shortAndLong, help: "Verbose output") - var verbose = false - - @Option(name: .long, help: "Format of the output") - var format: ListFormat = .table - - @OptionGroup - var global: Flags.Global - } - - struct ListImageImplementation { - static private func createHeader() -> [[String]] { - [["NAME", "TAG", "DIGEST"]] - } - - static private func createVerboseHeader() -> [[String]] { - [["NAME", "TAG", "INDEX DIGEST", "OS", "ARCH", "VARIANT", "SIZE", "CREATED", "MANIFEST DIGEST"]] - } - - static private func printImagesVerbose(images: [ClientImage]) async throws { - - var rows = createVerboseHeader() - for image in images { - let formatter = ByteCountFormatter() - for descriptor in try await image.index().manifests { - // Don't list attestation manifests - if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], - referenceType == "attestation-manifest" - { - continue - } - - guard let platform = descriptor.platform else { - continue - } - - let os = platform.os - let arch = platform.architecture - let variant = platform.variant ?? "" - - var config: ContainerizationOCI.Image - var manifest: ContainerizationOCI.Manifest - do { - config = try await image.config(for: platform) - manifest = try await image.manifest(for: platform) - } catch { - continue - } - - let created = config.created ?? "" - let size = descriptor.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size }) - let formattedSize = formatter.string(fromByteCount: size) - - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) - let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) - let row = [ - reference.name, - reference.tag ?? "", - Utility.trimDigest(digest: image.descriptor.digest), - os, - arch, - variant, - formattedSize, - created, - Utility.trimDigest(digest: descriptor.digest), - ] - rows.append(row) - } - } - - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } - - static private func printImages(images: [ClientImage], format: ListFormat, options: ListImageOptions) async throws { - var images = images - images.sort { - $0.reference < $1.reference - } - - if format == .json { - let data = try JSONEncoder().encode(images.map { $0.description }) - print(String(data: data, encoding: .utf8)!) - return - } - - if options.quiet { - try images.forEach { image in - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) - print(processedReferenceString) - } - return - } - - if options.verbose { - try await Self.printImagesVerbose(images: images) - return - } - - var rows = createHeader() - for image in images { - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) - let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) - rows.append([ - reference.name, - reference.tag ?? "", - Utility.trimDigest(digest: image.descriptor.digest), - ]) - } - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } - - static func validate(options: ListImageOptions) throws { - if options.quiet && options.verbose { - throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite and --verbose together") - } - let modifier = options.quiet || options.verbose - if modifier && options.format == .json { - throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite or --verbose along with --format json") - } - } - - static func listImages(options: ListImageOptions) async throws { - let images = try await ClientImage.list().filter { img in - !Utility.isInfraImage(name: img.reference) - } - try await printImages(images: images, format: options.format, options: options) - } - } - - struct ImageList: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List images", - aliases: ["ls"]) - - @OptionGroup - var options: ListImageOptions - - mutating func run() async throws { - try ListImageImplementation.validate(options: options) - try await ListImageImplementation.listImages(options: options) - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageLoad.swift b/Sources/ContainerCLI/Image/ImageLoad.swift deleted file mode 100644 index 719fd19ec..000000000 --- a/Sources/ContainerCLI/Image/ImageLoad.swift +++ /dev/null @@ -1,76 +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 Containerization -import ContainerizationError -import Foundation -import TerminalProgress - -extension Application { - struct ImageLoad: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "load", - abstract: "Load images from an OCI compatible tar archive" - ) - - @OptionGroup - var global: Flags.Global - - @Option( - name: .shortAndLong, help: "Path to the tar archive to load images from", completion: .file(), - transform: { str in - URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) - }) - var input: String - - func run() async throws { - guard FileManager.default.fileExists(atPath: input) else { - print("File does not exist \(input)") - Application.exit(withError: ArgumentParser.ExitCode(1)) - } - - let progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - totalTasks: 2 - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - progress.set(description: "Loading tar archive") - let loaded = try await ClientImage.load(from: input) - - let taskManager = ProgressTaskCoordinator() - let unpackTask = await taskManager.startTask() - progress.set(description: "Unpacking image") - progress.set(itemsName: "entries") - for image in loaded { - try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) - } - await taskManager.finish() - progress.finish() - print("Loaded images:") - for image in loaded { - print(image.reference) - } - } - } -} diff --git a/Sources/ContainerCLI/Image/ImagePrune.swift b/Sources/ContainerCLI/Image/ImagePrune.swift deleted file mode 100644 index d233247f1..000000000 --- a/Sources/ContainerCLI/Image/ImagePrune.swift +++ /dev/null @@ -1,38 +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 Foundation - -extension Application { - struct ImagePrune: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "prune", - abstract: "Remove unreferenced and dangling images") - - @OptionGroup - var global: Flags.Global - - func run() async throws { - let (_, size) = try await ClientImage.pruneImages() - let formatter = ByteCountFormatter() - let freed = formatter.string(fromByteCount: Int64(size)) - print("Cleaned unreferenced images and snapshots") - print("Reclaimed \(freed) in disk space") - } - } -} diff --git a/Sources/ContainerCLI/Image/ImagePull.swift b/Sources/ContainerCLI/Image/ImagePull.swift deleted file mode 100644 index 58f6dc2c6..000000000 --- a/Sources/ContainerCLI/Image/ImagePull.swift +++ /dev/null @@ -1,98 +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 Containerization -import ContainerizationError -import ContainerizationOCI -import TerminalProgress - -extension Application { - struct ImagePull: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "pull", - abstract: "Pull an image" - ) - - @OptionGroup - var global: Flags.Global - - @OptionGroup - var registry: Flags.Registry - - @OptionGroup - var progressFlags: Flags.Progress - - @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? - - @Argument var reference: String - - init() {} - - init(platform: String? = nil, scheme: String = "auto", reference: String, disableProgress: Bool = false) { - self.global = Flags.Global() - self.registry = Flags.Registry(scheme: scheme) - self.progressFlags = Flags.Progress(disableProgressUpdates: disableProgress) - self.platform = platform - self.reference = reference - } - - func run() async throws { - var p: Platform? - if let platform { - p = try Platform(from: platform) - } - - let scheme = try RequestScheme(registry.scheme) - - let processedReference = try ClientImage.normalizeReference(reference) - - var progressConfig: ProgressConfig - if self.progressFlags.disableProgressUpdates { - progressConfig = try ProgressConfig(disableProgressUpdates: self.progressFlags.disableProgressUpdates) - } else { - progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - ignoreSmallSize: true, - totalTasks: 2 - ) - } - - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - progress.set(description: "Fetching image") - progress.set(itemsName: "blobs") - let taskManager = ProgressTaskCoordinator() - let fetchTask = await taskManager.startTask() - let image = try await ClientImage.pull( - reference: processedReference, platform: p, scheme: scheme, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler) - ) - - progress.set(description: "Unpacking image") - progress.set(itemsName: "entries") - let unpackTask = await taskManager.startTask() - try await image.unpack(platform: p, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) - await taskManager.finish() - progress.finish() - } - } -} diff --git a/Sources/ContainerCLI/Image/ImagePush.swift b/Sources/ContainerCLI/Image/ImagePush.swift deleted file mode 100644 index e61d162de..000000000 --- a/Sources/ContainerCLI/Image/ImagePush.swift +++ /dev/null @@ -1,73 +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 Containerization -import ContainerizationOCI -import TerminalProgress - -extension Application { - struct ImagePush: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "push", - abstract: "Push an image" - ) - - @OptionGroup - var global: Flags.Global - - @OptionGroup - var registry: Flags.Registry - - @OptionGroup - var progressFlags: Flags.Progress - - @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? - - @Argument var reference: String - - func run() async throws { - var p: Platform? - if let platform { - p = try Platform(from: platform) - } - - let scheme = try RequestScheme(registry.scheme) - let image = try await ClientImage.get(reference: reference) - - var progressConfig: ProgressConfig - if progressFlags.disableProgressUpdates { - progressConfig = try ProgressConfig(disableProgressUpdates: progressFlags.disableProgressUpdates) - } else { - progressConfig = try ProgressConfig( - description: "Pushing image \(image.reference)", - itemsName: "blobs", - showItems: true, - showSpeed: false, - ignoreSmallSize: true - ) - } - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - _ = try await image.push(platform: p, scheme: scheme, progressUpdate: progress.handler) - progress.finish() - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageRemove.swift b/Sources/ContainerCLI/Image/ImageRemove.swift deleted file mode 100644 index 2f0c86c22..000000000 --- a/Sources/ContainerCLI/Image/ImageRemove.swift +++ /dev/null @@ -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 Containerization -import ContainerizationError -import Foundation - -extension Application { - struct RemoveImageOptions: ParsableArguments { - @Flag(name: .shortAndLong, help: "Remove all images") - var all: Bool = false - - @Argument - var images: [String] = [] - - @OptionGroup - var global: Flags.Global - } - - struct RemoveImageImplementation { - static func validate(options: RemoveImageOptions) throws { - if options.images.count == 0 && !options.all { - throw ContainerizationError(.invalidArgument, message: "no image specified and --all not supplied") - } - if options.images.count > 0 && options.all { - throw ContainerizationError(.invalidArgument, message: "explicitly supplied images conflict with the --all flag") - } - } - - static func removeImage(options: RemoveImageOptions) async throws { - let (found, notFound) = try await { - if options.all { - let found = try await ClientImage.list() - let notFound: [String] = [] - return (found, notFound) - } - return try await ClientImage.get(names: options.images) - }() - var failures: [String] = notFound - var didDeleteAnyImage = false - for image in found { - guard !Utility.isInfraImage(name: image.reference) else { - continue - } - do { - try await ClientImage.delete(reference: image.reference, garbageCollect: false) - print(image.reference) - didDeleteAnyImage = true - } catch { - log.error("failed to remove \(image.reference): \(error)") - failures.append(image.reference) - } - } - let (_, size) = try await ClientImage.pruneImages() - let formatter = ByteCountFormatter() - let freed = formatter.string(fromByteCount: Int64(size)) - - if didDeleteAnyImage { - print("Reclaimed \(freed) in disk space") - } - if failures.count > 0 { - throw ContainerizationError(.internalError, message: "failed to delete one or more images: \(failures)") - } - } - } - - struct ImageRemove: AsyncParsableCommand { - @OptionGroup - var options: RemoveImageOptions - - static let configuration = CommandConfiguration( - commandName: "delete", - abstract: "Remove one or more images", - aliases: ["rm"]) - - func validate() throws { - try RemoveImageImplementation.validate(options: options) - } - - mutating func run() async throws { - try await RemoveImageImplementation.removeImage(options: options) - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageSave.swift b/Sources/ContainerCLI/Image/ImageSave.swift deleted file mode 100644 index 8c0b6eac4..000000000 --- a/Sources/ContainerCLI/Image/ImageSave.swift +++ /dev/null @@ -1,67 +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 Containerization -import ContainerizationOCI -import Foundation -import TerminalProgress - -extension Application { - struct ImageSave: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "save", - abstract: "Save an image as an OCI compatible tar archive" - ) - - @OptionGroup - var global: Flags.Global - - @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? - - @Option( - name: .shortAndLong, help: "Path to save the image tar archive", completion: .file(), - transform: { str in - URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) - }) - var output: String - - @Argument var reference: String - - func run() async throws { - var p: Platform? - if let platform { - p = try Platform(from: platform) - } - - let progressConfig = try ProgressConfig( - description: "Saving image" - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - let image = try await ClientImage.get(reference: reference) - try await image.save(out: output, platform: p) - - progress.finish() - print("Image saved") - } - } -} diff --git a/Sources/ContainerCLI/Image/ImageTag.swift b/Sources/ContainerCLI/Image/ImageTag.swift deleted file mode 100644 index 01a76190f..000000000 --- a/Sources/ContainerCLI/Image/ImageTag.swift +++ /dev/null @@ -1,42 +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 - -extension Application { - struct ImageTag: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "tag", - abstract: "Tag an image") - - @Argument(help: "SOURCE_IMAGE[:TAG]") - var source: String - - @Argument(help: "TARGET_IMAGE[:TAG]") - var target: String - - @OptionGroup - var global: Flags.Global - - func run() async throws { - let existing = try await ClientImage.get(reference: source) - let targetReference = try ClientImage.normalizeReference(target) - try await existing.tag(new: targetReference) - print("Image \(source) tagged as \(target)") - } - } -} diff --git a/Sources/ContainerCLI/Image/ImagesCommand.swift b/Sources/ContainerCLI/Image/ImagesCommand.swift deleted file mode 100644 index 968dfd239..000000000 --- a/Sources/ContainerCLI/Image/ImagesCommand.swift +++ /dev/null @@ -1,38 +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 - -extension Application { - struct ImagesCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "images", - abstract: "Manage images", - subcommands: [ - ImageInspect.self, - ImageList.self, - ImageLoad.self, - ImagePrune.self, - ImagePull.self, - ImagePush.self, - ImageRemove.self, - ImageSave.self, - ImageTag.self, - ], - aliases: ["image", "i"] - ) - } -} diff --git a/Sources/ContainerCLI/Network/NetworkCommand.swift b/Sources/ContainerCLI/Network/NetworkCommand.swift deleted file mode 100644 index 7e502431b..000000000 --- a/Sources/ContainerCLI/Network/NetworkCommand.swift +++ /dev/null @@ -1,33 +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 - -extension Application { - struct NetworkCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "network", - abstract: "Manage container networks", - subcommands: [ - NetworkCreate.self, - NetworkDelete.self, - NetworkList.self, - NetworkInspect.self, - ], - aliases: ["n"] - ) - } -} diff --git a/Sources/ContainerCLI/Network/NetworkCreate.swift b/Sources/ContainerCLI/Network/NetworkCreate.swift deleted file mode 100644 index 535e029ed..000000000 --- a/Sources/ContainerCLI/Network/NetworkCreate.swift +++ /dev/null @@ -1,42 +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 ContainerNetworkService -import ContainerizationError -import Foundation -import TerminalProgress - -extension Application { - struct NetworkCreate: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "create", - abstract: "Create a new network") - - @Argument(help: "Network name") - var name: String - - @OptionGroup - var global: Flags.Global - - func run() async throws { - let config = NetworkConfiguration(id: self.name, mode: .nat) - let state = try await ClientNetwork.create(configuration: config) - print(state.id) - } - } -} diff --git a/Sources/ContainerCLI/Network/NetworkDelete.swift b/Sources/ContainerCLI/Network/NetworkDelete.swift deleted file mode 100644 index 836d6c8ca..000000000 --- a/Sources/ContainerCLI/Network/NetworkDelete.swift +++ /dev/null @@ -1,116 +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 ContainerNetworkService -import ContainerizationError -import Foundation - -extension Application { - struct NetworkDelete: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "delete", - abstract: "Delete one or more networks", - aliases: ["rm"]) - - @Flag(name: .shortAndLong, help: "Remove all networks") - var all = false - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Network names") - var networkNames: [String] = [] - - func validate() throws { - if networkNames.count == 0 && !all { - throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied") - } - if networkNames.count > 0 && all { - throw ContainerizationError( - .invalidArgument, - message: "explicitly supplied network name(s) conflict with the --all flag" - ) - } - } - - mutating func run() async throws { - let uniqueNetworkNames = Set(networkNames) - let networks: [NetworkState] - - if all { - networks = try await ClientNetwork.list() - } else { - networks = try await ClientNetwork.list() - .filter { c in - uniqueNetworkNames.contains(c.id) - } - - // If one of the networks requested isn't present lets throw. We don't need to do - // this for --all as --all should be perfectly usable with no networks to remove, - // otherwise it'd be quite clunky. - if networks.count != uniqueNetworkNames.count { - let missing = uniqueNetworkNames.filter { id in - !networks.contains { n in - n.id == id - } - } - throw ContainerizationError( - .notFound, - message: "failed to delete one or more networks: \(missing)" - ) - } - } - - if uniqueNetworkNames.contains(ClientNetwork.defaultNetworkName) { - throw ContainerizationError( - .invalidArgument, - message: "cannot delete the default network" - ) - } - - var failed = [String]() - try await withThrowingTaskGroup(of: NetworkState?.self) { group in - for network in networks { - group.addTask { - do { - // delete atomically disables the IP allocator, then deletes - // the allocator disable fails if any IPs are still in use - try await ClientNetwork.delete(id: network.id) - print(network.id) - return nil - } catch { - log.error("failed to delete network \(network.id): \(error)") - return network - } - } - } - - for try await network in group { - guard let network else { - continue - } - failed.append(network.id) - } - } - - if failed.count > 0 { - throw ContainerizationError(.internalError, message: "delete failed for one or more networks: \(failed)") - } - } - } -} diff --git a/Sources/ContainerCLI/Network/NetworkInspect.swift b/Sources/ContainerCLI/Network/NetworkInspect.swift deleted file mode 100644 index 614c8b111..000000000 --- a/Sources/ContainerCLI/Network/NetworkInspect.swift +++ /dev/null @@ -1,44 +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 ContainerNetworkService -import Foundation -import SwiftProtobuf - -extension Application { - struct NetworkInspect: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "inspect", - abstract: "Display information about one or more networks") - - @OptionGroup - var global: Flags.Global - - @Argument(help: "Networks to inspect") - var networks: [String] - - func run() async throws { - let objects: [any Codable] = try await ClientNetwork.list().filter { - networks.contains($0.id) - }.map { - PrintableNetwork($0) - } - print(try objects.jsonArray()) - } - } -} diff --git a/Sources/ContainerCLI/Network/NetworkList.swift b/Sources/ContainerCLI/Network/NetworkList.swift deleted file mode 100644 index 9fb44dcb4..000000000 --- a/Sources/ContainerCLI/Network/NetworkList.swift +++ /dev/null @@ -1,107 +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 ContainerNetworkService -import ContainerizationExtras -import Foundation -import SwiftProtobuf - -extension Application { - struct NetworkList: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List networks", - 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 networks = try await ClientNetwork.list() - try printNetworks(networks: networks, format: format) - } - - private func createHeader() -> [[String]] { - [["NETWORK", "STATE", "SUBNET"]] - } - - private func printNetworks(networks: [NetworkState], format: ListFormat) throws { - if format == .json { - let printables = networks.map { - PrintableNetwork($0) - } - let data = try JSONEncoder().encode(printables) - print(String(data: data, encoding: .utf8)!) - - return - } - - if self.quiet { - networks.forEach { - print($0.id) - } - return - } - - var rows = createHeader() - for network in networks { - rows.append(network.asRow) - } - - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } - } -} - -extension NetworkState { - var asRow: [String] { - switch self { - case .created(_): - return [self.id, self.state, "none"] - case .running(_, let status): - return [self.id, self.state, status.address] - } - } -} - -struct PrintableNetwork: Codable { - let id: String - let state: String - let config: NetworkConfiguration - let status: NetworkStatus? - - init(_ network: NetworkState) { - self.id = network.id - self.state = network.state - switch network { - case .created(let config): - self.config = config - self.status = nil - case .running(let config, let status): - self.config = config - self.status = status - } - } -} diff --git a/Sources/ContainerCLI/Registry/Login.swift b/Sources/ContainerCLI/Registry/Login.swift deleted file mode 100644 index 7de7fe7e4..000000000 --- a/Sources/ContainerCLI/Registry/Login.swift +++ /dev/null @@ -1,92 +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 Containerization -import ContainerizationError -import ContainerizationOCI -import Foundation - -extension Application { - struct Login: AsyncParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Login to a registry" - ) - - @Option(name: .shortAndLong, help: "Username") - var username: String = "" - - @Flag(help: "Take the password from stdin") - var passwordStdin: Bool = false - - @Argument(help: "Registry server name") - var server: String - - @OptionGroup - var registry: Flags.Registry - - func run() async throws { - var username = self.username - var password = "" - if passwordStdin { - if username == "" { - throw ContainerizationError( - .invalidArgument, message: "must provide --username with --password-stdin") - } - guard let passwordData = try FileHandle.standardInput.readToEnd() else { - throw ContainerizationError(.invalidArgument, message: "failed to read password from stdin") - } - password = String(decoding: passwordData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) - } - let keychain = KeychainHelper(id: Constants.keychainID) - if username == "" { - username = try keychain.userPrompt(domain: server) - } - if password == "" { - password = try keychain.passwordPrompt() - print() - } - - let server = Reference.resolveDomain(domain: server) - let scheme = try RequestScheme(registry.scheme).schemeFor(host: server) - let _url = "\(scheme)://\(server)" - guard let url = URL(string: _url) else { - throw ContainerizationError(.invalidArgument, message: "Cannot convert \(_url) to URL") - } - guard let host = url.host else { - throw ContainerizationError(.invalidArgument, message: "Invalid host \(server)") - } - - let client = RegistryClient( - host: host, - scheme: scheme.rawValue, - port: url.port, - authentication: BasicAuthentication(username: username, password: password), - retryOptions: .init( - maxRetries: 10, - retryInterval: 300_000_000, - shouldRetry: ({ response in - response.status.code >= 500 - }) - ) - ) - try await client.ping() - try keychain.save(domain: server, username: username, password: password) - print("Login succeeded") - } - } -} diff --git a/Sources/ContainerCLI/Registry/Logout.swift b/Sources/ContainerCLI/Registry/Logout.swift deleted file mode 100644 index a24996e12..000000000 --- a/Sources/ContainerCLI/Registry/Logout.swift +++ /dev/null @@ -1,39 +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 Containerization -import ContainerizationOCI - -extension Application { - struct Logout: AsyncParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Log out from a registry") - - @Argument(help: "Registry server name") - var registry: String - - @OptionGroup - var global: Flags.Global - - func run() async throws { - let keychain = KeychainHelper(id: Constants.keychainID) - let r = Reference.resolveDomain(domain: registry) - try keychain.delete(domain: r) - } - } -} diff --git a/Sources/ContainerCLI/Registry/RegistryCommand.swift b/Sources/ContainerCLI/Registry/RegistryCommand.swift deleted file mode 100644 index c160c9469..000000000 --- a/Sources/ContainerCLI/Registry/RegistryCommand.swift +++ /dev/null @@ -1,32 +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 - -extension Application { - struct RegistryCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "registry", - abstract: "Manage registry configurations", - subcommands: [ - Login.self, - Logout.self, - RegistryDefault.self, - ], - aliases: ["r"] - ) - } -} diff --git a/Sources/ContainerCLI/Registry/RegistryDefault.swift b/Sources/ContainerCLI/Registry/RegistryDefault.swift deleted file mode 100644 index 593d41e27..000000000 --- a/Sources/ContainerCLI/Registry/RegistryDefault.swift +++ /dev/null @@ -1,98 +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 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 - } - } - ClientDefaults.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 { - ClientDefaults.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(ClientDefaults.get(key: .defaultRegistryDomain)) - } - } -} diff --git a/Sources/ContainerCLI/RunCommand.swift b/Sources/ContainerCLI/RunCommand.swift deleted file mode 100644 index 3a818e939..000000000 --- a/Sources/ContainerCLI/RunCommand.swift +++ /dev/null @@ -1,317 +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 Containerization -import ContainerizationError -import ContainerizationExtras -import ContainerizationOS -import Foundation -import NIOCore -import NIOPosix -import TerminalProgress - -extension Application { - struct ContainerRunCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "run", - abstract: "Run a container") - - @OptionGroup - var processFlags: Flags.Process - - @OptionGroup - var resourceFlags: Flags.Resource - - @OptionGroup - var managementFlags: Flags.Management - - @OptionGroup - var registryFlags: Flags.Registry - - @OptionGroup - var global: Flags.Global - - @OptionGroup - var progressFlags: Flags.Progress - - @Argument(help: "Image name") - var image: String - - @Argument(parsing: .captureForPassthrough, help: "Container init process arguments") - var arguments: [String] = [] - - func run() async throws { - var exitCode: Int32 = 127 - let id = Utility.createContainerID(name: self.managementFlags.name) - - var progressConfig: ProgressConfig - if progressFlags.disableProgressUpdates { - progressConfig = try ProgressConfig(disableProgressUpdates: progressFlags.disableProgressUpdates) - } else { - progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - ignoreSmallSize: true, - totalTasks: 6 - ) - } - - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - - try Utility.validEntityName(id) - - // Check if container with id already exists. - let existing = try? await ClientContainer.get(id: id) - guard existing == nil else { - throw ContainerizationError( - .exists, - message: "container with id \(id) already exists" - ) - } - - let ck = try await Utility.containerConfigFromFlags( - id: id, - image: image, - arguments: arguments, - process: processFlags, - management: managementFlags, - resource: resourceFlags, - registry: registryFlags, - progressUpdate: progress.handler - ) - - progress.set(description: "Starting container") - - let options = ContainerCreateOptions(autoRemove: managementFlags.remove) - let container = try await ClientContainer.create( - configuration: ck.0, - options: options, - kernel: ck.1 - ) - - let detach = self.managementFlags.detach - - let process = try await container.bootstrap() - progress.finish() - - do { - let io = try ProcessIO.create( - tty: self.processFlags.tty, - interactive: self.processFlags.interactive, - detach: detach - ) - - if !self.managementFlags.cidfile.isEmpty { - let path = self.managementFlags.cidfile - let data = id.data(using: .utf8) - var attributes = [FileAttributeKey: Any]() - attributes[.posixPermissions] = 0o644 - let success = FileManager.default.createFile( - atPath: path, - contents: data, - attributes: attributes - ) - guard success else { - throw ContainerizationError( - .internalError, message: "failed to create cidfile at \(path): \(errno)") - } - } - - if detach { - try await process.start(io.stdio) - defer { - try? io.close() - } - try io.closeAfterStart() - print(id) - return - } - - if !self.processFlags.tty { - var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) - handler.start { - print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") - Darwin.exit(1) - } - } - - exitCode = try await Application.handleProcess(io: io, process: process) - } catch { - if error is ContainerizationError { - throw error - } - throw ContainerizationError(.internalError, message: "failed to run container: \(error)") - } - throw ArgumentParser.ExitCode(exitCode) - } - } -} - -struct ProcessIO { - let stdin: Pipe? - let stdout: Pipe? - let stderr: Pipe? - var ioTracker: IoTracker? - - struct IoTracker { - let stream: AsyncStream - let cont: AsyncStream.Continuation - let configuredStreams: Int - } - - let stdio: [FileHandle?] - - let console: Terminal? - - func closeAfterStart() throws { - try stdin?.fileHandleForReading.close() - try stdout?.fileHandleForWriting.close() - try stderr?.fileHandleForWriting.close() - } - - func close() throws { - try console?.reset() - } - - static func create(tty: Bool, interactive: Bool, detach: Bool) throws -> ProcessIO { - let current: Terminal? = try { - if !tty { - return nil - } - let current = try Terminal.current - try current.setraw() - return current - }() - - var stdio = [FileHandle?](repeating: nil, count: 3) - - let stdin: Pipe? = { - if !interactive && !tty { - return nil - } - return Pipe() - }() - - if let stdin { - if interactive { - let pin = FileHandle.standardInput - pin.readabilityHandler = { handle in - let data = handle.availableData - if data.isEmpty { - pin.readabilityHandler = nil - return - } - try! stdin.fileHandleForWriting.write(contentsOf: data) - } - } - stdio[0] = stdin.fileHandleForReading - } - - let stdout: Pipe? = { - if detach { - return nil - } - return Pipe() - }() - - var configuredStreams = 0 - let (stream, cc) = AsyncStream.makeStream() - if let stdout { - configuredStreams += 1 - let pout: FileHandle = { - if let current { - return current.handle - } - return .standardOutput - }() - - let rout = stdout.fileHandleForReading - rout.readabilityHandler = { handle in - let data = handle.availableData - if data.isEmpty { - rout.readabilityHandler = nil - cc.yield() - return - } - try! pout.write(contentsOf: data) - } - stdio[1] = stdout.fileHandleForWriting - } - - let stderr: Pipe? = { - if detach || tty { - return nil - } - return Pipe() - }() - if let stderr { - configuredStreams += 1 - let perr: FileHandle = .standardError - let rerr = stderr.fileHandleForReading - rerr.readabilityHandler = { handle in - let data = handle.availableData - if data.isEmpty { - rerr.readabilityHandler = nil - cc.yield() - return - } - try! perr.write(contentsOf: data) - } - stdio[2] = stderr.fileHandleForWriting - } - - var ioTracker: IoTracker? = nil - if configuredStreams > 0 { - ioTracker = .init(stream: stream, cont: cc, configuredStreams: configuredStreams) - } - - return .init( - stdin: stdin, - stdout: stdout, - stderr: stderr, - ioTracker: ioTracker, - stdio: stdio, - console: current - ) - } - - public func wait() async throws { - guard let ioTracker = self.ioTracker else { - return - } - do { - try await Timeout.run(seconds: 3) { - var counter = ioTracker.configuredStreams - for await _ in ioTracker.stream { - counter -= 1 - if counter == 0 { - ioTracker.cont.finish() - break - } - } - } - } catch { - log.error("Timeout waiting for IO to complete : \(error)") - throw error - } - } -} diff --git a/Sources/ContainerCLI/System/DNS/DNSCreate.swift b/Sources/ContainerCLI/System/DNS/DNSCreate.swift deleted file mode 100644 index 2dbe2d8ac..000000000 --- a/Sources/ContainerCLI/System/DNS/DNSCreate.swift +++ /dev/null @@ -1,51 +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 ContainerizationError -import ContainerizationExtras -import Foundation - -extension Application { - struct DNSCreate: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "create", - abstract: "Create a local DNS domain for containers (must run as an administrator)" - ) - - @Argument(help: "the local domain name") - var domainName: String - - func run() async throws { - let resolver: HostDNSResolver = HostDNSResolver() - do { - try resolver.createDomain(name: domainName) - print(domainName) - } catch let error as ContainerizationError { - throw error - } catch { - throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") - } - - do { - try HostDNSResolver.reinitialize() - } catch { - throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") - } - } - } -} diff --git a/Sources/ContainerCLI/System/DNS/DNSDefault.swift b/Sources/ContainerCLI/System/DNS/DNSDefault.swift deleted file mode 100644 index 5a746eab5..000000000 --- a/Sources/ContainerCLI/System/DNS/DNSDefault.swift +++ /dev/null @@ -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 ContainerClient - -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 { - ClientDefaults.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 { - ClientDefaults.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(ClientDefaults.getOptional(key: .defaultDNSDomain) ?? "") - } - } - } -} diff --git a/Sources/ContainerCLI/System/DNS/DNSDelete.swift b/Sources/ContainerCLI/System/DNS/DNSDelete.swift deleted file mode 100644 index b3360bb57..000000000 --- a/Sources/ContainerCLI/System/DNS/DNSDelete.swift +++ /dev/null @@ -1,49 +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 ContainerizationError -import Foundation - -extension Application { - struct DNSDelete: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "delete", - abstract: "Delete a local DNS domain (must run as an administrator)", - aliases: ["rm"] - ) - - @Argument(help: "the local domain name") - var domainName: String - - func run() async throws { - let resolver = HostDNSResolver() - do { - try resolver.deleteDomain(name: domainName) - print(domainName) - } catch { - throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") - } - - do { - try HostDNSResolver.reinitialize() - } catch { - throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") - } - } - } -} diff --git a/Sources/ContainerCLI/System/DNS/DNSList.swift b/Sources/ContainerCLI/System/DNS/DNSList.swift deleted file mode 100644 index 616415775..000000000 --- a/Sources/ContainerCLI/System/DNS/DNSList.swift +++ /dev/null @@ -1,36 +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 Foundation - -extension Application { - struct DNSList: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List local DNS domains", - aliases: ["ls"] - ) - - func run() async throws { - let resolver: HostDNSResolver = HostDNSResolver() - let domains = resolver.listDomains() - print(domains.joined(separator: "\n")) - } - - } -} diff --git a/Sources/ContainerCLI/System/Kernel/KernelSet.swift b/Sources/ContainerCLI/System/Kernel/KernelSet.swift deleted file mode 100644 index 6a1ac1790..000000000 --- a/Sources/ContainerCLI/System/Kernel/KernelSet.swift +++ /dev/null @@ -1,114 +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 Containerization -import ContainerizationError -import ContainerizationExtras -import ContainerizationOCI -import Foundation -import TerminalProgress - -extension Application { - struct KernelSet: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "set", - abstract: "Set the default kernel" - ) - - @Option(name: .customLong("binary"), help: "Path to the binary to set as the default kernel. If used with --tar, this points to a location inside the tar") - var binaryPath: String? = nil - - @Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar ball that contains the kernel to use") - var tarPath: String? = nil - - @Option(name: .customLong("arch"), help: "The architecture of the kernel binary. One of (amd64, arm64)") - var architecture: String = ContainerizationOCI.Platform.current.architecture.description - - @Flag(name: .customLong("recommended"), help: "Download and install the recommended kernel as the default. This flag ignores any other arguments") - var recommended: Bool = false - - func run() async throws { - if recommended { - let url = ClientDefaults.get(key: .defaultKernelURL) - let path = ClientDefaults.get(key: .defaultKernelBinaryPath) - print("Installing the recommended kernel from \(url)...") - try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path) - return - } - guard tarPath != nil else { - return try await self.setKernelFromBinary() - } - try await self.setKernelFromTar() - } - - private func setKernelFromBinary() async throws { - guard let binaryPath else { - throw ArgumentParser.ValidationError("Missing argument '--binary'") - } - let absolutePath = URL(fileURLWithPath: binaryPath, relativeTo: .currentDirectory()).absoluteURL.absoluteString - let platform = try getSystemPlatform() - try await ClientKernel.installKernel(kernelFilePath: absolutePath, platform: platform) - } - - private func setKernelFromTar() async throws { - guard let binaryPath else { - throw ArgumentParser.ValidationError("Missing argument '--binary'") - } - guard let tarPath else { - throw ArgumentParser.ValidationError("Missing argument '--tar") - } - let platform = try getSystemPlatform() - let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).absoluteString - let fm = FileManager.default - if fm.fileExists(atPath: localTarPath) { - try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform) - return - } - guard let remoteURL = URL(string: tarPath) else { - throw ContainerizationError(.invalidArgument, message: "Invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?") - } - try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL.absoluteString, kernelFilePath: binaryPath, platform: platform) - } - - private func getSystemPlatform() throws -> SystemPlatform { - switch architecture { - case "arm64": - return .linuxArm - case "amd64": - return .linuxAmd - default: - throw ContainerizationError(.unsupported, message: "Unsupported architecture \(architecture)") - } - } - - public static func downloadAndInstallWithProgressBar(tarRemoteURL: String, kernelFilePath: String, platform: SystemPlatform = .current) async throws { - let progressConfig = try ProgressConfig( - showTasks: true, - totalTasks: 2 - ) - let progress = ProgressBar(config: progressConfig) - defer { - progress.finish() - } - progress.start() - try await ClientKernel.installKernelFromTar(tarFile: tarRemoteURL, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler) - progress.finish() - } - - } -} diff --git a/Sources/ContainerCLI/System/SystemCommand.swift b/Sources/ContainerCLI/System/SystemCommand.swift deleted file mode 100644 index 3a92bfb92..000000000 --- a/Sources/ContainerCLI/System/SystemCommand.swift +++ /dev/null @@ -1,35 +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 - -extension Application { - struct SystemCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "system", - abstract: "Manage system components", - subcommands: [ - SystemDNS.self, - SystemLogs.self, - SystemStart.self, - SystemStop.self, - SystemStatus.self, - SystemKernel.self, - ], - aliases: ["s"] - ) - } -} diff --git a/Sources/ContainerCLI/System/SystemDNS.swift b/Sources/ContainerCLI/System/SystemDNS.swift deleted file mode 100644 index 4f9b3e3b3..000000000 --- a/Sources/ContainerCLI/System/SystemDNS.swift +++ /dev/null @@ -1,34 +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 ContainerizationError -import Foundation - -extension Application { - struct SystemDNS: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "dns", - abstract: "Manage local DNS domains", - subcommands: [ - DNSCreate.self, - DNSDelete.self, - DNSList.self, - DNSDefault.self, - ] - ) - } -} diff --git a/Sources/ContainerCLI/System/SystemKernel.swift b/Sources/ContainerCLI/System/SystemKernel.swift deleted file mode 100644 index 942bd6965..000000000 --- a/Sources/ContainerCLI/System/SystemKernel.swift +++ /dev/null @@ -1,29 +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 - -extension Application { - struct SystemKernel: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "kernel", - abstract: "Manage the default kernel configuration", - subcommands: [ - KernelSet.self - ] - ) - } -} diff --git a/Sources/ContainerCLI/System/SystemLogs.swift b/Sources/ContainerCLI/System/SystemLogs.swift deleted file mode 100644 index e2b87ffb9..000000000 --- a/Sources/ContainerCLI/System/SystemLogs.swift +++ /dev/null @@ -1,82 +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 ContainerizationError -import ContainerizationOS -import Foundation -import OSLog - -extension Application { - struct SystemLogs: AsyncParsableCommand { - static let subsystem = "com.apple.container" - - static let configuration = CommandConfiguration( - commandName: "logs", - abstract: "Fetch system logs for `container` services" - ) - - @OptionGroup - var global: Flags.Global - - @Option( - name: .long, - help: "Fetch logs starting from the specified time period (minus the current time); supported formats: m, h, d" - ) - var last: String = "5m" - - @Flag(name: .shortAndLong, help: "Follow log output") - var follow: Bool = false - - func run() async throws { - let process = Process() - let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM]) - - Task { - for await _ in sigHandler.signals { - process.terminate() - Darwin.exit(0) - } - } - - do { - var args = ["log"] - args.append(self.follow ? "stream" : "show") - args.append(contentsOf: ["--info", "--debug"]) - if !self.follow { - args.append(contentsOf: ["--last", last]) - } - args.append(contentsOf: ["--predicate", "subsystem = 'com.apple.container'"]) - - process.launchPath = "/usr/bin/env" - process.arguments = args - - process.standardOutput = FileHandle.standardOutput - process.standardError = FileHandle.standardError - - try process.run() - process.waitUntilExit() - } catch { - throw ContainerizationError( - .invalidArgument, - message: "failed to system logs: \(error)" - ) - } - throw ArgumentParser.ExitCode(process.terminationStatus) - } - } -} diff --git a/Sources/ContainerCLI/System/SystemStart.swift b/Sources/ContainerCLI/System/SystemStart.swift deleted file mode 100644 index acce91391..000000000 --- a/Sources/ContainerCLI/System/SystemStart.swift +++ /dev/null @@ -1,170 +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 ContainerPlugin -import ContainerizationError -import Foundation -import TerminalProgress - -extension Application { - struct SystemStart: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "start", - abstract: "Start `container` services" - ) - - @Option(name: .shortAndLong, help: "Path to the `container-apiserver` binary") - var path: String = Bundle.main.executablePath ?? "" - - @Flag(name: .long, help: "Enable debug logging for the runtime daemon.") - var debug = false - - @Flag( - name: .long, inversion: .prefixedEnableDisable, - help: "Specify whether the default kernel should be installed or not. The default behavior is to prompt the user for a response.") - var kernelInstall: Bool? - - func run() async throws { - // Without the true path to the binary in the plist, `container-apiserver` won't launch properly. - let executableUrl = URL(filePath: path) - .resolvingSymlinksInPath() - .deletingLastPathComponent() - .appendingPathComponent("container-apiserver") - - var args = [executableUrl.absolutePath()] - if debug { - args.append("--debug") - } - - let apiServerDataUrl = appRoot.appending(path: "apiserver") - try! FileManager.default.createDirectory(at: apiServerDataUrl, withIntermediateDirectories: true) - let env = ProcessInfo.processInfo.environment.filter { key, _ in - key.hasPrefix("CONTAINER_") - } - - let logURL = apiServerDataUrl.appending(path: "apiserver.log") - let plist = LaunchPlist( - label: "com.apple.container.apiserver", - arguments: args, - environment: env, - limitLoadToSessionType: [.Aqua, .Background, .System], - runAtLoad: true, - stdout: logURL.path, - stderr: logURL.path, - machServices: ["com.apple.container.apiserver"] - ) - - let plistURL = apiServerDataUrl.appending(path: "apiserver.plist") - let data = try plist.encode() - try data.write(to: plistURL) - - try ServiceManager.register(plistPath: plistURL.path) - - // Now ping our friendly daemon. Fail if we don't get a response. - do { - print("Verifying apiserver is running...") - try await ClientHealthCheck.ping(timeout: .seconds(10)) - } catch { - throw ContainerizationError( - .internalError, - message: "failed to get a response from apiserver: \(error)" - ) - } - - if await !initImageExists() { - try? await installInitialFilesystem() - } - - guard await !kernelExists() else { - return - } - try await installDefaultKernel() - } - - private func installInitialFilesystem() async throws { - let dep = Dependencies.initFs - let pullCommand = ImagePull(reference: dep.source) - print("Installing base container filesystem...") - do { - try await pullCommand.run() - } catch { - log.error("Failed to install base container filesystem: \(error)") - } - } - - private func installDefaultKernel() async throws { - let kernelDependency = Dependencies.kernel - let defaultKernelURL = kernelDependency.source - let defaultKernelBinaryPath = ClientDefaults.get(key: .defaultKernelBinaryPath) - - var shouldInstallKernel = false - if kernelInstall == nil { - print("No default kernel configured.") - print("Install the recommended default kernel from [\(kernelDependency.source)]? [Y/n]: ", terminator: "") - guard let read = readLine(strippingNewline: true) else { - throw ContainerizationError(.internalError, message: "Failed to read user input") - } - guard read.lowercased() == "y" || read.count == 0 else { - print("Please use the `container system kernel set --recommended` command to configure the default kernel") - return - } - shouldInstallKernel = true - } else { - shouldInstallKernel = kernelInstall ?? false - } - guard shouldInstallKernel else { - return - } - print("Installing kernel...") - try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: defaultKernelURL, kernelFilePath: defaultKernelBinaryPath) - } - - private func initImageExists() async -> Bool { - do { - let img = try await ClientImage.get(reference: Dependencies.initFs.source) - let _ = try await img.getSnapshot(platform: .current) - return true - } catch { - return false - } - } - - private func kernelExists() async -> Bool { - do { - try await ClientKernel.getDefaultKernel(for: .current) - return true - } catch { - return false - } - } - } - - private enum Dependencies: String { - case kernel - case initFs - - var source: String { - switch self { - case .initFs: - return ClientDefaults.get(key: .defaultInitImage) - case .kernel: - return ClientDefaults.get(key: .defaultKernelURL) - } - } - } -} diff --git a/Sources/ContainerCLI/System/SystemStatus.swift b/Sources/ContainerCLI/System/SystemStatus.swift deleted file mode 100644 index 132607681..000000000 --- a/Sources/ContainerCLI/System/SystemStatus.swift +++ /dev/null @@ -1,52 +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 ContainerPlugin -import ContainerizationError -import Foundation -import Logging - -extension Application { - struct SystemStatus: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "status", - abstract: "Show the status of `container` services" - ) - - @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") - var prefix: String = "com.apple.container." - - func run() async throws { - let isRegistered = try ServiceManager.isRegistered(fullServiceLabel: "\(prefix)apiserver") - if !isRegistered { - print("apiserver is not running and not registered with launchd") - Application.exit(withError: ExitCode(1)) - } - - // Now ping our friendly daemon. Fail after 10 seconds with no response. - do { - print("Verifying apiserver is running...") - try await ClientHealthCheck.ping(timeout: .seconds(10)) - print("apiserver is running") - } catch { - print("apiserver is not running") - Application.exit(withError: ExitCode(1)) - } - } - } -} diff --git a/Sources/ContainerCLI/System/SystemStop.swift b/Sources/ContainerCLI/System/SystemStop.swift deleted file mode 100644 index 32824dd0c..000000000 --- a/Sources/ContainerCLI/System/SystemStop.swift +++ /dev/null @@ -1,91 +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 ContainerPlugin -import ContainerizationOS -import Foundation -import Logging - -extension Application { - struct SystemStop: AsyncParsableCommand { - private static let stopTimeoutSeconds: Int32 = 5 - private static let shutdownTimeoutSeconds: Int32 = 20 - - static let configuration = CommandConfiguration( - commandName: "stop", - abstract: "Stop all `container` services" - ) - - @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") - var prefix: String = "com.apple.container." - - func run() async throws { - let log = Logger( - label: "com.apple.container.cli", - factory: { label in - StreamLogHandler.standardOutput(label: label) - } - ) - - let launchdDomainString = try ServiceManager.getDomainString() - let fullLabel = "\(launchdDomainString)/\(prefix)apiserver" - - log.info("stopping containers", metadata: ["stopTimeoutSeconds": "\(Self.stopTimeoutSeconds)"]) - do { - let containers = try await ClientContainer.list() - let signal = try Signals.parseSignal("SIGTERM") - let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: signal) - let failed = try await ContainerStop.stopContainers(containers: containers, stopOptions: opts) - if !failed.isEmpty { - log.warning("some containers could not be stopped gracefully", metadata: ["ids": "\(failed)"]) - } - - } catch { - log.warning("failed to stop all containers", metadata: ["error": "\(error)"]) - } - - log.info("waiting for containers to exit") - do { - for _ in 0.. Date: Tue, 15 Jul 2025 22:48:21 -0600 Subject: [PATCH 60/80] Reapply "Update Package.swift" This reverts commit 030830be9397a358e4b2e9c39d4df77bcb2cc163. --- Package.swift | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Package.swift b/Package.swift index 189c77e6b..7f02c5ab9 100644 --- a/Package.swift +++ b/Package.swift @@ -73,26 +73,26 @@ let package = Package( ], path: "Sources/CLI" ), -// .target( -// name: "ContainerCLI", -// dependencies: [ -// .product(name: "ArgumentParser", package: "swift-argument-parser"), -// .product(name: "Logging", package: "swift-log"), -// .product(name: "SwiftProtobuf", package: "swift-protobuf"), -// .product(name: "Containerization", package: "containerization"), -// .product(name: "ContainerizationOCI", package: "containerization"), -// .product(name: "ContainerizationOS", package: "containerization"), -// "CVersion", -// "TerminalProgress", -// "ContainerBuild", -// "ContainerClient", -// "ContainerPlugin", -// "ContainerLog", -// "Yams", -// "Rainbow", -// ], -// path: "Sources/ContainerCLI" -// ), + .target( + name: "ContainerCLI", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "SwiftProtobuf", package: "swift-protobuf"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + "CVersion", + "TerminalProgress", + "ContainerBuild", + "ContainerClient", + "ContainerPlugin", + "ContainerLog", + "Yams", + "Rainbow", + ], + path: "Sources/ContainerCLI" + ), .executableTarget( name: "container-apiserver", dependencies: [ From d7206c60535fe923f7f7706492879a1836fa41e4 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 22:48:23 -0600 Subject: [PATCH 61/80] Reapply "Update ContainerCLI" This reverts commit 8665d486e70a7c6930996346e819533268b916ba. --- Sources/ContainerCLI | Bin 916 -> 916 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI index ce9b7e01c3c1838dc5198f48c2a927a02ac3d673..6c3354e6261006b2d823f861983b6eee8933a906 100644 GIT binary patch delta 16 XcmbQjK81aP2J60q+!Bf#b<>#vEh+^6 delta 16 XcmbQjK81aP25Zg7RBwfiy6MaSFZ%^~ From 23728e5a1a3beaaf8e0bf6ad05bbd99aa225481c Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 22:48:26 -0600 Subject: [PATCH 62/80] Reapply "expose cli fixes" This reverts commit a359cf36fc779fbcbe6d5cdff71e4e624f7f3523. --- Package.swift | 22 +--------------------- Sources/ContainerCLI | Bin 916 -> 0 bytes 2 files changed, 1 insertion(+), 21 deletions(-) delete mode 100644 Sources/ContainerCLI diff --git a/Package.swift b/Package.swift index 7f02c5ab9..6710ef66d 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,7 @@ let package = Package( .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService"]), .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerClient", targets: ["ContainerClient"]), -// .library(name: "ContainerCLI", targets: ["ContainerCLI"]), + .executable(name: "ContainerCLI", targets: ["container"]), .library(name: "ContainerBuild", targets: ["ContainerBuild"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), @@ -73,26 +73,6 @@ let package = Package( ], path: "Sources/CLI" ), - .target( - name: "ContainerCLI", - dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "Logging", package: "swift-log"), - .product(name: "SwiftProtobuf", package: "swift-protobuf"), - .product(name: "Containerization", package: "containerization"), - .product(name: "ContainerizationOCI", package: "containerization"), - .product(name: "ContainerizationOS", package: "containerization"), - "CVersion", - "TerminalProgress", - "ContainerBuild", - "ContainerClient", - "ContainerPlugin", - "ContainerLog", - "Yams", - "Rainbow", - ], - path: "Sources/ContainerCLI" - ), .executableTarget( name: "container-apiserver", dependencies: [ diff --git a/Sources/ContainerCLI b/Sources/ContainerCLI deleted file mode 100644 index 6c3354e6261006b2d823f861983b6eee8933a906..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 916 zcmZvaO)mpc6o&6qQIRlOp*y3oZCd@B)~X)_iNvC7VT3anwIM z-pcemA<`~n;S5k6U=WL<2vKa9b^|=be$B9}#?Ehk)37QgawD8KtolKzS~qR@!yL<7 zhqhrh1huA~xwTc{Cq`2`kOqaJG*o~pkOy*OLuVNPlYWK{^T+pWj`jK7%k@70_^RJO zXJ0=54As}rE!2eCPzR#t(Ht@@CHJ-wxN+(+Bm5kjVUQ~Y6%0+z5U=@jW{Q!?+&hxg zU%}9}t9#}`C=_yh#A_vEJ+DO)@_mK4iyoo&=R8jz_ZRTzXg}(+(b!Tptn0y;o{9xy z89g3MbKIo~@yR?0-e*?9C(IMz zTV^l#jM)diX4b&Z%we$XuQYK7IX(eK$}w*fGvT<>{;gNR^7~G|_U Date: Tue, 15 Jul 2025 22:48:28 -0600 Subject: [PATCH 63/80] Reapply "Moved container executable to its own file, allowing the exposure of CLI as a library" This reverts commit 1b329d90421f00986c80c582acb1cd818c0ea57e. --- Package.swift | 10 ++++- Sources/ExecutableCLI/Application.swift | 52 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 Sources/ExecutableCLI/Application.swift diff --git a/Package.swift b/Package.swift index 6710ef66d..69ee6702a 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,7 @@ let package = Package( .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService"]), .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerClient", targets: ["ContainerClient"]), - .executable(name: "ContainerCLI", targets: ["container"]), + .library(name: "ContainerCLI", targets: ["ContainerCLI"]), .library(name: "ContainerBuild", targets: ["ContainerBuild"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), @@ -57,6 +57,14 @@ let package = Package( targets: [ .executableTarget( name: "container", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + "ContainerCLI", + ], + path: "Sources/ExecutableCLI" + ), + .target( + name: "ContainerCLI", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), diff --git a/Sources/ExecutableCLI/Application.swift b/Sources/ExecutableCLI/Application.swift new file mode 100644 index 000000000..4ce5bdc35 --- /dev/null +++ b/Sources/ExecutableCLI/Application.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerCLI +import ArgumentParser +import CVersion +import ContainerClient +import ContainerLog +import ContainerPlugin +import ContainerizationError +import ContainerizationOS +import Foundation +import Logging +import TerminalProgress + +@main +public struct Executable: AsyncParsableCommand { + public init() {} + +// @OptionGroup +// var global: Flags.Global + + public static let configuration = CommandConfiguration( + commandName: "container", + abstract: "A container platform for macOS", + subcommands: [ + ] + ) + + public static func main() async throws { +// try await Application.main() + } + + public func validate() throws { +// try Application.validate() + } +} From 119fd44b51cdc81b1fb1d5243f2291cf25873dda Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 22:48:30 -0600 Subject: [PATCH 64/80] =?UTF-8?q?Reapply=20"Moved=20container=20executable?= =?UTF-8?q?=20to=20its=20own=20file,=20allowing=20the=20exposure=20of=20CL?= =?UTF-8?q?I=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 7ca17a21d51e80a5806098b5cefa6cc3ced0f92b. --- Package.swift | 1 + Sources/CLI/Application.swift | 3 +- .../{Application.swift => Executable.swift} | 32 +++++++------------ 3 files changed, 13 insertions(+), 23 deletions(-) rename Sources/ExecutableCLI/{Application.swift => Executable.swift} (64%) diff --git a/Package.swift b/Package.swift index 69ee6702a..8c6377778 100644 --- a/Package.swift +++ b/Package.swift @@ -59,6 +59,7 @@ let package = Package( name: "container", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), + "ContainerClient", "ContainerCLI", ], path: "Sources/ExecutableCLI" diff --git a/Sources/CLI/Application.swift b/Sources/CLI/Application.swift index e62ded4fd..c1354ea2e 100644 --- a/Sources/CLI/Application.swift +++ b/Sources/CLI/Application.swift @@ -40,12 +40,11 @@ nonisolated(unsafe) var log = { return log }() -@main public struct Application: AsyncParsableCommand { public init() {} @OptionGroup - var global: Flags.Global + public var global: Flags.Global public static let configuration = CommandConfiguration( commandName: "container", diff --git a/Sources/ExecutableCLI/Application.swift b/Sources/ExecutableCLI/Executable.swift similarity index 64% rename from Sources/ExecutableCLI/Application.swift rename to Sources/ExecutableCLI/Executable.swift index 4ce5bdc35..56bcf0da5 100644 --- a/Sources/ExecutableCLI/Application.swift +++ b/Sources/ExecutableCLI/Executable.swift @@ -16,37 +16,27 @@ // -//import ContainerCLI -import ArgumentParser -import CVersion +import ContainerCLI import ContainerClient -import ContainerLog -import ContainerPlugin -import ContainerizationError -import ContainerizationOS -import Foundation -import Logging -import TerminalProgress +import ArgumentParser @main public struct Executable: AsyncParsableCommand { public init() {} -// @OptionGroup -// var global: Flags.Global + @OptionGroup + var global: Flags.Global - public static let configuration = CommandConfiguration( - commandName: "container", - abstract: "A container platform for macOS", - subcommands: [ - ] - ) + public static let configuration = Application.configuration public static func main() async throws { -// try await Application.main() + try await Application.main() } - public func validate() throws { -// try Application.validate() + public func run() async throws { + var application = Application() + application.global = global + try application.validate() + try application.run() } } From 41625149da09623194068531d1285981eddb766c Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 22:50:31 -0600 Subject: [PATCH 65/80] linker fixes? --- Package.swift | 1 + Plugins/compose/Commands/ComposeDown.swift | 2 +- Plugins/compose/Commands/ComposeUp.swift | 2 +- Plugins/compose/ComposeCommand.swift | 2 +- Plugins/compose/Errors.swift | 2 +- Plugins/compose/Helper Functions.swift | 2 +- 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Package.swift b/Package.swift index 8c6377778..22565ad6c 100644 --- a/Package.swift +++ b/Package.swift @@ -324,6 +324,7 @@ let package = Package( .executableTarget( name: "compose", dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), "container", "Yams", "Rainbow", diff --git a/Plugins/compose/Commands/ComposeDown.swift b/Plugins/compose/Commands/ComposeDown.swift index 39826b22e..aa2a5bd1d 100644 --- a/Plugins/compose/Commands/ComposeDown.swift +++ b/Plugins/compose/Commands/ComposeDown.swift @@ -25,7 +25,7 @@ import ArgumentParser import ContainerClient import Foundation import Yams -import container +import ContainerCLI extension Application { public struct ComposeDown: AsyncParsableCommand { diff --git a/Plugins/compose/Commands/ComposeUp.swift b/Plugins/compose/Commands/ComposeUp.swift index 7230dd63a..f00295cb8 100644 --- a/Plugins/compose/Commands/ComposeUp.swift +++ b/Plugins/compose/Commands/ComposeUp.swift @@ -27,7 +27,7 @@ import Foundation @preconcurrency import Rainbow import Yams import ContainerizationExtras -import container +import ContainerCLI extension Application { public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { diff --git a/Plugins/compose/ComposeCommand.swift b/Plugins/compose/ComposeCommand.swift index 07ebd683a..f630636e7 100644 --- a/Plugins/compose/ComposeCommand.swift +++ b/Plugins/compose/ComposeCommand.swift @@ -25,7 +25,7 @@ import ArgumentParser import Foundation import Rainbow import Yams -import container +import ContainerCLI extension Application { struct ComposeCommand: AsyncParsableCommand { diff --git a/Plugins/compose/Errors.swift b/Plugins/compose/Errors.swift index 3b650cd1b..233a9459b 100644 --- a/Plugins/compose/Errors.swift +++ b/Plugins/compose/Errors.swift @@ -22,7 +22,7 @@ // import Foundation -import container +import ContainerCLI extension Application { internal enum YamlError: Error, LocalizedError { diff --git a/Plugins/compose/Helper Functions.swift b/Plugins/compose/Helper Functions.swift index 6defad617..462bd9c93 100644 --- a/Plugins/compose/Helper Functions.swift +++ b/Plugins/compose/Helper Functions.swift @@ -23,7 +23,7 @@ import Foundation import Yams -import container +import ContainerCLI extension Application { /// Loads environment variables from a .env file. From 4518172705cd9eef43553cb6eb15033936ce90d8 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Tue, 15 Jul 2025 22:53:48 -0600 Subject: [PATCH 66/80] Access control fixes --- Plugins/compose/Commands/ComposeDown.swift | 122 +- Plugins/compose/Commands/ComposeUp.swift | 1182 ++++++++++---------- Plugins/compose/ComposeCommand.swift | 19 +- Plugins/compose/Errors.swift | 12 +- Plugins/compose/Helper Functions.swift | 8 +- 5 files changed, 669 insertions(+), 674 deletions(-) diff --git a/Plugins/compose/Commands/ComposeDown.swift b/Plugins/compose/Commands/ComposeDown.swift index aa2a5bd1d..f1c54de83 100644 --- a/Plugins/compose/Commands/ComposeDown.swift +++ b/Plugins/compose/Commands/ComposeDown.swift @@ -27,79 +27,77 @@ import Foundation import Yams import ContainerCLI -extension Application { - public struct ComposeDown: AsyncParsableCommand { - public init() {} - - public static let configuration: CommandConfiguration = .init( - commandName: "down", - abstract: "Stop containers with compose" - ) +struct ComposeDown: AsyncParsableCommand { + public init() {} + + public static let configuration: CommandConfiguration = .init( + commandName: "down", + abstract: "Stop containers with compose" + ) + + @Argument(help: "Specify the services to stop") + var services: [String] = [] + + @OptionGroup + var process: Flags.Process + + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + + private var fileManager: FileManager { FileManager.default } + private var projectName: String? + + public mutating func run() async throws { + // Read docker-compose.yml content + guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { + throw YamlError.dockerfileNotFound(dockerComposePath) + } - @Argument(help: "Specify the services to stop") - var services: [String] = [] + // Decode the YAML file into the DockerCompose struct + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - @OptionGroup - var process: Flags.Process + // Determine project name for container naming + if let name = dockerCompose.name { + projectName = name + print("Info: Docker Compose project name parsed as: \(name)") + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + } else { + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") + } - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) + services = try Service.topoSortConfiguredServices(services) - var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + // Filter for specified services + if !self.services.isEmpty { + services = services.filter({ serviceName, service in + self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) + }) + } - private var fileManager: FileManager { FileManager.default } - private var projectName: String? + try await stopOldStuff(services.map({ $0.serviceName }), remove: false) + } + + private func stopOldStuff(_ services: [String], remove: Bool) async throws { + guard let projectName else { return } + let containers = services.map { "\(projectName)-\($0)" } - public mutating func run() async throws { - // Read docker-compose.yml content - guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { - throw YamlError.dockerfileNotFound(dockerComposePath) - } + for container in containers { + print("Stopping container: \(container)") + guard let container = try? await ClientContainer.get(id: container) else { continue } - // Decode the YAML file into the DockerCompose struct - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - - // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") - } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") + do { + try await container.stop() + } catch { } - - var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) - services = try Service.topoSortConfiguredServices(services) - - // Filter for specified services - if !self.services.isEmpty { - services = services.filter({ serviceName, service in - self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) - }) - } - - try await stopOldStuff(services.map({ $0.serviceName }), remove: false) - } - - private func stopOldStuff(_ services: [String], remove: Bool) async throws { - guard let projectName else { return } - let containers = services.map { "\(projectName)-\($0)" } - - for container in containers { - print("Stopping container: \(container)") - guard let container = try? await ClientContainer.get(id: container) else { continue } - + if remove { do { - try await container.stop() + try await container.delete() } catch { } - if remove { - do { - try await container.delete() - } catch { - } - } } } } diff --git a/Plugins/compose/Commands/ComposeUp.swift b/Plugins/compose/Commands/ComposeUp.swift index f00295cb8..68849dc83 100644 --- a/Plugins/compose/Commands/ComposeUp.swift +++ b/Plugins/compose/Commands/ComposeUp.swift @@ -29,660 +29,658 @@ import Yams import ContainerizationExtras import ContainerCLI -extension Application { - public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { - public init() {} +public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { + public init() {} + + public static let configuration: CommandConfiguration = .init( + commandName: "up", + abstract: "Start containers with compose" + ) + + @Argument(help: "Specify the services to start") + var services: [String] = [] + + @Flag( + name: [.customShort("d"), .customLong("detach")], + help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") + var detatch: Bool = false + + @Flag(name: [.customShort("b"), .customLong("build")]) + var rebuild: Bool = false + + @Flag(name: .long, help: "Do not use cache") + var noCache: Bool = false + + @OptionGroup + var process: Flags.Process + + @OptionGroup + var global: Flags.Global + + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file + // + private var fileManager: FileManager { FileManager.default } + private var projectName: String? + private var environmentVariables: [String: String] = [:] + private var containerIps: [String: String] = [:] + private var containerConsoleColors: [String: NamedColor] = [:] + + private static let availableContainerConsoleColors: Set = [ + .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, + ] + + public mutating func run() async throws { + // Read docker-compose.yml content + guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { + throw YamlError.dockerfileNotFound(dockerComposePath) + } - public static let configuration: CommandConfiguration = .init( - commandName: "up", - abstract: "Start containers with compose" - ) + // Decode the YAML file into the DockerCompose struct + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - @Argument(help: "Specify the services to start") - var services: [String] = [] + // Load environment variables from .env file + environmentVariables = loadEnvFile(path: envFilePath) - @Flag( - name: [.customShort("d"), .customLong("detach")], - help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") - var detatch: Bool = false + // Handle 'version' field + if let version = dockerCompose.version { + print("Info: Docker Compose file version parsed as: \(version)") + print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") + } - @Flag(name: [.customShort("b"), .customLong("build")]) - var rebuild: Bool = false + // Determine project name for container naming + if let name = dockerCompose.name { + projectName = name + print("Info: Docker Compose project name parsed as: \(name)") + print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + } else { + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") + } - @Flag(name: .long, help: "Do not use cache") - var noCache: Bool = false + // Get Services to use + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) + services = try Service.topoSortConfiguredServices(services) - @OptionGroup - var process: Flags.Process + // Filter for specified services + if !self.services.isEmpty { + services = services.filter({ serviceName, service in + self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) + }) + } - @OptionGroup - var global: Flags.Global + // Stop Services + try await stopOldStuff(services.map({ $0.serviceName }), remove: true) - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml - var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file - // - private var fileManager: FileManager { FileManager.default } - private var projectName: String? - private var environmentVariables: [String: String] = [:] - private var containerIps: [String: String] = [:] - private var containerConsoleColors: [String: NamedColor] = [:] - - private static let availableContainerConsoleColors: Set = [ - .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, - ] - - public mutating func run() async throws { - // Read docker-compose.yml content - guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { - throw YamlError.dockerfileNotFound(dockerComposePath) - } - - // Decode the YAML file into the DockerCompose struct - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - - // Load environment variables from .env file - environmentVariables = loadEnvFile(path: envFilePath) - - // Handle 'version' field - if let version = dockerCompose.version { - print("Info: Docker Compose file version parsed as: \(version)") - print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") - } - - // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") - } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") - } - - // Get Services to use - var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) - services = try Service.topoSortConfiguredServices(services) - - // Filter for specified services - if !self.services.isEmpty { - services = services.filter({ serviceName, service in - self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) - }) - } - - // Stop Services - try await stopOldStuff(services.map({ $0.serviceName }), remove: true) - - // Process top-level networks - // This creates named networks defined in the docker-compose.yml - if let networks = dockerCompose.networks { - print("\n--- Processing Networks ---") - for (networkName, networkConfig) in networks { - try await setupNetwork(name: networkName, config: networkConfig) - } - print("--- Networks Processed ---\n") - } - - // Process top-level volumes - // This creates named volumes defined in the docker-compose.yml - if let volumes = dockerCompose.volumes { - print("\n--- Processing Volumes ---") - for (volumeName, volumeConfig) in volumes { - await createVolumeHardLink(name: volumeName, config: volumeConfig) - } - print("--- Volumes Processed ---\n") - } - - // Process each service defined in the docker-compose.yml - print("\n--- Processing Services ---") - - print(services.map(\.serviceName)) - for (serviceName, service) in services { - try await configService(service, serviceName: serviceName, from: dockerCompose) - } - - if !detatch { - await waitForever() - } + // Process top-level networks + // This creates named networks defined in the docker-compose.yml + if let networks = dockerCompose.networks { + print("\n--- Processing Networks ---") + for (networkName, networkConfig) in networks { + try await setupNetwork(name: networkName, config: networkConfig) + } + print("--- Networks Processed ---\n") } - func waitForever() async -> Never { - for await _ in AsyncStream(unfolding: {}) { - // This will never run - } - fatalError("unreachable") + // Process top-level volumes + // This creates named volumes defined in the docker-compose.yml + if let volumes = dockerCompose.volumes { + print("\n--- Processing Volumes ---") + for (volumeName, volumeConfig) in volumes { + await createVolumeHardLink(name: volumeName, config: volumeConfig) + } + print("--- Volumes Processed ---\n") } - private func getIPForRunningService(_ serviceName: String) async throws -> String? { - guard let projectName else { return nil } - - let containerName = "\(projectName)-\(serviceName)" - - let container = try await ClientContainer.get(id: containerName) - let ip = container.networks.compactMap { try? CIDRAddress($0.address).address.description }.first - - return ip - } - - /// Repeatedly checks `container list -a` until the given container is listed as `running`. - /// - Parameters: - /// - containerName: The exact name of the container (e.g. "Assignment-Manager-API-db"). - /// - timeout: Max seconds to wait before failing. - /// - interval: How often to poll (in seconds). - /// - Returns: `true` if the container reached "running" state within the timeout. - private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { - guard let projectName else { return } - let containerName = "\(projectName)-\(serviceName)" - - let deadline = Date().addingTimeInterval(timeout) - - while Date() < deadline { - let container = try? await ClientContainer.get(id: containerName) - if container?.status == .running { - return - } - - try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + // Process each service defined in the docker-compose.yml + print("\n--- Processing Services ---") + + print(services.map(\.serviceName)) + for (serviceName, service) in services { + try await configService(service, serviceName: serviceName, from: dockerCompose) + } + + if !detatch { + await waitForever() + } + } + + func waitForever() async -> Never { + for await _ in AsyncStream(unfolding: {}) { + // This will never run + } + fatalError("unreachable") + } + + private func getIPForRunningService(_ serviceName: String) async throws -> String? { + guard let projectName else { return nil } + + let containerName = "\(projectName)-\(serviceName)" + + let container = try await ClientContainer.get(id: containerName) + let ip = container.networks.compactMap { try? CIDRAddress($0.address).address.description }.first + + return ip + } + + /// Repeatedly checks `container list -a` until the given container is listed as `running`. + /// - Parameters: + /// - containerName: The exact name of the container (e.g. "Assignment-Manager-API-db"). + /// - timeout: Max seconds to wait before failing. + /// - interval: How often to poll (in seconds). + /// - Returns: `true` if the container reached "running" state within the timeout. + private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { + guard let projectName else { return } + let containerName = "\(projectName)-\(serviceName)" + + let deadline = Date().addingTimeInterval(timeout) + + while Date() < deadline { + let container = try? await ClientContainer.get(id: containerName) + if container?.status == .running { + return } - throw NSError( - domain: "ContainerWait", code: 1, - userInfo: [ - NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." - ]) + try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) } - private func stopOldStuff(_ services: [String], remove: Bool) async throws { - guard let projectName else { return } - let containers = services.map { "\(projectName)-\($0)" } + throw NSError( + domain: "ContainerWait", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." + ]) + } + + private func stopOldStuff(_ services: [String], remove: Bool) async throws { + guard let projectName else { return } + let containers = services.map { "\(projectName)-\($0)" } + + for container in containers { + print("Stopping container: \(container)") + guard let container = try? await ClientContainer.get(id: container) else { continue } - for container in containers { - print("Stopping container: \(container)") - guard let container = try? await ClientContainer.get(id: container) else { continue } - + do { + try await container.stop() + } catch { + } + if remove { do { - try await container.stop() + try await container.delete() } catch { } - if remove { - do { - try await container.delete() - } catch { - } - } } } + } + + // MARK: Compose Top Level Functions + + private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { + let ip = try await getIPForRunningService(serviceName) + self.containerIps[serviceName] = ip + for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { + self.environmentVariables[key] = ip ?? value + } + } + + private func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { + guard let projectName else { return } + let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name + + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") + let volumePath = volumeUrl.path(percentEncoded: false) - // MARK: Compose Top Level Functions + print( + "Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + ) + try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + } + + private func setupNetwork(name networkName: String, config networkConfig: Network) async throws { + let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name - private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { - let ip = try await getIPForRunningService(serviceName) - self.containerIps[serviceName] = ip - for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { - self.environmentVariables[key] = ip ?? value + if let externalNetwork = networkConfig.external, externalNetwork.isExternal { + print("Info: Network '\(networkName)' is declared as external.") + print("This tool assumes external network '\(externalNetwork.name ?? actualNetworkName)' already exists and will not attempt to create it.") + } else { + var networkCreateArgs: [String] = ["network", "create"] + +#warning("Docker Compose Network Options Not Supported") + // Add driver and driver options + if let driver = networkConfig.driver, !driver.isEmpty { + // networkCreateArgs.append("--driver") + // networkCreateArgs.append(driver) + print("Network Driver Detected, But Not Supported") + } + if let driverOpts = networkConfig.driver_opts, !driverOpts.isEmpty { + // for (optKey, optValue) in driverOpts { + // networkCreateArgs.append("--opt") + // networkCreateArgs.append("\(optKey)=\(optValue)") + // } + print("Network Options Detected, But Not Supported") + } + // Add various network flags + if networkConfig.attachable == true { + // networkCreateArgs.append("--attachable") + print("Network Attachable Flag Detected, But Not Supported") + } + if networkConfig.enable_ipv6 == true { + // networkCreateArgs.append("--ipv6") + print("Network IPv6 Flag Detected, But Not Supported") + } + if networkConfig.isInternal == true { + // networkCreateArgs.append("--internal") + print("Network Internal Flag Detected, But Not Supported") + } // CORRECTED: Use isInternal + + // Add labels + if let labels = networkConfig.labels, !labels.isEmpty { + print("Network Labels Detected, But Not Supported") + // for (labelKey, labelValue) in labels { + // networkCreateArgs.append("--label") + // networkCreateArgs.append("\(labelKey)=\(labelValue)") + // } + } + + print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") + print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") + guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else { + print("Network '\(networkName)' already exists") + return } + var networkCreate = Application.NetworkCreate() + networkCreate.global = global + networkCreate.name = actualNetworkName + + try await networkCreate.run() + print("Network '\(networkName)' created") } + } + + // MARK: Compose Service Level Functions + private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { + guard let projectName else { throw ComposeError.invalidProjectName } - private func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { - guard let projectName else { return } - let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name - - let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") - let volumePath = volumeUrl.path(percentEncoded: false) - + var imageToRun: String + + // Handle 'build' configuration + if let buildConfig = service.build { + imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) + } else if let img = service.image { + // Use specified image if no build config + // Pull image if necessary + try await pullImage(img, platform: service.container_name) + imageToRun = img + } else { + // Should not happen due to Service init validation, but as a fallback + throw ComposeError.imageNotFound(serviceName) + } + + // Handle 'deploy' configuration (note that this tool doesn't fully support it) + if service.deploy != nil { + print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") print( - "Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." ) - try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + print("The service will be run as a single container based on other configurations.") } - private func setupNetwork(name networkName: String, config networkConfig: Network) async throws { - let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name - - if let externalNetwork = networkConfig.external, externalNetwork.isExternal { - print("Info: Network '\(networkName)' is declared as external.") - print("This tool assumes external network '\(externalNetwork.name ?? actualNetworkName)' already exists and will not attempt to create it.") - } else { - var networkCreateArgs: [String] = ["network", "create"] - - #warning("Docker Compose Network Options Not Supported") - // Add driver and driver options - if let driver = networkConfig.driver, !driver.isEmpty { -// networkCreateArgs.append("--driver") -// networkCreateArgs.append(driver) - print("Network Driver Detected, But Not Supported") - } - if let driverOpts = networkConfig.driver_opts, !driverOpts.isEmpty { -// for (optKey, optValue) in driverOpts { -// networkCreateArgs.append("--opt") -// networkCreateArgs.append("\(optKey)=\(optValue)") -// } - print("Network Options Detected, But Not Supported") - } - // Add various network flags - if networkConfig.attachable == true { -// networkCreateArgs.append("--attachable") - print("Network Attachable Flag Detected, But Not Supported") - } - if networkConfig.enable_ipv6 == true { -// networkCreateArgs.append("--ipv6") - print("Network IPv6 Flag Detected, But Not Supported") - } - if networkConfig.isInternal == true { -// networkCreateArgs.append("--internal") - print("Network Internal Flag Detected, But Not Supported") - } // CORRECTED: Use isInternal - - // Add labels - if let labels = networkConfig.labels, !labels.isEmpty { - print("Network Labels Detected, But Not Supported") -// for (labelKey, labelValue) in labels { -// networkCreateArgs.append("--label") -// networkCreateArgs.append("\(labelKey)=\(labelValue)") -// } - } - - print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") - print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") - guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else { - print("Network '\(networkName)' already exists") - return - } - var networkCreate = NetworkCreate() - networkCreate.global = global - networkCreate.name = actualNetworkName - - try await networkCreate.run() - print("Network '\(networkName)' created") - } + var runCommandArgs: [String] = [] + + // Add detach flag if specified on the CLI + if detatch { + runCommandArgs.append("-d") } - // MARK: Compose Service Level Functions - private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { - guard let projectName else { throw ComposeError.invalidProjectName } - - var imageToRun: String - - // Handle 'build' configuration - if let buildConfig = service.build { - imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) - } else if let img = service.image { - // Use specified image if no build config - // Pull image if necessary - try await pullImage(img, platform: service.container_name) - imageToRun = img - } else { - // Should not happen due to Service init validation, but as a fallback - throw ComposeError.imageNotFound(serviceName) - } - - // Handle 'deploy' configuration (note that this tool doesn't fully support it) - if service.deploy != nil { - print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") - print( - "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." - ) - print("The service will be run as a single container based on other configurations.") - } - - var runCommandArgs: [String] = [] - - // Add detach flag if specified on the CLI - if detatch { - runCommandArgs.append("-d") - } - - // Determine container name - let containerName: String - if let explicitContainerName = service.container_name { - containerName = explicitContainerName - print("Info: Using explicit container_name: \(containerName)") - } else { - // Default container name based on project and service name - containerName = "\(projectName)-\(serviceName)" - } - runCommandArgs.append("--name") - runCommandArgs.append(containerName) - - // REMOVED: Restart policy is not supported by `container run` - // if let restart = service.restart { - // runCommandArgs.append("--restart") - // runCommandArgs.append(restart) - // } - - // Add user - if let user = service.user { - runCommandArgs.append("--user") - runCommandArgs.append(user) - } - - // Add volume mounts - if let volumes = service.volumes { - for volume in volumes { - let args = try await configVolume(volume) - runCommandArgs.append(contentsOf: args) - } - } - - // Combine environment variables from .env files and service environment - var combinedEnv: [String: String] = environmentVariables - - if let envFiles = service.env_file { - for envFile in envFiles { - let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") - combinedEnv.merge(additionalEnvVars) { (current, _) in current } - } - } - - if let serviceEnv = service.environment { - combinedEnv.merge(serviceEnv) { (old, new) in - guard !new.contains("${") else { - return old - } - return new - } // Service env overrides .env files + // Determine container name + let containerName: String + if let explicitContainerName = service.container_name { + containerName = explicitContainerName + print("Info: Using explicit container_name: \(containerName)") + } else { + // Default container name based on project and service name + containerName = "\(projectName)-\(serviceName)" + } + runCommandArgs.append("--name") + runCommandArgs.append(containerName) + + // REMOVED: Restart policy is not supported by `container run` + // if let restart = service.restart { + // runCommandArgs.append("--restart") + // runCommandArgs.append(restart) + // } + + // Add user + if let user = service.user { + runCommandArgs.append("--user") + runCommandArgs.append(user) + } + + // Add volume mounts + if let volumes = service.volumes { + for volume in volumes { + let args = try await configVolume(volume) + runCommandArgs.append(contentsOf: args) } - - // Fill in variables - combinedEnv = combinedEnv.mapValues({ value in - guard value.contains("${") else { return value } - - let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) - return combinedEnv[variableName] ?? value - }) - - // Fill in IPs - combinedEnv = combinedEnv.mapValues({ value in - containerIps[value] ?? value - }) - - // MARK: Spinning Spot - // Add environment variables to run command - for (key, value) in combinedEnv { - runCommandArgs.append("-e") - runCommandArgs.append("\(key)=\(value)") + } + + // Combine environment variables from .env files and service environment + var combinedEnv: [String: String] = environmentVariables + + if let envFiles = service.env_file { + for envFile in envFiles { + let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") + combinedEnv.merge(additionalEnvVars) { (current, _) in current } } - - // REMOVED: Port mappings (-p) are not supported by `container run` - // if let ports = service.ports { - // for port in ports { - // let resolvedPort = resolveVariable(port, with: envVarsFromFile) - // runCommandArgs.append("-p") - // runCommandArgs.append(resolvedPort) - // } - // } - - // Connect to specified networks - if let serviceNetworks = service.networks { - for network in serviceNetworks { - let resolvedNetwork = resolveVariable(network, with: environmentVariables) - // Use the explicit network name from top-level definition if available, otherwise resolved name - let networkToConnect = dockerCompose.networks?[network]?.name ?? resolvedNetwork - runCommandArgs.append("--network") - runCommandArgs.append(networkToConnect) + } + + if let serviceEnv = service.environment { + combinedEnv.merge(serviceEnv) { (old, new) in + guard !new.contains("${") else { + return old } - print( - "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml." - ) - print( - "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." - ) - } else { - print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.") - } - - // Add hostname - if let hostname = service.hostname { - let resolvedHostname = resolveVariable(hostname, with: environmentVariables) - runCommandArgs.append("--hostname") - runCommandArgs.append(resolvedHostname) - } - - // Add working directory - if let workingDir = service.working_dir { - let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) - runCommandArgs.append("--workdir") - runCommandArgs.append(resolvedWorkingDir) - } - - // Add privileged flag - if service.privileged == true { - runCommandArgs.append("--privileged") - } + return new + } // Service env overrides .env files + } + + // Fill in variables + combinedEnv = combinedEnv.mapValues({ value in + guard value.contains("${") else { return value } - // Add read-only flag - if service.read_only == true { - runCommandArgs.append("--read-only") + let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) + return combinedEnv[variableName] ?? value + }) + + // Fill in IPs + combinedEnv = combinedEnv.mapValues({ value in + containerIps[value] ?? value + }) + + // MARK: Spinning Spot + // Add environment variables to run command + for (key, value) in combinedEnv { + runCommandArgs.append("-e") + runCommandArgs.append("\(key)=\(value)") + } + + // REMOVED: Port mappings (-p) are not supported by `container run` + // if let ports = service.ports { + // for port in ports { + // let resolvedPort = resolveVariable(port, with: envVarsFromFile) + // runCommandArgs.append("-p") + // runCommandArgs.append(resolvedPort) + // } + // } + + // Connect to specified networks + if let serviceNetworks = service.networks { + for network in serviceNetworks { + let resolvedNetwork = resolveVariable(network, with: environmentVariables) + // Use the explicit network name from top-level definition if available, otherwise resolved name + let networkToConnect = dockerCompose.networks?[network]?.name ?? resolvedNetwork + runCommandArgs.append("--network") + runCommandArgs.append(networkToConnect) } - - // Handle service-level configs (note: still only parsing/logging, not attaching) - if let serviceConfigs = service.configs { + print( + "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in docker-compose.yml." + ) + print( + "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." + ) + } else { + print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.") + } + + // Add hostname + if let hostname = service.hostname { + let resolvedHostname = resolveVariable(hostname, with: environmentVariables) + runCommandArgs.append("--hostname") + runCommandArgs.append(resolvedHostname) + } + + // Add working directory + if let workingDir = service.working_dir { + let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) + runCommandArgs.append("--workdir") + runCommandArgs.append(resolvedWorkingDir) + } + + // Add privileged flag + if service.privileged == true { + runCommandArgs.append("--privileged") + } + + // Add read-only flag + if service.read_only == true { + runCommandArgs.append("--read-only") + } + + // Handle service-level configs (note: still only parsing/logging, not attaching) + if let serviceConfigs = service.configs { + print( + "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) + print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.") + for serviceConfig in serviceConfigs { print( - "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" ) - print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.") - for serviceConfig in serviceConfigs { - print( - " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" - ) - } } - // - // Handle service-level secrets (note: still only parsing/logging, not attaching) - if let serviceSecrets = service.secrets { + } + // + // Handle service-level secrets (note: still only parsing/logging, not attaching) + if let serviceSecrets = service.secrets { + print( + "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) + print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.") + for serviceSecret in serviceSecrets { print( - "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" ) - print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.") - for serviceSecret in serviceSecrets { - print( - " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" - ) - } - } - - // Add interactive and TTY flags - if service.stdin_open == true { - runCommandArgs.append("-i") // --interactive - } - if service.tty == true { - runCommandArgs.append("-t") // --tty - } - - runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint - - // Add entrypoint or command - if let entrypointParts = service.entrypoint { - runCommandArgs.append("--entrypoint") - runCommandArgs.append(contentsOf: entrypointParts) - } else if let commandParts = service.command { - runCommandArgs.append(contentsOf: commandParts) - } - - var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! - - if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { - while containerConsoleColors.values.contains(serviceColor) { - serviceColor = Self.availableContainerConsoleColors.randomElement()! - } - } - - self.containerConsoleColors[serviceName] = serviceColor - - Task { [self, serviceColor] in - @Sendable - func handleOutput(_ output: String) { - print("\(serviceName): \(output)".applyingColor(serviceColor)) - } - - print("\nStarting service: \(serviceName)") - print("Starting \(serviceName)") - print("----------------------------------------\n") - let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) - } - - do { - try await waitUntilServiceIsRunning(serviceName) - try await updateEnvironmentWithServiceIP(serviceName) - } catch { - print(error) } } - private func pullImage(_ imageName: String, platform: String?) async throws { - let imageList = try await ClientImage.list() - guard !imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageName }) else { - return + // Add interactive and TTY flags + if service.stdin_open == true { + runCommandArgs.append("-i") // --interactive + } + if service.tty == true { + runCommandArgs.append("-t") // --tty + } + + runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint + + // Add entrypoint or command + if let entrypointParts = service.entrypoint { + runCommandArgs.append("--entrypoint") + runCommandArgs.append(contentsOf: entrypointParts) + } else if let commandParts = service.command { + runCommandArgs.append(contentsOf: commandParts) + } + + var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! + + if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { + while containerConsoleColors.values.contains(serviceColor) { + serviceColor = Self.availableContainerConsoleColors.randomElement()! } - - print("Pulling Image \(imageName)...") - var registry = Flags.Registry() - registry.scheme = "auto" // Set or SwiftArgumentParser gets mad - - var progress = Flags.Progress() - progress.disableProgressUpdates = false - - var imagePull = ImagePull() - imagePull.progressFlags = progress - imagePull.registry = registry - imagePull.global = global - imagePull.reference = imageName - imagePull.platform = platform - try await imagePull.run() - } - - /// Builds Docker Service - /// - /// - Parameters: - /// - buildConfig: The configuration for the build - /// - service: The service you would like to build - /// - serviceName: The fallback name for the image - /// - /// - Returns: Image Name (`String`) - private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { - // Determine image tag for built image - let imageToRun = service.image ?? "\(serviceName):latest" - let imageList = try await ClientImage.list() - if !rebuild, imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageToRun }) { - return imageToRun + } + + self.containerConsoleColors[serviceName] = serviceColor + + Task { [self, serviceColor] in + @Sendable + func handleOutput(_ output: String) { + print("\(serviceName): \(output)".applyingColor(serviceColor)) } - var buildCommand = BuildCommand() - - // Set Build Commands - buildCommand.buildArg = (buildConfig.args ?? [:]).map({ "\($0.key)=\(resolveVariable($0.value, with: environmentVariables))" }) - - // Locate Dockerfile and context - buildCommand.contextDir = "\(self.cwd)/\(buildConfig.context)" - buildCommand.file = "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")" - - // Handle Caching - buildCommand.noCache = noCache - buildCommand.cacheIn = [] - buildCommand.cacheOut = [] - - // Handle OS/Arch - let split = service.platform?.split(separator: "/") - buildCommand.os = [String(split?.first ?? "linux")] - buildCommand.arch = [String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64")] - - // Set Image Name - buildCommand.targetImageName = imageToRun - - // Set CPU & Memory - buildCommand.cpus = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 - buildCommand.memory = service.deploy?.resources?.limits?.memory ?? "2048MB" - - // Set Miscelaneous - buildCommand.label = [] // No Label Equivalent? - buildCommand.progress = "auto" - buildCommand.vsockPort = 8088 - buildCommand.quiet = false - buildCommand.target = "" - buildCommand.output = ["type=oci"] - print("\n----------------------------------------") - print("Building image for service: \(serviceName) (Tag: \(imageToRun))") - try buildCommand.validate() - try await buildCommand.run() - print("Image build for \(serviceName) completed.") - print("----------------------------------------") - + print("\nStarting service: \(serviceName)") + print("Starting \(serviceName)") + print("----------------------------------------\n") + let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) + } + + do { + try await waitUntilServiceIsRunning(serviceName) + try await updateEnvironmentWithServiceIP(serviceName) + } catch { + print(error) + } + } + + private func pullImage(_ imageName: String, platform: String?) async throws { + let imageList = try await ClientImage.list() + guard !imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageName }) else { + return + } + + print("Pulling Image \(imageName)...") + var registry = Flags.Registry() + registry.scheme = "auto" // Set or SwiftArgumentParser gets mad + + var progress = Flags.Progress() + progress.disableProgressUpdates = false + + var imagePull = Application.ImagePull() + imagePull.progressFlags = progress + imagePull.registry = registry + imagePull.global = global + imagePull.reference = imageName + imagePull.platform = platform + try await imagePull.run() + } + + /// Builds Docker Service + /// + /// - Parameters: + /// - buildConfig: The configuration for the build + /// - service: The service you would like to build + /// - serviceName: The fallback name for the image + /// + /// - Returns: Image Name (`String`) + private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { + // Determine image tag for built image + let imageToRun = service.image ?? "\(serviceName):latest" + let imageList = try await ClientImage.list() + if !rebuild, imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageToRun }) { return imageToRun } - private func configVolume(_ volume: String) async throws -> [String] { - let resolvedVolume = resolveVariable(volume, with: environmentVariables) - - var runCommandArgs: [String] = [] - - // Parse the volume string: destination[:mode] - let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) - - guard components.count >= 2 else { - print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") - return [] - } - - let source = components[0] - let destination = components[1] - - // Check if the source looks like a host path (contains '/' or starts with '.') - // This heuristic helps distinguish bind mounts from named volume references. - if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { - // This is likely a bind mount (local path to container path) - var isDirectory: ObjCBool = false - // Ensure the path is absolute or relative to the current directory for FileManager - let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) - - if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { - if isDirectory.boolValue { - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument - } else { - // Host path exists but is a file - print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") - } + var buildCommand = Application.BuildCommand() + + // Set Build Commands + buildCommand.buildArg = (buildConfig.args ?? [:]).map({ "\($0.key)=\(resolveVariable($0.value, with: environmentVariables))" }) + + // Locate Dockerfile and context + buildCommand.contextDir = "\(self.cwd)/\(buildConfig.context)" + buildCommand.file = "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")" + + // Handle Caching + buildCommand.noCache = noCache + buildCommand.cacheIn = [] + buildCommand.cacheOut = [] + + // Handle OS/Arch + let split = service.platform?.split(separator: "/") + buildCommand.os = [String(split?.first ?? "linux")] + buildCommand.arch = [String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64")] + + // Set Image Name + buildCommand.targetImageName = imageToRun + + // Set CPU & Memory + buildCommand.cpus = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 + buildCommand.memory = service.deploy?.resources?.limits?.memory ?? "2048MB" + + // Set Miscelaneous + buildCommand.label = [] // No Label Equivalent? + buildCommand.progress = "auto" + buildCommand.vsockPort = 8088 + buildCommand.quiet = false + buildCommand.target = "" + buildCommand.output = ["type=oci"] + print("\n----------------------------------------") + print("Building image for service: \(serviceName) (Tag: \(imageToRun))") + try buildCommand.validate() + try await buildCommand.run() + print("Image build for \(serviceName) completed.") + print("----------------------------------------") + + return imageToRun + } + + private func configVolume(_ volume: String) async throws -> [String] { + let resolvedVolume = resolveVariable(volume, with: environmentVariables) + + var runCommandArgs: [String] = [] + + // Parse the volume string: destination[:mode] + let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) + + guard components.count >= 2 else { + print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") + return [] + } + + let source = components[0] + let destination = components[1] + + // Check if the source looks like a host path (contains '/' or starts with '.') + // This heuristic helps distinguish bind mounts from named volume references. + if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { + // This is likely a bind mount (local path to container path) + var isDirectory: ObjCBool = false + // Ensure the path is absolute or relative to the current directory for FileManager + let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) + + if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { + if isDirectory.boolValue { + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument } else { - // Host path does not exist, assume it's meant to be a directory and try to create it. - do { - try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) - print("Info: Created missing host directory for volume: \(fullHostPath)") - runCommandArgs.append("-v") - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument - } catch { - print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") - } + // Host path exists but is a file + print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") } } else { - guard let projectName else { return [] } - let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") - let volumePath = volumeUrl.path(percentEncoded: false) - - let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() - let destinationPath = destinationUrl.path(percentEncoded: false) - - print( - "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." - ) - try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) - - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument + // Host path does not exist, assume it's meant to be a directory and try to create it. + do { + try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) + print("Info: Created missing host directory for volume: \(fullHostPath)") + runCommandArgs.append("-v") + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + } catch { + print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") + } } + } else { + guard let projectName else { return [] } + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") + let volumePath = volumeUrl.path(percentEncoded: false) - return runCommandArgs + let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() + let destinationPath = destinationUrl.path(percentEncoded: false) + + print( + "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + ) + try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument } + + return runCommandArgs } } // MARK: CommandLine Functions -extension Application.ComposeUp { +extension ComposeUp { /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. /// diff --git a/Plugins/compose/ComposeCommand.swift b/Plugins/compose/ComposeCommand.swift index f630636e7..82a873bb8 100644 --- a/Plugins/compose/ComposeCommand.swift +++ b/Plugins/compose/ComposeCommand.swift @@ -27,16 +27,15 @@ import Rainbow import Yams import ContainerCLI -extension Application { - struct ComposeCommand: AsyncParsableCommand { - static let configuration: CommandConfiguration = .init( - commandName: "compose", - abstract: "Manage containers with Docker Compose files", - subcommands: [ - ComposeUp.self, - ComposeDown.self, - ]) - } +@main +struct ComposeCommand: AsyncParsableCommand { + static let configuration: CommandConfiguration = .init( + commandName: "compose", + abstract: "Manage containers with Docker Compose files", + subcommands: [ + ComposeUp.self, + ComposeDown.self, + ]) } /// A structure representing the result of a command-line process execution. diff --git a/Plugins/compose/Errors.swift b/Plugins/compose/Errors.swift index 233a9459b..df15740ef 100644 --- a/Plugins/compose/Errors.swift +++ b/Plugins/compose/Errors.swift @@ -24,8 +24,8 @@ import Foundation import ContainerCLI -extension Application { - internal enum YamlError: Error, LocalizedError { +//extension Application { + enum YamlError: Error, LocalizedError { case dockerfileNotFound(String) var errorDescription: String? { @@ -36,7 +36,7 @@ extension Application { } } - internal enum ComposeError: Error, LocalizedError { + enum ComposeError: Error, LocalizedError { case imageNotFound(String) case invalidProjectName @@ -50,7 +50,7 @@ extension Application { } } - internal enum TerminalError: Error, LocalizedError { + enum TerminalError: Error, LocalizedError { case commandFailed(String) var errorDescription: String? { @@ -59,9 +59,9 @@ extension Application { } /// An enum representing streaming output from either `stdout` or `stderr`. - internal enum CommandOutput { + enum CommandOutput { case stdout(String) case stderr(String) case exitCode(Int32) } -} +//} diff --git a/Plugins/compose/Helper Functions.swift b/Plugins/compose/Helper Functions.swift index 462bd9c93..524e8a8cd 100644 --- a/Plugins/compose/Helper Functions.swift +++ b/Plugins/compose/Helper Functions.swift @@ -25,11 +25,11 @@ import Foundation import Yams import ContainerCLI -extension Application { +//extension Application { /// Loads environment variables from a .env file. /// - Parameter path: The full path to the .env file. /// - Returns: A dictionary of key-value pairs representing environment variables. - internal static func loadEnvFile(path: String) -> [String: String] { + internal func loadEnvFile(path: String) -> [String: String] { var envVars: [String: String] = [:] let fileURL = URL(fileURLWithPath: path) do { @@ -60,7 +60,7 @@ extension Application { /// - value: The string possibly containing environment variable references. /// - envVars: A dictionary of environment variables to use for resolution. /// - Returns: The string with all recognized environment variables resolved. - internal static func resolveVariable(_ value: String, with envVars: [String: String]) -> String { + internal func resolveVariable(_ value: String, with envVars: [String: String]) -> String { var resolvedValue = value // Regex to find ${VAR}, ${VAR:-default}, ${VAR:?error} let regex = try! NSRegularExpression(pattern: "\\$\\{([A-Z0-9_]+)(:?-(.*?))?(:\\?(.*?))?\\}", options: []) @@ -92,6 +92,6 @@ extension Application { } return resolvedValue } -} +//} extension String: @retroactive Error {} From e7ce75244cbf71b76e1f9f47b0eca0b43bc05bd4 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Wed, 16 Jul 2025 19:42:05 -0600 Subject: [PATCH 67/80] update makefile to install compose as plugin --- Makefile | 50 ++++++++++++++++++++++++++---------- scripts/uninstall-compose.sh | 42 ++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 scripts/uninstall-compose.sh diff --git a/Makefile b/Makefile index 58b3c1d1e..68aaf1581 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ export GIT_COMMIT := $(shell git rev-parse HEAD) SWIFT := "/usr/bin/swift" DESTDIR ?= /usr/local/ ROOT_DIR := $(shell git rev-parse --show-toplevel) -BUILD_BIN_DIR = $(shell $(SWIFT) build -c $(BUILD_CONFIGURATION) --show-bin-path) +BUILD_BIN_DIR := .build/$(BUILD_CONFIGURATION) STAGING_DIR := bin/$(BUILD_CONFIGURATION)/staging/ PKG_PATH := bin/$(BUILD_CONFIGURATION)/container-installer-unsigned.pkg DSYM_DIR := bin/$(BUILD_CONFIGURATION)/bundle/container-dSYM @@ -37,7 +37,7 @@ SUDO ?= sudo include Protobuf.Makefile .PHONY: all -all: container +all: container compose all: init-block .PHONY: build @@ -46,10 +46,13 @@ build: @$(SWIFT) build -c $(BUILD_CONFIGURATION) .PHONY: container -container: build - @# Install binaries under project directory +container: build @"$(MAKE)" BUILD_CONFIGURATION=$(BUILD_CONFIGURATION) DESTDIR=$(ROOT_DIR)/ SUDO= install +.PHONY: compose +compose: build + @"$(MAKE)" BUILD_CONFIGURATION=$(BUILD_CONFIGURATION) DESTDIR=$(ROOT_DIR)/ SUDO= install-compose + .PHONY: release release: BUILD_CONFIGURATION = release release: all @@ -60,7 +63,7 @@ init-block: .PHONY: install install: installer-pkg - @echo Installing container installer package + @echo Installing container installer package @if [ -z "$(SUDO)" ] ; then \ temp_dir=$$(mktemp -d) ; \ xar -xf $(PKG_PATH) -C $${temp_dir} ; \ @@ -69,14 +72,8 @@ install: installer-pkg else \ $(SUDO) installer -pkg $(PKG_PATH) -target / ; \ fi - - @echo Installing compose into $(DESTDIR)... - @$(SUDO) mkdir -p $(join $(DESTDIR), libexec/container-plugins/compose/bin) - @$(SUDO) install $(BUILD_BIN_DIR)/compose $(join $(DESTDIR), libexec/container-plugins/compose/bin/compose) - @$(SUDO) install config/compose-config.json $(join $(DESTDIR), libexec/container-plugins/compose/config.json) - @$(SUDO) codesign $(CODESIGN_OPTS) $(join $(DESTDIR), libexec/container-plugins/compose/bin/compose) - -$(STAGING_DIR): + +$(STAGING_DIR): @echo Installing container binaries from $(BUILD_BIN_DIR) into $(STAGING_DIR)... @rm -rf $(STAGING_DIR) @mkdir -p $(join $(STAGING_DIR), bin) @@ -109,6 +106,31 @@ installer-pkg: $(STAGING_DIR) @pkgbuild --root $(STAGING_DIR) --identifier com.apple.container-installer --install-location /usr/local --version ${RELEASE_VERSION} $(PKG_PATH) @rm -rf $(STAGING_DIR) +################################################################################ +# Compose plugin install targets +################################################################################ + +COMPOSE_STAGING_DIR := bin/$(BUILD_CONFIGURATION)/compose-staging/ + +.PHONY: install-compose +install-compose: compose-staging + @echo Installing compose plugin to $(DESTDIR)... + @$(SUDO) mkdir -p $(DESTDIR)/libexec/container/plugins/compose/bin + @$(SUDO) install $(BUILD_BIN_DIR)/compose $(DESTDIR)/libexec/container/plugins/compose/bin/compose + @$(SUDO) install config/compose-config.json $(DESTDIR)/libexec/container/plugins/compose/config.json + @$(SUDO) install scripts/uninstall-compose.sh $(DESTDIR)/libexec/container/plugins/compose/bin/uninstall-compose.sh + @$(SUDO) codesign $(CODESIGN_OPTS) $(DESTDIR)/libexec/container/plugins/compose/bin/compose + +compose-staging: + @echo Staging compose plugin binaries to $(COMPOSE_STAGING_DIR)... + @rm -rf $(COMPOSE_STAGING_DIR) + @mkdir -p $(COMPOSE_STAGING_DIR)/libexec/container/plugins/compose/bin + @install $(BUILD_BIN_DIR)/container $(COMPOSE_STAGING_DIR)/libexec/container/plugins/compose/bin/compose + @install config/compose-config.json $(COMPOSE_STAGING_DIR)/libexec/container/plugins/compose/config.json + @install scripts/uninstall-compose.sh $(COMPOSE_STAGING_DIR)/libexec/container/plugins/compose/bin/uninstall-compose.sh + +################################################################################ + .PHONY: dsym dsym: @echo Copying debug symbols... @@ -130,7 +152,7 @@ test: .PHONY: install-kernel install-kernel: @bin/container system stop || true - @bin/container system start --enable-kernel-install + @bin/container system start --enable-kernel-install .PHONY: integration integration: init-block diff --git a/scripts/uninstall-compose.sh b/scripts/uninstall-compose.sh new file mode 100644 index 000000000..f03ce489d --- /dev/null +++ b/scripts/uninstall-compose.sh @@ -0,0 +1,42 @@ +@ -0,0 +1,41 @@ +#!/bin/bash +# 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. + +set -e + +echo "Uninstalling Compose plugin..." + +COMPOSE_PLUGIN_DIR="/usr/local/libexec/container/plugins/compose" +COMPOSE_BIN="$COMPOSE_PLUGIN_DIR/bin/compose" +COMPOSE_CONFIG="$COMPOSE_PLUGIN_DIR/config.json" + +if [ -f "$COMPOSE_BIN" ]; then + echo "Removing compose binary: $COMPOSE_BIN" + rm -f "$COMPOSE_BIN" +fi + +if [ -f "$COMPOSE_CONFIG" ]; then + echo "Removing compose config: $COMPOSE_CONFIG" + rm -f "$COMPOSE_CONFIG" +fi + +if [ -d "$COMPOSE_PLUGIN_DIR/bin" ]; then + rmdir "$COMPOSE_PLUGIN_DIR/bin" 2>/dev/null || true +fi +if [ -d "$COMPOSE_PLUGIN_DIR" ]; then + rmdir "$COMPOSE_PLUGIN_DIR" 2>/dev/null || true +fi + +echo "Compose plugin has been removed." From 7c256c66cc837a785c208ffe494a447debd7b34a Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Fri, 25 Jul 2025 09:53:57 -0700 Subject: [PATCH 68/80] Update ComposeUp.swift --- Plugins/compose/Commands/ComposeUp.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Plugins/compose/Commands/ComposeUp.swift b/Plugins/compose/Commands/ComposeUp.swift index 68849dc83..5be9a4c54 100644 --- a/Plugins/compose/Commands/ComposeUp.swift +++ b/Plugins/compose/Commands/ComposeUp.swift @@ -45,6 +45,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") var detatch: Bool = false + @Argument(help: "The path to your Docker Compose file") + var composeFile: String = "docker-compose.yml" + + @Argument(help: "The path to your environment file") + var envFile: String = ".env" + @Flag(name: [.customShort("b"), .customLong("build")]) var rebuild: Bool = false @@ -58,9 +64,9 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { var global: Flags.Global private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml - var envFilePath: String { "\(cwd)/.env" } // Path to optional .env file - // + var dockerComposePath: String { "\(cwd)/\(composeFile)" } // Path to docker-compose.yml + var envFilePath: String { "\(cwd)/\(envFile)" } // Path to optional .env file + private var fileManager: FileManager { FileManager.default } private var projectName: String? private var environmentVariables: [String: String] = [:] From 248b66703076e2e612c29d577f6bb5bdbbfb806c Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Fri, 25 Jul 2025 10:01:53 -0700 Subject: [PATCH 69/80] Argument parsing fixes --- Plugins/compose/Commands/ComposeUp.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/compose/Commands/ComposeUp.swift b/Plugins/compose/Commands/ComposeUp.swift index 5be9a4c54..98d8c75f7 100644 --- a/Plugins/compose/Commands/ComposeUp.swift +++ b/Plugins/compose/Commands/ComposeUp.swift @@ -45,10 +45,10 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") var detatch: Bool = false - @Argument(help: "The path to your Docker Compose file") + @Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file") var composeFile: String = "docker-compose.yml" - @Argument(help: "The path to your environment file") + @Option(name: [.customShort("e"), .long], help: "The path to your environment file") var envFile: String = ".env" @Flag(name: [.customShort("b"), .customLong("build")]) From 3c9afd9affc2b70490c74e1ec41ca947dc76e238 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Fri, 25 Jul 2025 10:04:30 -0700 Subject: [PATCH 70/80] Argument parsing fixes --- Plugins/compose/Commands/ComposeUp.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Plugins/compose/Commands/ComposeUp.swift b/Plugins/compose/Commands/ComposeUp.swift index 98d8c75f7..b0a2b0462 100644 --- a/Plugins/compose/Commands/ComposeUp.swift +++ b/Plugins/compose/Commands/ComposeUp.swift @@ -48,9 +48,6 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { @Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file") var composeFile: String = "docker-compose.yml" - @Option(name: [.customShort("e"), .long], help: "The path to your environment file") - var envFile: String = ".env" - @Flag(name: [.customShort("b"), .customLong("build")]) var rebuild: Bool = false @@ -65,7 +62,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } var dockerComposePath: String { "\(cwd)/\(composeFile)" } // Path to docker-compose.yml - var envFilePath: String { "\(cwd)/\(envFile)" } // Path to optional .env file + var envFilePath: String { "\(cwd)/\(process.envFile.first ?? ".env")" } // Path to optional .env file private var fileManager: FileManager { FileManager.default } private var projectName: String? From 958274c558a93dcaebb7afdfae23d2301eb1440d Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Fri, 25 Jul 2025 10:06:37 -0700 Subject: [PATCH 71/80] depends_on fixes --- Plugins/compose/Codable Structs/Service.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Plugins/compose/Codable Structs/Service.swift b/Plugins/compose/Codable Structs/Service.swift index 1c5aeb528..5292b81ff 100644 --- a/Plugins/compose/Codable Structs/Service.swift +++ b/Plugins/compose/Codable Structs/Service.swift @@ -135,7 +135,11 @@ struct Service: Codable, Hashable { command = nil } - depends_on = try container.decodeIfPresent([String].self, forKey: .depends_on) + if let dependsOnString = try? container.decodeIfPresent(String.self, forKey: .depends_on) { + depends_on = [dependsOnString] + } else { + depends_on = try container.decodeIfPresent([String].self, forKey: .depends_on) + } user = try container.decodeIfPresent(String.self, forKey: .user) container_name = try container.decodeIfPresent(String.self, forKey: .container_name) From 8b1e27f26bef64bb9ba7c1608b061f79c4488445 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Fri, 25 Jul 2025 10:17:59 -0700 Subject: [PATCH 72/80] added docker-compose file yaml vs yml automatic extension switching --- Plugins/compose/Commands/ComposeUp.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Plugins/compose/Commands/ComposeUp.swift b/Plugins/compose/Commands/ComposeUp.swift index b0a2b0462..45e125ec6 100644 --- a/Plugins/compose/Commands/ComposeUp.swift +++ b/Plugins/compose/Commands/ComposeUp.swift @@ -75,6 +75,15 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ] public mutating func run() async throws { + // Check for .yml vs .yaml file extension + if !fileManager.fileExists(atPath: dockerComposePath) { + let url = URL(filePath: dockerComposePath) + + let fileNameNoExtension = url.deletingPathExtension().lastPathComponent + let newExtension = url.pathExtension == "yaml" ? "yml" : "yaml" + composeFile = "\(fileNameNoExtension).\(newExtension)" + } + // Read docker-compose.yml content guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { throw YamlError.dockerfileNotFound(dockerComposePath) From d309b4827bfd1ac4d7a8b91c305f7eac2c6c16d2 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:33:46 -0700 Subject: [PATCH 73/80] formatting and header fixes --- Package.swift | 4 +- Plugins/compose/Commands/ComposeDown.swift | 38 ++-- Plugins/compose/Commands/ComposeUp.swift | 232 +++++++++++---------- Plugins/compose/ComposeCommand.swift | 2 +- Plugins/compose/Errors.swift | 70 +++---- Sources/CLI/Application.swift | 2 +- Sources/CLI/BuildCommand.swift | 6 +- Sources/CLI/Network/NetworkCreate.swift | 2 +- Sources/ExecutableCLI/Executable.swift | 4 +- 9 files changed, 182 insertions(+), 178 deletions(-) diff --git a/Package.swift b/Package.swift index ae3c73466..df8624098 100644 --- a/Package.swift +++ b/Package.swift @@ -335,7 +335,7 @@ let package = Package( .define("BUILDER_SHIM_VERSION", to: "\"\(builderShimVersion)\""), ] ), - + // MARK: Plugins .executableTarget( name: "compose", @@ -346,6 +346,6 @@ let package = Package( "Rainbow", ], path: "Plugins/compose" - ) + ), ] ) diff --git a/Plugins/compose/Commands/ComposeDown.swift b/Plugins/compose/Commands/ComposeDown.swift index f1c54de83..70cf13660 100644 --- a/Plugins/compose/Commands/ComposeDown.swift +++ b/Plugins/compose/Commands/ComposeDown.swift @@ -22,73 +22,75 @@ // import ArgumentParser +import ContainerCLI import ContainerClient import Foundation import Yams -import ContainerCLI struct ComposeDown: AsyncParsableCommand { public init() {} - + public static let configuration: CommandConfiguration = .init( commandName: "down", abstract: "Stop containers with compose" ) - + @Argument(help: "Specify the services to stop") var services: [String] = [] - + @OptionGroup var process: Flags.Process - + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - + var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml - + private var fileManager: FileManager { FileManager.default } private var projectName: String? - + public mutating func run() async throws { // Read docker-compose.yml content guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { throw YamlError.dockerfileNotFound(dockerComposePath) } - + // Decode the YAML file into the DockerCompose struct let dockerComposeString = String(data: yamlData, encoding: .utf8)! let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - + // Determine project name for container naming if let name = dockerCompose.name { projectName = name print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + print( + "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." + ) } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") } - + var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) services = try Service.topoSortConfiguredServices(services) - + // Filter for specified services if !self.services.isEmpty { services = services.filter({ serviceName, service in self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) }) } - + try await stopOldStuff(services.map({ $0.serviceName }), remove: false) } - + private func stopOldStuff(_ services: [String], remove: Bool) async throws { guard let projectName else { return } let containers = services.map { "\(projectName)-\($0)" } - + for container in containers { print("Stopping container: \(container)") guard let container = try? await ClientContainer.get(id: container) else { continue } - + do { try await container.stop() } catch { diff --git a/Plugins/compose/Commands/ComposeUp.swift b/Plugins/compose/Commands/ComposeUp.swift index 45e125ec6..d74220a47 100644 --- a/Plugins/compose/Commands/ComposeUp.swift +++ b/Plugins/compose/Commands/ComposeUp.swift @@ -22,110 +22,112 @@ // import ArgumentParser +import ContainerCLI import ContainerClient +import ContainerizationExtras import Foundation @preconcurrency import Rainbow import Yams -import ContainerizationExtras -import ContainerCLI public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { public init() {} - + public static let configuration: CommandConfiguration = .init( commandName: "up", abstract: "Start containers with compose" ) - + @Argument(help: "Specify the services to start") var services: [String] = [] - + @Flag( name: [.customShort("d"), .customLong("detach")], help: "Detatches from container logs. Note: If you do NOT detatch, killing this process will NOT kill the container. To kill the container, run container-compose down") var detatch: Bool = false - + @Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file") var composeFile: String = "docker-compose.yml" - + @Flag(name: [.customShort("b"), .customLong("build")]) var rebuild: Bool = false - + @Flag(name: .long, help: "Do not use cache") var noCache: Bool = false - + @OptionGroup var process: Flags.Process - + @OptionGroup var global: Flags.Global - + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } var dockerComposePath: String { "\(cwd)/\(composeFile)" } // Path to docker-compose.yml var envFilePath: String { "\(cwd)/\(process.envFile.first ?? ".env")" } // Path to optional .env file - + private var fileManager: FileManager { FileManager.default } private var projectName: String? private var environmentVariables: [String: String] = [:] private var containerIps: [String: String] = [:] private var containerConsoleColors: [String: NamedColor] = [:] - + private static let availableContainerConsoleColors: Set = [ .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, ] - + public mutating func run() async throws { // Check for .yml vs .yaml file extension if !fileManager.fileExists(atPath: dockerComposePath) { let url = URL(filePath: dockerComposePath) - + let fileNameNoExtension = url.deletingPathExtension().lastPathComponent let newExtension = url.pathExtension == "yaml" ? "yml" : "yaml" composeFile = "\(fileNameNoExtension).\(newExtension)" } - + // Read docker-compose.yml content guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { throw YamlError.dockerfileNotFound(dockerComposePath) } - + // Decode the YAML file into the DockerCompose struct let dockerComposeString = String(data: yamlData, encoding: .utf8)! let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - + // Load environment variables from .env file environmentVariables = loadEnvFile(path: envFilePath) - + // Handle 'version' field if let version = dockerCompose.version { print("Info: Docker Compose file version parsed as: \(version)") print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") } - + // Determine project name for container naming if let name = dockerCompose.name { projectName = name print("Info: Docker Compose project name parsed as: \(name)") - print("Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool.") + print( + "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." + ) } else { - projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name + projectName = URL(fileURLWithPath: cwd).lastPathComponent // Default to directory name print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") } - + // Get Services to use var services: [(serviceName: String, service: Service)] = dockerCompose.services.map({ ($0, $1) }) services = try Service.topoSortConfiguredServices(services) - + // Filter for specified services if !self.services.isEmpty { services = services.filter({ serviceName, service in self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) }) } - + // Stop Services try await stopOldStuff(services.map({ $0.serviceName }), remove: true) - + // Process top-level networks // This creates named networks defined in the docker-compose.yml if let networks = dockerCompose.networks { @@ -135,7 +137,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } print("--- Networks Processed ---\n") } - + // Process top-level volumes // This creates named volumes defined in the docker-compose.yml if let volumes = dockerCompose.volumes { @@ -145,38 +147,38 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } print("--- Volumes Processed ---\n") } - + // Process each service defined in the docker-compose.yml print("\n--- Processing Services ---") - + print(services.map(\.serviceName)) for (serviceName, service) in services { try await configService(service, serviceName: serviceName, from: dockerCompose) } - + if !detatch { await waitForever() } } - + func waitForever() async -> Never { for await _ in AsyncStream(unfolding: {}) { // This will never run } fatalError("unreachable") } - + private func getIPForRunningService(_ serviceName: String) async throws -> String? { guard let projectName else { return nil } - + let containerName = "\(projectName)-\(serviceName)" - + let container = try await ClientContainer.get(id: containerName) let ip = container.networks.compactMap { try? CIDRAddress($0.address).address.description }.first - + return ip } - + /// Repeatedly checks `container list -a` until the given container is listed as `running`. /// - Parameters: /// - containerName: The exact name of the container (e.g. "Assignment-Manager-API-db"). @@ -186,33 +188,33 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { guard let projectName else { return } let containerName = "\(projectName)-\(serviceName)" - + let deadline = Date().addingTimeInterval(timeout) - + while Date() < deadline { let container = try? await ClientContainer.get(id: containerName) if container?.status == .running { return } - + try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) } - + throw NSError( domain: "ContainerWait", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." ]) } - + private func stopOldStuff(_ services: [String], remove: Bool) async throws { guard let projectName else { return } let containers = services.map { "\(projectName)-\($0)" } - + for container in containers { print("Stopping container: \(container)") guard let container = try? await ClientContainer.get(id: container) else { continue } - + do { try await container.stop() } catch { @@ -225,9 +227,9 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } } - + // MARK: Compose Top Level Functions - + private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { let ip = try await getIPForRunningService(serviceName) self.containerIps[serviceName] = ip @@ -235,30 +237,30 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { self.environmentVariables[key] = ip ?? value } } - + private func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { guard let projectName else { return } let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name - + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") let volumePath = volumeUrl.path(percentEncoded: false) - + print( "Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." ) try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) } - + private func setupNetwork(name networkName: String, config networkConfig: Network) async throws { let actualNetworkName = networkConfig.name ?? networkName // Use explicit name or key as name - + if let externalNetwork = networkConfig.external, externalNetwork.isExternal { print("Info: Network '\(networkName)' is declared as external.") print("This tool assumes external network '\(externalNetwork.name ?? actualNetworkName)' already exists and will not attempt to create it.") } else { var networkCreateArgs: [String] = ["network", "create"] - -#warning("Docker Compose Network Options Not Supported") + + #warning("Docker Compose Network Options Not Supported") // Add driver and driver options if let driver = networkConfig.driver, !driver.isEmpty { // networkCreateArgs.append("--driver") @@ -285,7 +287,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // networkCreateArgs.append("--internal") print("Network Internal Flag Detected, But Not Supported") } // CORRECTED: Use isInternal - + // Add labels if let labels = networkConfig.labels, !labels.isEmpty { print("Network Labels Detected, But Not Supported") @@ -294,7 +296,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // networkCreateArgs.append("\(labelKey)=\(labelValue)") // } } - + print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else { @@ -304,18 +306,18 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { var networkCreate = Application.NetworkCreate() networkCreate.global = global networkCreate.name = actualNetworkName - + try await networkCreate.run() print("Network '\(networkName)' created") } } - + // MARK: Compose Service Level Functions private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { guard let projectName else { throw ComposeError.invalidProjectName } - + var imageToRun: String - + // Handle 'build' configuration if let buildConfig = service.build { imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) @@ -328,7 +330,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Should not happen due to Service init validation, but as a fallback throw ComposeError.imageNotFound(serviceName) } - + // Handle 'deploy' configuration (note that this tool doesn't fully support it) if service.deploy != nil { print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") @@ -337,14 +339,14 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ) print("The service will be run as a single container based on other configurations.") } - + var runCommandArgs: [String] = [] - + // Add detach flag if specified on the CLI if detatch { runCommandArgs.append("-d") } - + // Determine container name let containerName: String if let explicitContainerName = service.container_name { @@ -356,19 +358,19 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } runCommandArgs.append("--name") runCommandArgs.append(containerName) - + // REMOVED: Restart policy is not supported by `container run` // if let restart = service.restart { // runCommandArgs.append("--restart") // runCommandArgs.append(restart) // } - + // Add user if let user = service.user { runCommandArgs.append("--user") runCommandArgs.append(user) } - + // Add volume mounts if let volumes = service.volumes { for volume in volumes { @@ -376,17 +378,17 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append(contentsOf: args) } } - + // Combine environment variables from .env files and service environment var combinedEnv: [String: String] = environmentVariables - + if let envFiles = service.env_file { for envFile in envFiles { let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") combinedEnv.merge(additionalEnvVars) { (current, _) in current } } } - + if let serviceEnv = service.environment { combinedEnv.merge(serviceEnv) { (old, new) in guard !new.contains("${") else { @@ -395,27 +397,27 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return new } // Service env overrides .env files } - + // Fill in variables combinedEnv = combinedEnv.mapValues({ value in guard value.contains("${") else { return value } - + let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) return combinedEnv[variableName] ?? value }) - + // Fill in IPs combinedEnv = combinedEnv.mapValues({ value in containerIps[value] ?? value }) - + // MARK: Spinning Spot // Add environment variables to run command for (key, value) in combinedEnv { runCommandArgs.append("-e") runCommandArgs.append("\(key)=\(value)") } - + // REMOVED: Port mappings (-p) are not supported by `container run` // if let ports = service.ports { // for port in ports { @@ -424,7 +426,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // runCommandArgs.append(resolvedPort) // } // } - + // Connect to specified networks if let serviceNetworks = service.networks { for network in serviceNetworks { @@ -443,31 +445,31 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } else { print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.") } - + // Add hostname if let hostname = service.hostname { let resolvedHostname = resolveVariable(hostname, with: environmentVariables) runCommandArgs.append("--hostname") runCommandArgs.append(resolvedHostname) } - + // Add working directory if let workingDir = service.working_dir { let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) runCommandArgs.append("--workdir") runCommandArgs.append(resolvedWorkingDir) } - + // Add privileged flag if service.privileged == true { runCommandArgs.append("--privileged") } - + // Add read-only flag if service.read_only == true { runCommandArgs.append("--read-only") } - + // Handle service-level configs (note: still only parsing/logging, not attaching) if let serviceConfigs = service.configs { print( @@ -493,7 +495,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ) } } - + // Add interactive and TTY flags if service.stdin_open == true { runCommandArgs.append("-i") // --interactive @@ -501,9 +503,9 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { if service.tty == true { runCommandArgs.append("-t") // --tty } - + runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint - + // Add entrypoint or command if let entrypointParts = service.entrypoint { runCommandArgs.append("--entrypoint") @@ -511,29 +513,29 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } else if let commandParts = service.command { runCommandArgs.append(contentsOf: commandParts) } - + var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! - + if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { while containerConsoleColors.values.contains(serviceColor) { serviceColor = Self.availableContainerConsoleColors.randomElement()! } } - + self.containerConsoleColors[serviceName] = serviceColor - + Task { [self, serviceColor] in @Sendable func handleOutput(_ output: String) { print("\(serviceName): \(output)".applyingColor(serviceColor)) } - + print("\nStarting service: \(serviceName)") print("Starting \(serviceName)") print("----------------------------------------\n") let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) } - + do { try await waitUntilServiceIsRunning(serviceName) try await updateEnvironmentWithServiceIP(serviceName) @@ -541,20 +543,20 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print(error) } } - + private func pullImage(_ imageName: String, platform: String?) async throws { let imageList = try await ClientImage.list() guard !imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageName }) else { return } - + print("Pulling Image \(imageName)...") var registry = Flags.Registry() - registry.scheme = "auto" // Set or SwiftArgumentParser gets mad - + registry.scheme = "auto" // Set or SwiftArgumentParser gets mad + var progress = Flags.Progress() progress.disableProgressUpdates = false - + var imagePull = Application.ImagePull() imagePull.progressFlags = progress imagePull.registry = registry @@ -563,7 +565,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { imagePull.platform = platform try await imagePull.run() } - + /// Builds Docker Service /// /// - Parameters: @@ -579,35 +581,35 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { if !rebuild, imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageToRun }) { return imageToRun } - + var buildCommand = Application.BuildCommand() - + // Set Build Commands buildCommand.buildArg = (buildConfig.args ?? [:]).map({ "\($0.key)=\(resolveVariable($0.value, with: environmentVariables))" }) - + // Locate Dockerfile and context buildCommand.contextDir = "\(self.cwd)/\(buildConfig.context)" buildCommand.file = "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")" - + // Handle Caching buildCommand.noCache = noCache buildCommand.cacheIn = [] buildCommand.cacheOut = [] - + // Handle OS/Arch let split = service.platform?.split(separator: "/") buildCommand.os = [String(split?.first ?? "linux")] buildCommand.arch = [String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64")] - + // Set Image Name buildCommand.targetImageName = imageToRun - + // Set CPU & Memory buildCommand.cpus = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 buildCommand.memory = service.deploy?.resources?.limits?.memory ?? "2048MB" - + // Set Miscelaneous - buildCommand.label = [] // No Label Equivalent? + buildCommand.label = [] // No Label Equivalent? buildCommand.progress = "auto" buildCommand.vsockPort = 8088 buildCommand.quiet = false @@ -619,26 +621,26 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { try await buildCommand.run() print("Image build for \(serviceName) completed.") print("----------------------------------------") - + return imageToRun } - + private func configVolume(_ volume: String) async throws -> [String] { let resolvedVolume = resolveVariable(volume, with: environmentVariables) - + var runCommandArgs: [String] = [] - + // Parse the volume string: destination[:mode] let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) - + guard components.count >= 2 else { print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") return [] } - + let source = components[0] let destination = components[1] - + // Check if the source looks like a host path (contains '/' or starts with '.') // This heuristic helps distinguish bind mounts from named volume references. if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { @@ -646,7 +648,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { var isDirectory: ObjCBool = false // Ensure the path is absolute or relative to the current directory for FileManager let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) - + if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { if isDirectory.boolValue { // Host path exists and is a directory, add the volume @@ -672,21 +674,21 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { guard let projectName else { return [] } let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") let volumePath = volumeUrl.path(percentEncoded: false) - + let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() let destinationPath = destinationUrl.path(percentEncoded: false) - + print( "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." ) try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) - + // Host path exists and is a directory, add the volume runCommandArgs.append("-v") // Reconstruct the volume string without mode, ensuring it's source:destination runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument } - + return runCommandArgs } } diff --git a/Plugins/compose/ComposeCommand.swift b/Plugins/compose/ComposeCommand.swift index 82a873bb8..aa678d8f0 100644 --- a/Plugins/compose/ComposeCommand.swift +++ b/Plugins/compose/ComposeCommand.swift @@ -22,10 +22,10 @@ // import ArgumentParser +import ContainerCLI import Foundation import Rainbow import Yams -import ContainerCLI @main struct ComposeCommand: AsyncParsableCommand { diff --git a/Plugins/compose/Errors.swift b/Plugins/compose/Errors.swift index df15740ef..15a4e4810 100644 --- a/Plugins/compose/Errors.swift +++ b/Plugins/compose/Errors.swift @@ -21,47 +21,47 @@ // Created by Morris Richman on 6/18/25. // -import Foundation import ContainerCLI +import Foundation //extension Application { - enum YamlError: Error, LocalizedError { - case dockerfileNotFound(String) - - var errorDescription: String? { - switch self { - case .dockerfileNotFound(let path): - return "docker-compose.yml not found at \(path)" - } - } - } - - enum ComposeError: Error, LocalizedError { - case imageNotFound(String) - case invalidProjectName - - var errorDescription: String? { - switch self { - case .imageNotFound(let name): - return "Service \(name) must define either 'image' or 'build'." - case .invalidProjectName: - return "Could not find project name." - } +enum YamlError: Error, LocalizedError { + case dockerfileNotFound(String) + + var errorDescription: String? { + switch self { + case .dockerfileNotFound(let path): + return "docker-compose.yml not found at \(path)" } } - - enum TerminalError: Error, LocalizedError { - case commandFailed(String) - - var errorDescription: String? { - "Command failed: \(self)" +} + +enum ComposeError: Error, LocalizedError { + case imageNotFound(String) + case invalidProjectName + + var errorDescription: String? { + switch self { + case .imageNotFound(let name): + return "Service \(name) must define either 'image' or 'build'." + case .invalidProjectName: + return "Could not find project name." } } - - /// An enum representing streaming output from either `stdout` or `stderr`. - enum CommandOutput { - case stdout(String) - case stderr(String) - case exitCode(Int32) +} + +enum TerminalError: Error, LocalizedError { + case commandFailed(String) + + var errorDescription: String? { + "Command failed: \(self)" } +} + +/// An enum representing streaming output from either `stdout` or `stderr`. +enum CommandOutput { + case stdout(String) + case stderr(String) + case exitCode(Int32) +} //} diff --git a/Sources/CLI/Application.swift b/Sources/CLI/Application.swift index c1354ea2e..d0124d616 100644 --- a/Sources/CLI/Application.swift +++ b/Sources/CLI/Application.swift @@ -42,7 +42,7 @@ nonisolated(unsafe) var log = { public struct Application: AsyncParsableCommand { public init() {} - + @OptionGroup public var global: Flags.Global diff --git a/Sources/CLI/BuildCommand.swift b/Sources/CLI/BuildCommand.swift index d6ce3291e..1f8eff217 100644 --- a/Sources/CLI/BuildCommand.swift +++ b/Sources/CLI/BuildCommand.swift @@ -36,7 +36,7 @@ extension Application { config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) return config } - + public init() {} @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") @@ -129,13 +129,13 @@ extension Application { do { let container = try await ClientContainer.get(id: "buildkit") let fh = try await container.dial(vsockPort) - + let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) let b = try Builder(socket: fh, group: threadGroup) // If this call succeeds, then BuildKit is running. let _ = try await b.info() - + return b } catch { // If we get here, "Dialing builder" is shown for such a short period diff --git a/Sources/CLI/Network/NetworkCreate.swift b/Sources/CLI/Network/NetworkCreate.swift index 40f86f7d1..fadd2c0ea 100644 --- a/Sources/CLI/Network/NetworkCreate.swift +++ b/Sources/CLI/Network/NetworkCreate.swift @@ -26,7 +26,7 @@ extension Application { public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a new network") - + public init() {} @Argument(help: "Network name") diff --git a/Sources/ExecutableCLI/Executable.swift b/Sources/ExecutableCLI/Executable.swift index 56bcf0da5..9b7e5dd64 100644 --- a/Sources/ExecutableCLI/Executable.swift +++ b/Sources/ExecutableCLI/Executable.swift @@ -16,14 +16,14 @@ // +import ArgumentParser import ContainerCLI import ContainerClient -import ArgumentParser @main public struct Executable: AsyncParsableCommand { public init() {} - + @OptionGroup var global: Flags.Global From 1562dbf0a772bd73a4314de9c080b94144b49630 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:15:38 -0700 Subject: [PATCH 74/80] expose ComposeCLI functions separately from executable --- Package.swift | 15 +++++++++++--- .../ComposeCLI}/Codable Structs/Build.swift | 0 .../ComposeCLI}/Codable Structs/Config.swift | 0 .../ComposeCLI}/Codable Structs/Deploy.swift | 0 .../Codable Structs/DeployResources.swift | 0 .../Codable Structs/DeployRestartPolicy.swift | 0 .../Codable Structs/DeviceReservation.swift | 0 .../Codable Structs/DockerCompose.swift | 0 .../Codable Structs/ExternalConfig.swift | 0 .../Codable Structs/ExternalNetwork.swift | 0 .../Codable Structs/ExternalSecret.swift | 0 .../Codable Structs/ExternalVolume.swift | 0 .../Codable Structs/Healthcheck.swift | 0 .../ComposeCLI}/Codable Structs/Network.swift | 0 .../Codable Structs/ResourceLimits.swift | 0 .../ResourceReservations.swift | 0 .../ComposeCLI}/Codable Structs/Secret.swift | 0 .../ComposeCLI}/Codable Structs/Service.swift | 0 .../Codable Structs/ServiceConfig.swift | 0 .../Codable Structs/ServiceSecret.swift | 0 .../ComposeCLI}/Codable Structs/Volume.swift | 0 .../ComposeCLI}/Commands/ComposeDown.swift | 2 +- .../ComposeCLI}/Commands/ComposeUp.swift | 0 .../ComposeCLI}/Errors.swift | 0 .../ComposeCLI}/Helper Functions.swift | 17 ++++++++++++++++ .../compose/ComposeCommand.swift | 20 +------------------ 26 files changed, 31 insertions(+), 23 deletions(-) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/Build.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/Config.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/Deploy.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/DeployResources.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/DeployRestartPolicy.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/DeviceReservation.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/DockerCompose.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/ExternalConfig.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/ExternalNetwork.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/ExternalSecret.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/ExternalVolume.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/Healthcheck.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/Network.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/ResourceLimits.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/ResourceReservations.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/Secret.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/Service.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/ServiceConfig.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/ServiceSecret.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Codable Structs/Volume.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Commands/ComposeDown.swift (98%) rename Plugins/{compose => Compose/ComposeCLI}/Commands/ComposeUp.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Errors.swift (100%) rename Plugins/{compose => Compose/ComposeCLI}/Helper Functions.swift (92%) rename Plugins/{ => Compose}/compose/ComposeCommand.swift (73%) diff --git a/Package.swift b/Package.swift index 896afdf1a..5fc2b4df8 100644 --- a/Package.swift +++ b/Package.swift @@ -47,6 +47,7 @@ let package = Package( .library(name: "ContainerBuildCache", targets: ["ContainerBuildCache"]), .library(name: "ContainerBuildSnapshotter", targets: ["ContainerBuildSnapshotter"]), .library(name: "ContainerBuildParser", targets: ["ContainerBuildParser"]), + .library(name: "ComposeCLI", targets: ["ComposeCLI"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), @@ -446,15 +447,23 @@ let package = Package( ), // MARK: Plugins - .executableTarget( - name: "compose", + .target( + name: "ComposeCLI", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), "container", "Yams", "Rainbow", ], - path: "Plugins/compose" + path: "Plugins/Compose/ComposeCLI" + ), + + .executableTarget( + name: "compose", + dependencies: [ + "ComposeCLI" + ], + path: "Plugins/Compose/compose" ), ] ) diff --git a/Plugins/compose/Codable Structs/Build.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Build.swift similarity index 100% rename from Plugins/compose/Codable Structs/Build.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/Build.swift diff --git a/Plugins/compose/Codable Structs/Config.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Config.swift similarity index 100% rename from Plugins/compose/Codable Structs/Config.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/Config.swift diff --git a/Plugins/compose/Codable Structs/Deploy.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Deploy.swift similarity index 100% rename from Plugins/compose/Codable Structs/Deploy.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/Deploy.swift diff --git a/Plugins/compose/Codable Structs/DeployResources.swift b/Plugins/Compose/ComposeCLI/Codable Structs/DeployResources.swift similarity index 100% rename from Plugins/compose/Codable Structs/DeployResources.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/DeployResources.swift diff --git a/Plugins/compose/Codable Structs/DeployRestartPolicy.swift b/Plugins/Compose/ComposeCLI/Codable Structs/DeployRestartPolicy.swift similarity index 100% rename from Plugins/compose/Codable Structs/DeployRestartPolicy.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/DeployRestartPolicy.swift diff --git a/Plugins/compose/Codable Structs/DeviceReservation.swift b/Plugins/Compose/ComposeCLI/Codable Structs/DeviceReservation.swift similarity index 100% rename from Plugins/compose/Codable Structs/DeviceReservation.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/DeviceReservation.swift diff --git a/Plugins/compose/Codable Structs/DockerCompose.swift b/Plugins/Compose/ComposeCLI/Codable Structs/DockerCompose.swift similarity index 100% rename from Plugins/compose/Codable Structs/DockerCompose.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/DockerCompose.swift diff --git a/Plugins/compose/Codable Structs/ExternalConfig.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ExternalConfig.swift similarity index 100% rename from Plugins/compose/Codable Structs/ExternalConfig.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/ExternalConfig.swift diff --git a/Plugins/compose/Codable Structs/ExternalNetwork.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ExternalNetwork.swift similarity index 100% rename from Plugins/compose/Codable Structs/ExternalNetwork.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/ExternalNetwork.swift diff --git a/Plugins/compose/Codable Structs/ExternalSecret.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ExternalSecret.swift similarity index 100% rename from Plugins/compose/Codable Structs/ExternalSecret.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/ExternalSecret.swift diff --git a/Plugins/compose/Codable Structs/ExternalVolume.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ExternalVolume.swift similarity index 100% rename from Plugins/compose/Codable Structs/ExternalVolume.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/ExternalVolume.swift diff --git a/Plugins/compose/Codable Structs/Healthcheck.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Healthcheck.swift similarity index 100% rename from Plugins/compose/Codable Structs/Healthcheck.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/Healthcheck.swift diff --git a/Plugins/compose/Codable Structs/Network.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Network.swift similarity index 100% rename from Plugins/compose/Codable Structs/Network.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/Network.swift diff --git a/Plugins/compose/Codable Structs/ResourceLimits.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ResourceLimits.swift similarity index 100% rename from Plugins/compose/Codable Structs/ResourceLimits.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/ResourceLimits.swift diff --git a/Plugins/compose/Codable Structs/ResourceReservations.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ResourceReservations.swift similarity index 100% rename from Plugins/compose/Codable Structs/ResourceReservations.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/ResourceReservations.swift diff --git a/Plugins/compose/Codable Structs/Secret.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Secret.swift similarity index 100% rename from Plugins/compose/Codable Structs/Secret.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/Secret.swift diff --git a/Plugins/compose/Codable Structs/Service.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Service.swift similarity index 100% rename from Plugins/compose/Codable Structs/Service.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/Service.swift diff --git a/Plugins/compose/Codable Structs/ServiceConfig.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ServiceConfig.swift similarity index 100% rename from Plugins/compose/Codable Structs/ServiceConfig.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/ServiceConfig.swift diff --git a/Plugins/compose/Codable Structs/ServiceSecret.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ServiceSecret.swift similarity index 100% rename from Plugins/compose/Codable Structs/ServiceSecret.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/ServiceSecret.swift diff --git a/Plugins/compose/Codable Structs/Volume.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Volume.swift similarity index 100% rename from Plugins/compose/Codable Structs/Volume.swift rename to Plugins/Compose/ComposeCLI/Codable Structs/Volume.swift diff --git a/Plugins/compose/Commands/ComposeDown.swift b/Plugins/Compose/ComposeCLI/Commands/ComposeDown.swift similarity index 98% rename from Plugins/compose/Commands/ComposeDown.swift rename to Plugins/Compose/ComposeCLI/Commands/ComposeDown.swift index 70cf13660..87168885a 100644 --- a/Plugins/compose/Commands/ComposeDown.swift +++ b/Plugins/Compose/ComposeCLI/Commands/ComposeDown.swift @@ -27,7 +27,7 @@ import ContainerClient import Foundation import Yams -struct ComposeDown: AsyncParsableCommand { +public struct ComposeDown: AsyncParsableCommand { public init() {} public static let configuration: CommandConfiguration = .init( diff --git a/Plugins/compose/Commands/ComposeUp.swift b/Plugins/Compose/ComposeCLI/Commands/ComposeUp.swift similarity index 100% rename from Plugins/compose/Commands/ComposeUp.swift rename to Plugins/Compose/ComposeCLI/Commands/ComposeUp.swift diff --git a/Plugins/compose/Errors.swift b/Plugins/Compose/ComposeCLI/Errors.swift similarity index 100% rename from Plugins/compose/Errors.swift rename to Plugins/Compose/ComposeCLI/Errors.swift diff --git a/Plugins/compose/Helper Functions.swift b/Plugins/Compose/ComposeCLI/Helper Functions.swift similarity index 92% rename from Plugins/compose/Helper Functions.swift rename to Plugins/Compose/ComposeCLI/Helper Functions.swift index 524e8a8cd..c48bfead3 100644 --- a/Plugins/compose/Helper Functions.swift +++ b/Plugins/Compose/ComposeCLI/Helper Functions.swift @@ -23,6 +23,7 @@ import Foundation import Yams +import Rainbow import ContainerCLI //extension Application { @@ -95,3 +96,19 @@ import ContainerCLI //} extension String: @retroactive Error {} + +/// A structure representing the result of a command-line process execution. +struct CommandResult { + /// The standard output captured from the process. + let stdout: String + + /// The standard error output captured from the process. + let stderr: String + + /// The exit code returned by the process upon termination. + let exitCode: Int32 +} + +extension NamedColor: Codable { + +} diff --git a/Plugins/compose/ComposeCommand.swift b/Plugins/Compose/compose/ComposeCommand.swift similarity index 73% rename from Plugins/compose/ComposeCommand.swift rename to Plugins/Compose/compose/ComposeCommand.swift index aa678d8f0..3b4e6cd94 100644 --- a/Plugins/compose/ComposeCommand.swift +++ b/Plugins/Compose/compose/ComposeCommand.swift @@ -22,10 +22,8 @@ // import ArgumentParser -import ContainerCLI +import ComposeCLI import Foundation -import Rainbow -import Yams @main struct ComposeCommand: AsyncParsableCommand { @@ -37,19 +35,3 @@ struct ComposeCommand: AsyncParsableCommand { ComposeDown.self, ]) } - -/// A structure representing the result of a command-line process execution. -struct CommandResult { - /// The standard output captured from the process. - let stdout: String - - /// The standard error output captured from the process. - let stderr: String - - /// The exit code returned by the process upon termination. - let exitCode: Int32 -} - -extension NamedColor: Codable { - -} From 1e74e16fa9e8997b931baa7861e621130cab9eee Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:22:36 -0700 Subject: [PATCH 75/80] Update Package.swift --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index 5fc2b4df8..ad6821438 100644 --- a/Package.swift +++ b/Package.swift @@ -461,6 +461,7 @@ let package = Package( .executableTarget( name: "compose", dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), "ComposeCLI" ], path: "Plugins/Compose/compose" From f489307d6e1ec5b95017771f35042e4ebb8d982b Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:42:54 -0700 Subject: [PATCH 76/80] Update Package.swift --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index ad6821438..1a85035e8 100644 --- a/Package.swift +++ b/Package.swift @@ -452,6 +452,7 @@ let package = Package( dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), "container", + "ContainerCLI", "Yams", "Rainbow", ], From 5fcf36823262286ad5886d8a73a2f23e4f01a4e1 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Sun, 14 Sep 2025 12:14:17 -0700 Subject: [PATCH 77/80] merge expose-command-structs-for-plugins to test new plugin system --- .../Containers/ContainersHarness.swift | 3 +- .../Containers/ContainersService.swift | 57 +++++++++--- Sources/APIServer/Kernel/KernelHarness.swift | 10 ++- Sources/APIServer/Kernel/KernelService.swift | 44 +++++++--- Sources/CLI/Application.swift | 21 ++--- Sources/CLI/BuildCommand.swift | 88 ++++++++++++------- Sources/CLI/Builder/Builder.swift | 6 +- Sources/CLI/Builder/BuilderDelete.swift | 6 +- Sources/CLI/Builder/BuilderStart.swift | 10 ++- Sources/CLI/Builder/BuilderStatus.swift | 6 +- Sources/CLI/Builder/BuilderStop.swift | 6 +- Sources/CLI/Container/ContainerCreate.swift | 8 +- Sources/CLI/Container/ContainerDelete.swift | 41 ++++----- Sources/CLI/Container/ContainerExec.swift | 8 +- Sources/CLI/Container/ContainerInspect.swift | 8 +- Sources/CLI/Container/ContainerKill.swift | 10 ++- Sources/CLI/Container/ContainerList.swift | 8 +- Sources/CLI/Container/ContainerLogs.swift | 8 +- Sources/CLI/Container/ContainerStart.swift | 8 +- Sources/CLI/Container/ContainerStop.swift | 10 ++- Sources/CLI/Container/ContainersCommand.swift | 6 +- Sources/CLI/DefaultCommand.swift | 58 +++++++++++- ...ImagesCommand.swift => ImageCommand.swift} | 10 ++- Sources/CLI/Image/ImageInspect.swift | 7 +- Sources/CLI/Image/ImageList.swift | 21 +++-- Sources/CLI/Image/ImageLoad.swift | 7 +- Sources/CLI/Image/ImagePrune.swift | 7 +- Sources/CLI/Image/ImagePull.swift | 28 ++++-- Sources/CLI/Image/ImagePush.swift | 7 +- Sources/CLI/Image/ImageRemove.swift | 16 ++-- Sources/CLI/Image/ImageSave.swift | 52 +++++++++-- Sources/CLI/Image/ImageTag.swift | 7 +- Sources/CLI/Network/NetworkCommand.swift | 5 +- Sources/CLI/Network/NetworkCreate.swift | 7 +- Sources/CLI/Network/NetworkDelete.swift | 9 +- Sources/CLI/Network/NetworkInspect.swift | 7 +- Sources/CLI/Network/NetworkList.swift | 15 ++-- Sources/CLI/Registry/Login.swift | 7 +- Sources/CLI/Registry/Logout.swift | 7 +- Sources/CLI/Registry/RegistryCommand.swift | 5 +- Sources/CLI/Registry/RegistryDefault.swift | 26 +++--- Sources/CLI/RunCommand.swift | 21 ++--- Sources/CLI/System/DNS/DNSCreate.swift | 7 +- Sources/CLI/System/DNS/DNSDefault.swift | 26 +++--- Sources/CLI/System/DNS/DNSDelete.swift | 7 +- Sources/CLI/System/DNS/DNSList.swift | 7 +- Sources/CLI/System/Kernel/KernelSet.swift | 26 +++--- Sources/CLI/System/SystemCommand.swift | 5 +- Sources/CLI/System/SystemDNS.swift | 5 +- Sources/CLI/System/SystemKernel.swift | 5 +- Sources/CLI/System/SystemLogs.swift | 10 ++- Sources/CLI/System/SystemStart.swift | 14 +-- Sources/CLI/System/SystemStatus.swift | 7 +- Sources/CLI/System/SystemStop.swift | 8 +- Sources/CLI/Volume/VolumeCommand.swift | 5 +- Sources/CLI/Volume/VolumeCreate.swift | 7 +- Sources/CLI/Volume/VolumeDelete.swift | 7 +- Sources/CLI/Volume/VolumeInspect.swift | 7 +- Sources/CLI/Volume/VolumeList.swift | 11 +-- Sources/ContainerClient/ContainerEvents.swift | 2 - .../Core/ClientContainer.swift | 3 +- .../ContainerClient/Core/ClientImage.swift | 25 ++++-- .../ContainerClient/Core/ClientKernel.swift | 8 +- .../FileDownloader.swift | 2 +- Sources/ContainerClient/Flags.swift | 5 +- Sources/ContainerClient/Parser.swift | 51 ++++++----- Sources/ContainerClient/Utility.swift | 6 +- Sources/ContainerClient/XPC+.swift | 3 + .../Server/ImageService.swift | 6 +- .../Server/ImagesServiceHarness.swift | 7 +- 70 files changed, 636 insertions(+), 347 deletions(-) rename Sources/CLI/Image/{ImagesCommand.swift => ImageCommand.swift} (85%) rename Sources/{APIServer/Kernel => ContainerClient}/FileDownloader.swift (98%) diff --git a/Sources/APIServer/Containers/ContainersHarness.swift b/Sources/APIServer/Containers/ContainersHarness.swift index 1f17ad657..bcac1fcc9 100644 --- a/Sources/APIServer/Containers/ContainersHarness.swift +++ b/Sources/APIServer/Containers/ContainersHarness.swift @@ -69,7 +69,8 @@ struct ContainersHarness { guard let id else { throw ContainerizationError(.invalidArgument, message: "id cannot be empty") } - try await service.delete(id: id) + let forceDelete = message.bool(key: .forceDelete) + try await service.delete(id: id, force: forceDelete) return message.reply() } diff --git a/Sources/APIServer/Containers/ContainersService.swift b/Sources/APIServer/Containers/ContainersService.swift index d46d76377..7abd16d41 100644 --- a/Sources/APIServer/Containers/ContainersService.swift +++ b/Sources/APIServer/Containers/ContainersService.swift @@ -84,9 +84,7 @@ actor ContainersService { /// List all containers registered with the service. public func list() async throws -> [ContainerSnapshot] { self.log.debug("\(#function)") - return await lock.withLock { context in - Array(await self.containers.values) - } + return Array(self.containers.values) } /// Execute an operation with the current container list while maintaining atomicity @@ -207,14 +205,35 @@ actor ContainersService { } /// Delete a container and its resources. - public func delete(id: String) async throws { + public func delete(id: String, force: Bool) async throws { self.log.debug("\(#function)") let item = try self._get(id: id) switch item.status { - case .running, .stopping: + case .running: + if !force { + throw ContainerizationError( + .invalidState, + message: "container \(id) is \(item.status) and can not be deleted" + ) + } + let autoRemove = try getContainerCreationOptions(id: id).autoRemove + let opts = ContainerStopOptions( + timeoutInSeconds: 5, + signal: SIGKILL + ) + try await self._stop( + id: id, + runtimeHandler: item.configuration.runtimeHandler, + options: opts + ) + if autoRemove { + return + } + try self._cleanup(id: id, item: item) + case .stopping: throw ContainerizationError( .invalidState, - message: "container \(id) is not yet stopped and can not be deleted" + message: "container \(id) is \(item.status) and can not be deleted" ) default: try self._cleanup(id: id, item: item) @@ -251,6 +270,13 @@ actor ContainersService { try self._cleanup(id: id, item: item) } + private func getContainerCreationOptions(id: String) throws -> ContainerCreateOptions { + let path = self.containerRoot.appendingPathComponent(id) + let bundle = ContainerClient.Bundle(path: path) + let options: ContainerCreateOptions = try bundle.load(filename: "options.json") + return options + } + private func containerProcessExitHandler(_ id: String, _ exitCode: Int32, context: AsyncLock.Context) async { self.log.info("Handling container \(id) exit. Code \(exitCode)") do { @@ -258,9 +284,7 @@ actor ContainersService { let snapshot = ContainerSnapshot(configuration: item.configuration, status: .stopped, networks: []) await self.setContainer(id, snapshot, context: context) - let path = self.containerRoot.appendingPathComponent(id) - let bundle = ContainerClient.Bundle(path: path) - let options: ContainerCreateOptions = try bundle.load(filename: "options.json") + let options = try getContainerCreationOptions(id: id) if options.autoRemove { try self.cleanup(id: id, item: item, context: context) } @@ -315,14 +339,25 @@ extension ContainersService { let item = try await self.get(id: id, context: context) switch item.status { case .running: - let client = SandboxClient(id: item.configuration.id, runtime: item.configuration.runtimeHandler) - try await client.stop(options: options) + try await self._stop( + id: id, + runtimeHandler: item.configuration.runtimeHandler, + options: options + ) default: return } } } + private func _stop(id: String, runtimeHandler: String, options: ContainerStopOptions) async throws { + let client = SandboxClient( + id: id, + runtime: runtimeHandler + ) + try await client.stop(options: options) + } + public func logs(id: String) async throws -> [FileHandle] { self.log.debug("\(#function)") // Logs doesn't care if the container is running or not, just that diff --git a/Sources/APIServer/Kernel/KernelHarness.swift b/Sources/APIServer/Kernel/KernelHarness.swift index 5de5460a2..2da0e4243 100644 --- a/Sources/APIServer/Kernel/KernelHarness.swift +++ b/Sources/APIServer/Kernel/KernelHarness.swift @@ -33,18 +33,20 @@ struct KernelHarness { public func install(_ message: XPCMessage) async throws -> XPCMessage { let kernelFilePath = try message.kernelFilePath() let platform = try message.platform() + let force = try message.kernelForce() guard let kernelTarUrl = try message.kernelTarURL() else { // We have been given a path to a kernel binary on disk guard let kernelFile = URL(string: kernelFilePath) else { throw ContainerizationError(.invalidArgument, message: "Invalid kernel file path: \(kernelFilePath)") } - try await self.service.installKernel(kernelFile: kernelFile, platform: platform) + try await self.service.installKernel(kernelFile: kernelFile, platform: platform, force: force) return message.reply() } let progressUpdateService = ProgressUpdateService(message: message) - try await self.service.installKernelFrom(tar: kernelTarUrl, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progressUpdateService?.handler) + try await self.service.installKernelFrom( + tar: kernelTarUrl, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progressUpdateService?.handler, force: force) return message.reply() } @@ -86,4 +88,8 @@ extension XPCMessage { } return k } + + fileprivate func kernelForce() throws -> Bool { + self.bool(key: .kernelForce) + } } diff --git a/Sources/APIServer/Kernel/KernelService.swift b/Sources/APIServer/Kernel/KernelService.swift index 6d56a8d84..fecc0892a 100644 --- a/Sources/APIServer/Kernel/KernelService.swift +++ b/Sources/APIServer/Kernel/KernelService.swift @@ -37,10 +37,19 @@ actor KernelService { /// Copies a kernel binary from a local path on disk into the managed kernels directory /// as the default kernel for the provided platform. - public func installKernel(kernelFile url: URL, platform: SystemPlatform = .linuxArm) throws { + public func installKernel(kernelFile url: URL, platform: SystemPlatform = .linuxArm, force: Bool) throws { self.log.info("KernelService: \(#function) - kernelFile: \(url), platform: \(String(describing: platform))") let kFile = url.resolvingSymlinksInPath() let destPath = self.kernelDirectory.appendingPathComponent(kFile.lastPathComponent) + if force { + do { + try FileManager.default.removeItem(at: destPath) + } catch let error as NSError { + guard error.code == NSFileNoSuchFileError else { + throw error + } + } + } try FileManager.default.copyItem(at: kFile, to: destPath) try Task.checkCancellation() do { @@ -54,7 +63,7 @@ actor KernelService { /// Copies a kernel binary from inside of tar file into the managed kernels directory /// as the default kernel for the provided platform. /// The parameter `tar` maybe a location to a local file on disk, or a remote URL. - public func installKernelFrom(tar: URL, kernelFilePath: String, platform: SystemPlatform, progressUpdate: ProgressUpdateHandler?) async throws { + public func installKernelFrom(tar: URL, kernelFilePath: String, platform: SystemPlatform, progressUpdate: ProgressUpdateHandler?, force: Bool) async throws { self.log.info("KernelService: \(#function) - tar: \(tar), kernelFilePath: \(kernelFilePath), platform: \(String(describing: platform))") let tempDir = FileManager.default.uniqueTemporaryDirectory() @@ -75,16 +84,15 @@ actor KernelService { if let progressUpdate { downloadProgressUpdate = ProgressTaskCoordinator.handler(for: downloadTask, from: progressUpdate) } - try await FileDownloader.downloadFile(url: tar, to: tarFile, progressUpdate: downloadProgressUpdate) + try await ContainerClient.FileDownloader.downloadFile(url: tar, to: tarFile, progressUpdate: downloadProgressUpdate) } await taskManager.finish() await progressUpdate?([ .setDescription("Unpacking kernel") ]) - let archiveReader = try ArchiveReader(file: tarFile) - let kernelFile = try archiveReader.extractFile(from: kernelFilePath, to: tempDir) - try self.installKernel(kernelFile: kernelFile, platform: platform) + let kernelFile = try self.extractFile(tarFile: tarFile, at: kernelFilePath, to: tempDir) + try self.installKernel(kernelFile: kernelFile, platform: platform, force: force) if !FileManager.default.fileExists(atPath: tar.absoluteString) { try FileManager.default.removeItem(at: tarFile) @@ -112,13 +120,27 @@ actor KernelService { } return Kernel(path: defaultKernelPath, platform: platform) } -} -extension ArchiveReader { - fileprivate func extractFile(from: String, to directory: URL) throws -> URL { - let (_, data) = try self.extractFile(path: from) + private func extractFile(tarFile: URL, at: String, to directory: URL) throws -> URL { + var target = at + var archiveReader = try ArchiveReader(file: tarFile) + var (entry, data) = try archiveReader.extractFile(path: target) + + // if the target file is a symlink, get the data for the actual file + if entry.fileType == .symbolicLink, let symlinkRelative = entry.symlinkTarget { + // the previous extractFile changes the underlying file pointer, so we need to reopen the file + // to ensure we traverse all the files in the archive + archiveReader = try ArchiveReader(file: tarFile) + let symlinkTarget = URL(filePath: target).deletingLastPathComponent().appending(path: symlinkRelative) + + // standardize so that we remove any and all ../ and ./ in the path since symlink targets + // are relative paths to the target file from the symlink's parent dir itself + target = symlinkTarget.standardized.relativePath + let (_, targetData) = try archiveReader.extractFile(path: target) + data = targetData + } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) - let fileName = URL(filePath: from).lastPathComponent + let fileName = URL(filePath: target).lastPathComponent let fileURL = directory.appendingPathComponent(fileName) try data.write(to: fileURL, options: .atomic) return fileURL diff --git a/Sources/CLI/Application.swift b/Sources/CLI/Application.swift index 75387b530..b4f5053fb 100644 --- a/Sources/CLI/Application.swift +++ b/Sources/CLI/Application.swift @@ -29,22 +29,17 @@ import TerminalProgress // `log` is updated only once in the `validate()` method. nonisolated(unsafe) var log = { - LoggingSystem.bootstrap { label in - OSLogHandler( - label: label, - category: "CLI" - ) - } + LoggingSystem.bootstrap(StreamLogHandler.standardError) var log = Logger(label: "com.apple.container") - log.logLevel = .debug + log.logLevel = .info return log }() public struct Application: AsyncParsableCommand { - public init() {} - @OptionGroup public var global: Flags.Global + + public init() {} public static let configuration = CommandConfiguration( commandName: "container", @@ -73,7 +68,7 @@ public struct Application: AsyncParsableCommand { name: "Image", subcommands: [ BuildCommand.self, - ImagesCommand.self, + ImageCommand.self, RegistryCommand.self, ] ), @@ -132,7 +127,7 @@ public struct Application: AsyncParsableCommand { } } - static func createPluginLoader() async throws -> PluginLoader { + public static func createPluginLoader() async throws -> PluginLoader { let installRoot = CommandLine.executablePathUrl .deletingLastPathComponent() .appendingPathComponent("..") @@ -176,7 +171,7 @@ public struct Application: AsyncParsableCommand { ) } - static func handleProcess(io: ProcessIO, process: ClientProcess) async throws -> Int32 { + public static func handleProcess(io: ProcessIO, process: ClientProcess) async throws -> Int32 { let signals = AsyncSignalHandler.create(notify: Application.signalSet) return try await withThrowingTaskGroup(of: Int32?.self, returning: Int32.self) { group in let waitAdded = group.addTaskUnlessCancelled { @@ -314,7 +309,7 @@ extension Application { print(altered) } - enum ListFormat: String, CaseIterable, ExpressibleByArgument { + public enum ListFormat: String, CaseIterable, ExpressibleByArgument { case json case table } diff --git a/Sources/CLI/BuildCommand.swift b/Sources/CLI/BuildCommand.swift index ef8fae73b..ab6bcf66a 100644 --- a/Sources/CLI/BuildCommand.swift +++ b/Sources/CLI/BuildCommand.swift @@ -28,6 +28,7 @@ import TerminalProgress extension Application { public struct BuildCommand: AsyncParsableCommand { + public init() {} public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "build" @@ -37,72 +38,85 @@ extension Application { return config } - public init() {} - @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") - public var cpus: Int64 = 2 + var cpus: Int64 = 2 @Option( name: [.customLong("memory"), .customShort("m")], help: "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" ) - public var memory: String = "2048MB" + var memory: String = "2048MB" @Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val")) - public var buildArg: [String] = [] + var buildArg: [String] = [] @Argument(help: "Build directory") - public var contextDir: String = "." + var contextDir: String = "." @Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path")) - public var file: String = "Dockerfile" + var file: String = "Dockerfile" @Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val")) - public var label: [String] = [] + var label: [String] = [] @Flag(name: .long, help: "Do not use cache") - public var noCache: Bool = false + var noCache: Bool = false @Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build", valueName: "value")) - public var output: [String] = { + var output: [String] = { ["type=oci"] }() @Option(name: .long, help: ArgumentHelp("Cache imports for the build", valueName: "value", visibility: .hidden)) - public var cacheIn: [String] = { + var cacheIn: [String] = { [] }() @Option(name: .long, help: ArgumentHelp("Cache exports for the build", valueName: "value", visibility: .hidden)) - public var cacheOut: [String] = { + var cacheOut: [String] = { [] }() - @Option(name: .long, help: ArgumentHelp("set the build architecture", valueName: "value")) - public var arch: [String] = { - ["arm64"] + @Option( + name: .long, + help: "add the platform to the build", + transform: { val in val.split(separator: ",").map { String($0) } } + ) + var platform: [[String]] = [[]] + + @Option( + name: .long, + help: ArgumentHelp("add the OS type to the build", valueName: "value"), + transform: { val in val.split(separator: ",").map { String($0) } } + ) + var os: [[String]] = { + [["linux"]] }() - @Option(name: .long, help: ArgumentHelp("set the build os", valueName: "value")) - public var os: [String] = { - ["linux"] + @Option( + name: [.long, .short], + help: ArgumentHelp("add the architecture type to the build", valueName: "value"), + transform: { val in val.split(separator: ",").map { String($0) } } + ) + var arch: [[String]] = { + [[Arch.hostArchitecture().rawValue]] }() @Option(name: .long, help: ArgumentHelp("Progress type - one of [auto|plain|tty]", valueName: "type")) - public var progress: String = "auto" + var progress: String = "auto" @Option(name: .long, help: ArgumentHelp("Builder-shim vsock port", valueName: "port")) - public var vsockPort: UInt32 = 8088 + var vsockPort: UInt32 = 8088 @Option(name: [.customShort("t"), .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) - public var targetImageName: String = UUID().uuidString.lowercased() + var targetImageName: String = UUID().uuidString.lowercased() @Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage")) - public var target: String = "" + var target: String = "" @Flag(name: .shortAndLong, help: "Suppress build output") - public var quiet: Bool = false + var quiet: Bool = false public func run() async throws { do { @@ -119,7 +133,7 @@ extension Application { progress.set(description: "Dialing builder") - let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { group in + let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { [vsockPort, cpus, memory] group in defer { group.cancelAll() } @@ -135,7 +149,6 @@ extension Application { // If this call succeeds, then BuildKit is running. let _ = try await b.info() - return b } catch { // If we get here, "Dialing builder" is shown for such a short period @@ -220,19 +233,30 @@ extension Application { throw ContainerizationError(.interrupted, message: "exiting on signal \(sig)") } } - let platforms: [Platform] = try { - var results: [Platform] = [] - for o in self.os { - for a in self.arch { + let platforms: Set = try { + var results: Set = [] + for platform in (self.platform.flatMap { $0 }) { + guard let p = try? Platform(from: platform) else { + throw ValidationError("invalid platform specified \(platform)") + } + results.insert(p) + } + + if !results.isEmpty { + return results + } + + for o in (self.os.flatMap { $0 }) { + for a in (self.arch.flatMap { $0 }) { guard let platform = try? Platform(from: "\(o)/\(a)") else { throw ValidationError("invalid os/architecture combination \(o)/\(a)") } - results.append(platform) + results.insert(platform) } } return results }() - group.addTask { [buildArg, contextDir, label, noCache, terminal, target, quiet, cacheIn, cacheOut] in + group.addTask { [terminal, buildArg, contextDir, label, noCache, target, quiet, cacheIn, cacheOut] in let config = ContainerBuild.Builder.BuildConfig( buildID: buildID, contentStore: RemoteContentStoreClient(), @@ -241,7 +265,7 @@ extension Application { dockerfile: dockerfile, labels: label, noCache: noCache, - platforms: platforms, + platforms: [Platform](platforms), terminal: terminal, tag: imageName, target: target, diff --git a/Sources/CLI/Builder/Builder.swift b/Sources/CLI/Builder/Builder.swift index ad9eb6c97..8f831c0c6 100644 --- a/Sources/CLI/Builder/Builder.swift +++ b/Sources/CLI/Builder/Builder.swift @@ -17,8 +17,10 @@ import ArgumentParser extension Application { - struct BuilderCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct BuilderCommand: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "builder", abstract: "Manage an image builder instance", subcommands: [ diff --git a/Sources/CLI/Builder/BuilderDelete.swift b/Sources/CLI/Builder/BuilderDelete.swift index 8074fd60a..ac284f8c1 100644 --- a/Sources/CLI/Builder/BuilderDelete.swift +++ b/Sources/CLI/Builder/BuilderDelete.swift @@ -20,7 +20,9 @@ import ContainerizationError import Foundation extension Application { - struct BuilderDelete: AsyncParsableCommand { + public struct BuilderDelete: AsyncParsableCommand { + public init() {} + public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "delete" @@ -35,7 +37,7 @@ extension Application { @Flag(name: .shortAndLong, help: "Force delete builder even if it is running") var force = false - func run() async throws { + public func run() async throws { do { let container = try await ClientContainer.get(id: "buildkit") if container.status != .stopped { diff --git a/Sources/CLI/Builder/BuilderStart.swift b/Sources/CLI/Builder/BuilderStart.swift index 95aafecf3..81caa3e0f 100644 --- a/Sources/CLI/Builder/BuilderStart.swift +++ b/Sources/CLI/Builder/BuilderStart.swift @@ -27,7 +27,9 @@ import Foundation import TerminalProgress extension Application { - struct BuilderStart: AsyncParsableCommand { + public struct BuilderStart: AsyncParsableCommand { + public init() {} + public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "start" @@ -39,16 +41,16 @@ extension Application { } @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") - public var cpus: Int64 = 2 + var cpus: Int64 = 2 @Option( name: [.customLong("memory"), .customShort("m")], help: "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" ) - public var memory: String = "2048MB" + var memory: String = "2048MB" - func run() async throws { + public func run() async throws { let progressConfig = try ProgressConfig( showTasks: true, showItems: true, diff --git a/Sources/CLI/Builder/BuilderStatus.swift b/Sources/CLI/Builder/BuilderStatus.swift index b1210a3dd..802d9c148 100644 --- a/Sources/CLI/Builder/BuilderStatus.swift +++ b/Sources/CLI/Builder/BuilderStatus.swift @@ -20,7 +20,9 @@ import ContainerizationError import Foundation extension Application { - struct BuilderStatus: AsyncParsableCommand { + public struct BuilderStatus: AsyncParsableCommand { + public init() {} + public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "status" @@ -34,7 +36,7 @@ extension Application { @Flag(name: .long, help: ArgumentHelp("Display detailed status in json format")) var json: Bool = false - func run() async throws { + public func run() async throws { do { let container = try await ClientContainer.get(id: "buildkit") if json { diff --git a/Sources/CLI/Builder/BuilderStop.swift b/Sources/CLI/Builder/BuilderStop.swift index e7484c9c1..8416bf95b 100644 --- a/Sources/CLI/Builder/BuilderStop.swift +++ b/Sources/CLI/Builder/BuilderStop.swift @@ -20,7 +20,9 @@ import ContainerizationError import Foundation extension Application { - struct BuilderStop: AsyncParsableCommand { + public struct BuilderStop: AsyncParsableCommand { + public init() {} + public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "stop" @@ -31,7 +33,7 @@ extension Application { return config } - func run() async throws { + public func run() async throws { do { let container = try await ClientContainer.get(id: "buildkit") try await container.stop() diff --git a/Sources/CLI/Container/ContainerCreate.swift b/Sources/CLI/Container/ContainerCreate.swift index 8fd96cd5b..2a020e97e 100644 --- a/Sources/CLI/Container/ContainerCreate.swift +++ b/Sources/CLI/Container/ContainerCreate.swift @@ -21,8 +21,10 @@ import Foundation import TerminalProgress extension Application { - struct ContainerCreate: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ContainerCreate: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a new container") @@ -47,7 +49,7 @@ extension Application { @OptionGroup var global: Flags.Global - func run() async throws { + public func run() async throws { let progressConfig = try ProgressConfig( showTasks: true, showItems: true, diff --git a/Sources/CLI/Container/ContainerDelete.swift b/Sources/CLI/Container/ContainerDelete.swift index 2e3372189..106624af3 100644 --- a/Sources/CLI/Container/ContainerDelete.swift +++ b/Sources/CLI/Container/ContainerDelete.swift @@ -20,8 +20,10 @@ import ContainerizationError import Foundation extension Application { - struct ContainerDelete: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ContainerDelete: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "delete", abstract: "Delete one or more containers", aliases: ["rm"]) @@ -38,7 +40,7 @@ extension Application { @Argument(help: "Container IDs/names") var containerIDs: [String] = [] - func validate() throws { + public func validate() throws { if containerIDs.count == 0 && !all { throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") } @@ -50,7 +52,7 @@ extension Application { } } - mutating func run() async throws { + public mutating func run() async throws { let set = Set(containerIDs) var containers = [ClientContainer]() @@ -80,33 +82,23 @@ extension Application { var failed = [String]() let force = self.force let all = self.all - try await withThrowingTaskGroup(of: ClientContainer?.self) { group in + try await withThrowingTaskGroup(of: String?.self) { group in for container in containers { group.addTask { do { - // First we need to find if the container supports auto-remove - // and if so we need to skip deletion. - if container.status == .running { - if !force { - // We don't want to error if the user just wants all containers deleted. - // It's implied we'll skip containers we can't actually delete. - if all { - return nil - } + if container.status == .running && !force { + guard all else { throw ContainerizationError(.invalidState, message: "container is running") } - let stopOpts = ContainerStopOptions( - timeoutInSeconds: 5, - signal: SIGKILL - ) - try await container.stop(opts: stopOpts) + return nil // Skip running container when using --all } - try await container.delete() + + try await container.delete(force: force) print(container.id) return nil } catch { log.error("failed to delete container \(container.id): \(error)") - return container + return container.id } } } @@ -115,12 +107,15 @@ extension Application { guard let ctr else { continue } - failed.append(ctr.id) + failed.append(ctr) } } if failed.count > 0 { - throw ContainerizationError(.internalError, message: "delete failed for one or more containers: \(failed)") + throw ContainerizationError( + .internalError, + message: "delete failed for one or more containers: \(failed)" + ) } } } diff --git a/Sources/CLI/Container/ContainerExec.swift b/Sources/CLI/Container/ContainerExec.swift index dc4a92ae1..9e998ac6e 100644 --- a/Sources/CLI/Container/ContainerExec.swift +++ b/Sources/CLI/Container/ContainerExec.swift @@ -21,8 +21,10 @@ import ContainerizationOS import Foundation extension Application { - struct ContainerExec: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ContainerExec: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "exec", abstract: "Run a new command in a running container") @@ -38,7 +40,7 @@ extension Application { @Argument(parsing: .captureForPassthrough, help: "New process arguments") var arguments: [String] - func run() async throws { + public func run() async throws { var exitCode: Int32 = 127 let container = try await ClientContainer.get(id: containerID) try ensureRunning(container: container) diff --git a/Sources/CLI/Container/ContainerInspect.swift b/Sources/CLI/Container/ContainerInspect.swift index 43bda51a1..0b522de59 100644 --- a/Sources/CLI/Container/ContainerInspect.swift +++ b/Sources/CLI/Container/ContainerInspect.swift @@ -20,8 +20,10 @@ import Foundation import SwiftProtobuf extension Application { - struct ContainerInspect: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ContainerInspect: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "inspect", abstract: "Display information about one or more containers") @@ -31,7 +33,7 @@ extension Application { @Argument(help: "Containers to inspect") var containers: [String] - func run() async throws { + public func run() async throws { let objects: [any Codable] = try await ClientContainer.list().filter { containers.contains($0.id) }.map { diff --git a/Sources/CLI/Container/ContainerKill.swift b/Sources/CLI/Container/ContainerKill.swift index 9b9ef4ed4..3e5a89946 100644 --- a/Sources/CLI/Container/ContainerKill.swift +++ b/Sources/CLI/Container/ContainerKill.swift @@ -21,8 +21,10 @@ import ContainerizationOS import Darwin extension Application { - struct ContainerKill: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ContainerKill: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "kill", abstract: "Kill one or more running containers") @@ -38,7 +40,7 @@ extension Application { @OptionGroup var global: Flags.Global - func validate() throws { + public func validate() throws { if containerIDs.count == 0 && !all { throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") } @@ -47,7 +49,7 @@ extension Application { } } - mutating func run() async throws { + public mutating func run() async throws { let set = Set(containerIDs) var containers = try await ClientContainer.list().filter { c in diff --git a/Sources/CLI/Container/ContainerList.swift b/Sources/CLI/Container/ContainerList.swift index 43e5a4cec..f6ce69bac 100644 --- a/Sources/CLI/Container/ContainerList.swift +++ b/Sources/CLI/Container/ContainerList.swift @@ -22,8 +22,10 @@ import Foundation import SwiftProtobuf extension Application { - struct ContainerList: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ContainerList: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "list", abstract: "List containers", aliases: ["ls"]) @@ -40,7 +42,7 @@ extension Application { @OptionGroup var global: Flags.Global - func run() async throws { + public func run() async throws { let containers = try await ClientContainer.list() try printContainers(containers: containers, format: format) } diff --git a/Sources/CLI/Container/ContainerLogs.swift b/Sources/CLI/Container/ContainerLogs.swift index 5f1199669..f49e3582d 100644 --- a/Sources/CLI/Container/ContainerLogs.swift +++ b/Sources/CLI/Container/ContainerLogs.swift @@ -22,8 +22,10 @@ import Dispatch import Foundation extension Application { - struct ContainerLogs: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ContainerLogs: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "logs", abstract: "Fetch container stdio or boot logs" ) @@ -43,7 +45,7 @@ extension Application { @Argument(help: "Container to fetch logs for") var container: String - func run() async throws { + public func run() async throws { do { let container = try await ClientContainer.get(id: container) let fhs = try await container.logs() diff --git a/Sources/CLI/Container/ContainerStart.swift b/Sources/CLI/Container/ContainerStart.swift index 4f8a86cfe..789182ab8 100644 --- a/Sources/CLI/Container/ContainerStart.swift +++ b/Sources/CLI/Container/ContainerStart.swift @@ -21,8 +21,10 @@ import ContainerizationOS import TerminalProgress extension Application { - struct ContainerStart: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ContainerStart: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "start", abstract: "Start a container") @@ -38,7 +40,7 @@ extension Application { @Argument(help: "Container's ID") var containerID: String - func run() async throws { + public func run() async throws { var exitCode: Int32 = 127 let progressConfig = try ProgressConfig( diff --git a/Sources/CLI/Container/ContainerStop.swift b/Sources/CLI/Container/ContainerStop.swift index 78f69090e..3c789f4b0 100644 --- a/Sources/CLI/Container/ContainerStop.swift +++ b/Sources/CLI/Container/ContainerStop.swift @@ -21,8 +21,10 @@ import ContainerizationOS import Foundation extension Application { - struct ContainerStop: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ContainerStop: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "stop", abstract: "Stop one or more running containers") @@ -41,7 +43,7 @@ extension Application { @OptionGroup var global: Flags.Global - func validate() throws { + public func validate() throws { if containerIDs.count == 0 && !all { throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") } @@ -51,7 +53,7 @@ extension Application { } } - mutating func run() async throws { + public mutating func run() async throws { let set = Set(containerIDs) var containers = [ClientContainer]() if self.all { diff --git a/Sources/CLI/Container/ContainersCommand.swift b/Sources/CLI/Container/ContainersCommand.swift index ef6aff93e..27694799d 100644 --- a/Sources/CLI/Container/ContainersCommand.swift +++ b/Sources/CLI/Container/ContainersCommand.swift @@ -17,8 +17,10 @@ import ArgumentParser extension Application { - struct ContainersCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ContainersCommand: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "containers", abstract: "Manage containers", subcommands: [ diff --git a/Sources/CLI/DefaultCommand.swift b/Sources/CLI/DefaultCommand.swift index a8c5f0201..5b08c59e1 100644 --- a/Sources/CLI/DefaultCommand.swift +++ b/Sources/CLI/DefaultCommand.swift @@ -17,9 +17,11 @@ import ArgumentParser import ContainerClient import ContainerPlugin +import Darwin +import Foundation struct DefaultCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public static let configuration = CommandConfiguration( commandName: nil, shouldDisplay: false ) @@ -45,10 +47,62 @@ struct DefaultCommand: AsyncParsableCommand { throw ValidationError("Unknown option '\(command)'") } + // Compute canonical plugin directories to show in helpful errors (avoid hard-coded paths) + let installRoot = CommandLine.executablePathUrl + .deletingLastPathComponent() + .appendingPathComponent("..") + .standardized + let userPluginsURL = PluginLoader.userPluginsDir(installRoot: installRoot) + let installRootPluginsURL = + installRoot + .appendingPathComponent("libexec") + .appendingPathComponent("container") + .appendingPathComponent("plugins") + .standardized + let hintPaths = [userPluginsURL, installRootPluginsURL] + .map { $0.appendingPathComponent(command).path(percentEncoded: false) } + .joined(separator: "\n - ") + + // If plugin loader couldn't be created, the system/APIServer likely isn't running. + if pluginLoader == nil { + throw ValidationError( + """ + Plugins are unavailable. Start the container system services and retry: + + container system start + + Check to see that the plugin exists under: + - \(hintPaths) + + """ + ) + } + guard let plugin = pluginLoader?.findPlugin(name: command), plugin.config.isCLI else { - throw ValidationError("failed to find plugin named container-\(command)") + throw ValidationError( + """ + Plugin 'container-\(command)' not found. + + - If system services are not running, start them with: container system start + - If the plugin isn't installed, ensure it exists under: + + Check to see that the plugin exists under: + - \(hintPaths) + + """ + ) } + // Before execing into the plugin, restore default SIGINT/SIGTERM so the plugin can manage signals. + Self.resetSignalsForPluginExec() // Exec performs execvp (with no fork). try plugin.exec(args: remaining) } } + +extension DefaultCommand { + // Exposed for tests to verify signal reset semantics. + static func resetSignalsForPluginExec() { + signal(SIGINT, SIG_DFL) + signal(SIGTERM, SIG_DFL) + } +} diff --git a/Sources/CLI/Image/ImagesCommand.swift b/Sources/CLI/Image/ImageCommand.swift similarity index 85% rename from Sources/CLI/Image/ImagesCommand.swift rename to Sources/CLI/Image/ImageCommand.swift index 968dfd239..2db4cf8e4 100644 --- a/Sources/CLI/Image/ImagesCommand.swift +++ b/Sources/CLI/Image/ImageCommand.swift @@ -17,9 +17,11 @@ import ArgumentParser extension Application { - struct ImagesCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "images", + public struct ImageCommand: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "image", abstract: "Manage images", subcommands: [ ImageInspect.self, @@ -32,7 +34,7 @@ extension Application { ImageSave.self, ImageTag.self, ], - aliases: ["image", "i"] + aliases: ["i"] ) } } diff --git a/Sources/CLI/Image/ImageInspect.swift b/Sources/CLI/Image/ImageInspect.swift index cea356867..a033d5631 100644 --- a/Sources/CLI/Image/ImageInspect.swift +++ b/Sources/CLI/Image/ImageInspect.swift @@ -21,8 +21,9 @@ import Foundation import SwiftProtobuf extension Application { - struct ImageInspect: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ImageInspect: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "inspect", abstract: "Display information about one or more images") @@ -32,7 +33,7 @@ extension Application { @Argument(help: "Images to inspect") var images: [String] - func run() async throws { + public func run() async throws { var printable = [any Codable]() let result = try await ClientImage.get(names: images) let notFound = result.error diff --git a/Sources/CLI/Image/ImageList.swift b/Sources/CLI/Image/ImageList.swift index bacf9cac4..31f8dd18d 100644 --- a/Sources/CLI/Image/ImageList.swift +++ b/Sources/CLI/Image/ImageList.swift @@ -23,7 +23,8 @@ import Foundation import SwiftProtobuf extension Application { - struct ListImageOptions: ParsableArguments { + public struct ListImageOptions: ParsableArguments { + public init() {} @Flag(name: .shortAndLong, help: "Only output the image name") var quiet = false @@ -37,16 +38,17 @@ extension Application { var global: Flags.Global } - struct ListImageImplementation { - static private func createHeader() -> [[String]] { + public struct ListImageImplementation { + public init() {} + static func createHeader() -> [[String]] { [["NAME", "TAG", "DIGEST"]] } - static private func createVerboseHeader() -> [[String]] { + static func createVerboseHeader() -> [[String]] { [["NAME", "TAG", "INDEX DIGEST", "OS", "ARCH", "VARIANT", "SIZE", "CREATED", "MANIFEST DIGEST"]] } - static private func printImagesVerbose(images: [ClientImage]) async throws { + static func printImagesVerbose(images: [ClientImage]) async throws { var rows = createVerboseHeader() for image in images { @@ -102,7 +104,7 @@ extension Application { print(formatter.format()) } - static private func printImages(images: [ClientImage], format: ListFormat, options: ListImageOptions) async throws { + static func printImages(images: [ClientImage], format: ListFormat, options: ListImageOptions) async throws { var images = images images.sort { $0.reference < $1.reference @@ -160,8 +162,9 @@ extension Application { } } - struct ImageList: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ImageList: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "list", abstract: "List images", aliases: ["ls"]) @@ -169,7 +172,7 @@ extension Application { @OptionGroup var options: ListImageOptions - mutating func run() async throws { + public mutating func run() async throws { try ListImageImplementation.validate(options: options) try await ListImageImplementation.listImages(options: options) } diff --git a/Sources/CLI/Image/ImageLoad.swift b/Sources/CLI/Image/ImageLoad.swift index 719fd19ec..6b7fdd902 100644 --- a/Sources/CLI/Image/ImageLoad.swift +++ b/Sources/CLI/Image/ImageLoad.swift @@ -22,8 +22,9 @@ import Foundation import TerminalProgress extension Application { - struct ImageLoad: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ImageLoad: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "load", abstract: "Load images from an OCI compatible tar archive" ) @@ -38,7 +39,7 @@ extension Application { }) var input: String - func run() async throws { + public func run() async throws { guard FileManager.default.fileExists(atPath: input) else { print("File does not exist \(input)") Application.exit(withError: ArgumentParser.ExitCode(1)) diff --git a/Sources/CLI/Image/ImagePrune.swift b/Sources/CLI/Image/ImagePrune.swift index d233247f1..290ce91ea 100644 --- a/Sources/CLI/Image/ImagePrune.swift +++ b/Sources/CLI/Image/ImagePrune.swift @@ -19,15 +19,16 @@ import ContainerClient import Foundation extension Application { - struct ImagePrune: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ImagePrune: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "prune", abstract: "Remove unreferenced and dangling images") @OptionGroup var global: Flags.Global - func run() async throws { + public func run() async throws { let (_, size) = try await ClientImage.pruneImages() let formatter = ByteCountFormatter() let freed = formatter.string(fromByteCount: Int64(size)) diff --git a/Sources/CLI/Image/ImagePull.swift b/Sources/CLI/Image/ImagePull.swift index 806cb8f42..4d9275f5a 100644 --- a/Sources/CLI/Image/ImagePull.swift +++ b/Sources/CLI/Image/ImagePull.swift @@ -29,17 +29,31 @@ extension Application { ) @OptionGroup - public var global: Flags.Global + var global: Flags.Global @OptionGroup - public var registry: Flags.Registry + var registry: Flags.Registry @OptionGroup - public var progressFlags: Flags.Progress + var progressFlags: Flags.Progress - @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") public var platform: String? + @Option( + help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'. This takes precedence over --os and --arch" + ) + var platform: String? + + @Option( + help: "Set OS if image can target multiple operating systems" + ) + var os: String? + + @Option( + name: [.customLong("arch"), .customShort("a")], + help: "Set arch if image can target multiple architectures" + ) + var arch: String? - @Argument public var reference: String + @Argument var reference: String public init() {} @@ -55,6 +69,10 @@ extension Application { var p: Platform? if let platform { p = try Platform(from: platform) + } else if let arch { + p = try Platform(from: "\(os ?? "linux")/\(arch)") + } else if let os { + p = try Platform(from: "\(os)/\(arch ?? Arch.hostArchitecture().rawValue)") } let scheme = try RequestScheme(registry.scheme) diff --git a/Sources/CLI/Image/ImagePush.swift b/Sources/CLI/Image/ImagePush.swift index e61d162de..792f79ba5 100644 --- a/Sources/CLI/Image/ImagePush.swift +++ b/Sources/CLI/Image/ImagePush.swift @@ -21,8 +21,9 @@ import ContainerizationOCI import TerminalProgress extension Application { - struct ImagePush: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ImagePush: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "push", abstract: "Push an image" ) @@ -40,7 +41,7 @@ extension Application { @Argument var reference: String - func run() async throws { + public func run() async throws { var p: Platform? if let platform { p = try Platform(from: platform) diff --git a/Sources/CLI/Image/ImageRemove.swift b/Sources/CLI/Image/ImageRemove.swift index 2f0c86c22..0d7970629 100644 --- a/Sources/CLI/Image/ImageRemove.swift +++ b/Sources/CLI/Image/ImageRemove.swift @@ -21,7 +21,9 @@ import ContainerizationError import Foundation extension Application { - struct RemoveImageOptions: ParsableArguments { + public struct RemoveImageOptions: ParsableArguments { + public init() {} + @Flag(name: .shortAndLong, help: "Remove all images") var all: Bool = false @@ -32,7 +34,7 @@ extension Application { var global: Flags.Global } - struct RemoveImageImplementation { + public struct RemoveImageImplementation { static func validate(options: RemoveImageOptions) throws { if options.images.count == 0 && !options.all { throw ContainerizationError(.invalidArgument, message: "no image specified and --all not supplied") @@ -79,20 +81,22 @@ extension Application { } } - struct ImageRemove: AsyncParsableCommand { + public struct ImageRemove: AsyncParsableCommand { + public init() {} + @OptionGroup var options: RemoveImageOptions - static let configuration = CommandConfiguration( + public static let configuration = CommandConfiguration( commandName: "delete", abstract: "Remove one or more images", aliases: ["rm"]) - func validate() throws { + public func validate() throws { try RemoveImageImplementation.validate(options: options) } - mutating func run() async throws { + public mutating func run() async throws { try await RemoveImageImplementation.removeImage(options: options) } } diff --git a/Sources/CLI/Image/ImageSave.swift b/Sources/CLI/Image/ImageSave.swift index 8c0b6eac4..d6d4063e1 100644 --- a/Sources/CLI/Image/ImageSave.swift +++ b/Sources/CLI/Image/ImageSave.swift @@ -17,13 +17,15 @@ import ArgumentParser import ContainerClient import Containerization +import ContainerizationError import ContainerizationOCI import Foundation import TerminalProgress extension Application { - struct ImageSave: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ImageSave: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "save", abstract: "Save an image as an OCI compatible tar archive" ) @@ -31,7 +33,21 @@ extension Application { @OptionGroup var global: Flags.Global - @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? + @Option( + help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'. This takes precedence over --os and --arch" + ) + var platform: String? + + @Option( + help: "Set OS if image can target multiple operating systems" + ) + var os: String? + + @Option( + name: [.customLong("arch"), .customShort("a")], + help: "Set arch if image can target multiple architectures" + ) + var arch: String? @Option( name: .shortAndLong, help: "Path to save the image tar archive", completion: .file(), @@ -40,16 +56,20 @@ extension Application { }) var output: String - @Argument var reference: String + @Argument var references: [String] - func run() async throws { + public func run() async throws { var p: Platform? if let platform { p = try Platform(from: platform) + } else if let arch { + p = try Platform(from: "\(os ?? "linux")/\(arch)") + } else if let os { + p = try Platform(from: "\(os)/\(arch ?? Arch.hostArchitecture().rawValue)") } let progressConfig = try ProgressConfig( - description: "Saving image" + description: "Saving image(s)" ) let progress = ProgressBar(config: progressConfig) defer { @@ -57,11 +77,25 @@ extension Application { } progress.start() - let image = try await ClientImage.get(reference: reference) - try await image.save(out: output, platform: p) + var images: [ImageDescription] = [] + for reference in references { + do { + images.append(try await ClientImage.get(reference: reference).description) + } catch { + print("failed to get image for reference \(reference): \(error)") + } + } + + guard images.count == references.count else { + throw ContainerizationError(.invalidArgument, message: "failed to save image(s)") + + } + try await ClientImage.save(references: references, out: output, platform: p) progress.finish() - print("Image saved") + for reference in references { + print(reference) + } } } } diff --git a/Sources/CLI/Image/ImageTag.swift b/Sources/CLI/Image/ImageTag.swift index 01a76190f..f28e5b45b 100644 --- a/Sources/CLI/Image/ImageTag.swift +++ b/Sources/CLI/Image/ImageTag.swift @@ -18,8 +18,9 @@ import ArgumentParser import ContainerClient extension Application { - struct ImageTag: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ImageTag: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "tag", abstract: "Tag an image") @@ -32,7 +33,7 @@ extension Application { @OptionGroup var global: Flags.Global - func run() async throws { + public func run() async throws { let existing = try await ClientImage.get(reference: source) let targetReference = try ClientImage.normalizeReference(target) try await existing.tag(new: targetReference) diff --git a/Sources/CLI/Network/NetworkCommand.swift b/Sources/CLI/Network/NetworkCommand.swift index 7e502431b..b2d0797c6 100644 --- a/Sources/CLI/Network/NetworkCommand.swift +++ b/Sources/CLI/Network/NetworkCommand.swift @@ -17,8 +17,9 @@ import ArgumentParser extension Application { - struct NetworkCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct NetworkCommand: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "network", abstract: "Manage container networks", subcommands: [ diff --git a/Sources/CLI/Network/NetworkCreate.swift b/Sources/CLI/Network/NetworkCreate.swift index fadd2c0ea..27cb93126 100644 --- a/Sources/CLI/Network/NetworkCreate.swift +++ b/Sources/CLI/Network/NetworkCreate.swift @@ -23,17 +23,16 @@ import TerminalProgress extension Application { public struct NetworkCreate: AsyncParsableCommand { + public init() {} public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a new network") - public init() {} - @Argument(help: "Network name") - public var name: String + var name: String @OptionGroup - public var global: Flags.Global + var global: Flags.Global public func run() async throws { let config = NetworkConfiguration(id: self.name, mode: .nat) diff --git a/Sources/CLI/Network/NetworkDelete.swift b/Sources/CLI/Network/NetworkDelete.swift index 836d6c8ca..9aff9c561 100644 --- a/Sources/CLI/Network/NetworkDelete.swift +++ b/Sources/CLI/Network/NetworkDelete.swift @@ -21,8 +21,9 @@ import ContainerizationError import Foundation extension Application { - struct NetworkDelete: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct NetworkDelete: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "delete", abstract: "Delete one or more networks", aliases: ["rm"]) @@ -36,7 +37,7 @@ extension Application { @Argument(help: "Network names") var networkNames: [String] = [] - func validate() throws { + public func validate() throws { if networkNames.count == 0 && !all { throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied") } @@ -48,7 +49,7 @@ extension Application { } } - mutating func run() async throws { + public mutating func run() async throws { let uniqueNetworkNames = Set(networkNames) let networks: [NetworkState] diff --git a/Sources/CLI/Network/NetworkInspect.swift b/Sources/CLI/Network/NetworkInspect.swift index 614c8b111..3c4b918e7 100644 --- a/Sources/CLI/Network/NetworkInspect.swift +++ b/Sources/CLI/Network/NetworkInspect.swift @@ -21,8 +21,9 @@ import Foundation import SwiftProtobuf extension Application { - struct NetworkInspect: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct NetworkInspect: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "inspect", abstract: "Display information about one or more networks") @@ -32,7 +33,7 @@ extension Application { @Argument(help: "Networks to inspect") var networks: [String] - func run() async throws { + public func run() async throws { let objects: [any Codable] = try await ClientNetwork.list().filter { networks.contains($0.id) }.map { diff --git a/Sources/CLI/Network/NetworkList.swift b/Sources/CLI/Network/NetworkList.swift index 9fb44dcb4..15c8351b5 100644 --- a/Sources/CLI/Network/NetworkList.swift +++ b/Sources/CLI/Network/NetworkList.swift @@ -22,8 +22,9 @@ import Foundation import SwiftProtobuf extension Application { - struct NetworkList: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct NetworkList: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "list", abstract: "List networks", aliases: ["ls"]) @@ -37,16 +38,16 @@ extension Application { @OptionGroup var global: Flags.Global - func run() async throws { + public func run() async throws { let networks = try await ClientNetwork.list() try printNetworks(networks: networks, format: format) } - private func createHeader() -> [[String]] { + func createHeader() -> [[String]] { [["NETWORK", "STATE", "SUBNET"]] } - private func printNetworks(networks: [NetworkState], format: ListFormat) throws { + func printNetworks(networks: [NetworkState], format: ListFormat) throws { if format == .json { let printables = networks.map { PrintableNetwork($0) @@ -86,13 +87,13 @@ extension NetworkState { } } -struct PrintableNetwork: Codable { +public struct PrintableNetwork: Codable { let id: String let state: String let config: NetworkConfiguration let status: NetworkStatus? - init(_ network: NetworkState) { + public init(_ network: NetworkState) { self.id = network.id self.state = network.state switch network { diff --git a/Sources/CLI/Registry/Login.swift b/Sources/CLI/Registry/Login.swift index 7de7fe7e4..3346f3fe9 100644 --- a/Sources/CLI/Registry/Login.swift +++ b/Sources/CLI/Registry/Login.swift @@ -22,8 +22,9 @@ import ContainerizationOCI import Foundation extension Application { - struct Login: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct Login: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( abstract: "Login to a registry" ) @@ -39,7 +40,7 @@ extension Application { @OptionGroup var registry: Flags.Registry - func run() async throws { + public func run() async throws { var username = self.username var password = "" if passwordStdin { diff --git a/Sources/CLI/Registry/Logout.swift b/Sources/CLI/Registry/Logout.swift index a24996e12..ac1155010 100644 --- a/Sources/CLI/Registry/Logout.swift +++ b/Sources/CLI/Registry/Logout.swift @@ -20,8 +20,9 @@ import Containerization import ContainerizationOCI extension Application { - struct Logout: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct Logout: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( abstract: "Log out from a registry") @Argument(help: "Registry server name") @@ -30,7 +31,7 @@ extension Application { @OptionGroup var global: Flags.Global - func run() async throws { + public func run() async throws { let keychain = KeychainHelper(id: Constants.keychainID) let r = Reference.resolveDomain(domain: registry) try keychain.delete(domain: r) diff --git a/Sources/CLI/Registry/RegistryCommand.swift b/Sources/CLI/Registry/RegistryCommand.swift index c160c9469..e4321725e 100644 --- a/Sources/CLI/Registry/RegistryCommand.swift +++ b/Sources/CLI/Registry/RegistryCommand.swift @@ -17,8 +17,9 @@ import ArgumentParser extension Application { - struct RegistryCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct RegistryCommand: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "registry", abstract: "Manage registry configurations", subcommands: [ diff --git a/Sources/CLI/Registry/RegistryDefault.swift b/Sources/CLI/Registry/RegistryDefault.swift index 43457221b..e33e90692 100644 --- a/Sources/CLI/Registry/RegistryDefault.swift +++ b/Sources/CLI/Registry/RegistryDefault.swift @@ -22,8 +22,9 @@ import ContainerizationOCI import Foundation extension Application { - struct RegistryDefault: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct RegistryDefault: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "default", abstract: "Manage the default image registry", subcommands: [ @@ -34,8 +35,9 @@ extension Application { ) } - struct DefaultSetCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct DefaultSetCommand: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "set", abstract: "Set the default registry" ) @@ -49,7 +51,7 @@ extension Application { @Argument var host: String - func run() async throws { + public func run() async throws { let scheme = try RequestScheme(registry.scheme).schemeFor(host: host) let _url = "\(scheme)://\(host)" @@ -73,26 +75,28 @@ extension Application { } } - struct DefaultUnsetCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct DefaultUnsetCommand: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "unset", abstract: "Unset the default registry", aliases: ["clear"] ) - func run() async throws { + public func run() async throws { DefaultsStore.unset(key: .defaultRegistryDomain) print("Unset the default registry domain") } } - struct DefaultInspectCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct DefaultInspectCommand: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "inspect", abstract: "Display the default registry domain" ) - func run() async throws { + public func run() async throws { print(DefaultsStore.get(key: .defaultRegistryDomain)) } } diff --git a/Sources/CLI/RunCommand.swift b/Sources/CLI/RunCommand.swift index ef116b289..ead393d58 100644 --- a/Sources/CLI/RunCommand.swift +++ b/Sources/CLI/RunCommand.swift @@ -26,8 +26,9 @@ import NIOPosix import TerminalProgress extension Application { - struct ContainerRunCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct ContainerRunCommand: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "run", abstract: "Run a container") @@ -55,7 +56,7 @@ extension Application { @Argument(parsing: .captureForPassthrough, help: "Container init process arguments") var arguments: [String] = [] - func run() async throws { + public func run() async throws { var exitCode: Int32 = 127 let id = Utility.createContainerID(name: self.managementFlags.name) @@ -166,13 +167,13 @@ extension Application { } } -struct ProcessIO { +public struct ProcessIO: Sendable { let stdin: Pipe? let stdout: Pipe? let stderr: Pipe? var ioTracker: IoTracker? - struct IoTracker { + public struct IoTracker: Sendable{ let stream: AsyncStream let cont: AsyncStream.Continuation let configuredStreams: Int @@ -333,7 +334,7 @@ struct ProcessIO { } } - public func wait() async throws { + func wait() async throws { guard let ioTracker = self.ioTracker else { return } @@ -355,10 +356,10 @@ struct ProcessIO { } } -struct OSFile: Sendable { +public struct OSFile: Sendable { private let fd: Int32 - enum IOAction: Equatable { + public enum IOAction: Equatable { case eof case again case success @@ -366,11 +367,11 @@ struct OSFile: Sendable { case error(_ errno: Int32) } - init(fd: Int32) { + public init(fd: Int32) { self.fd = fd } - init(handle: FileHandle) { + public init(handle: FileHandle) { self.fd = handle.fileDescriptor } diff --git a/Sources/CLI/System/DNS/DNSCreate.swift b/Sources/CLI/System/DNS/DNSCreate.swift index 2dbe2d8ac..22e27401e 100644 --- a/Sources/CLI/System/DNS/DNSCreate.swift +++ b/Sources/CLI/System/DNS/DNSCreate.swift @@ -21,8 +21,9 @@ import ContainerizationExtras import Foundation extension Application { - struct DNSCreate: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct DNSCreate: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a local DNS domain for containers (must run as an administrator)" ) @@ -30,7 +31,7 @@ extension Application { @Argument(help: "the local domain name") var domainName: String - func run() async throws { + public func run() async throws { let resolver: HostDNSResolver = HostDNSResolver() do { try resolver.createDomain(name: domainName) diff --git a/Sources/CLI/System/DNS/DNSDefault.swift b/Sources/CLI/System/DNS/DNSDefault.swift index b3f074485..0cf4d3d90 100644 --- a/Sources/CLI/System/DNS/DNSDefault.swift +++ b/Sources/CLI/System/DNS/DNSDefault.swift @@ -18,8 +18,9 @@ import ArgumentParser import ContainerPersistence extension Application { - struct DNSDefault: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct DNSDefault: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "default", abstract: "Set or unset the default local DNS domain", subcommands: [ @@ -29,8 +30,9 @@ extension Application { ] ) - struct DefaultSetCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct DefaultSetCommand: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "set", abstract: "Set the default local DNS domain" @@ -39,32 +41,34 @@ extension Application { @Argument(help: "the default `--domain-name` to use for the `create` or `run` command") var domainName: String - func run() async throws { + public func run() async throws { DefaultsStore.set(value: domainName, key: .defaultDNSDomain) print(domainName) } } - struct DefaultUnsetCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct DefaultUnsetCommand: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "unset", abstract: "Unset the default local DNS domain", aliases: ["clear"] ) - func run() async throws { + public func run() async throws { DefaultsStore.unset(key: .defaultDNSDomain) print("Unset the default local DNS domain") } } - struct DefaultInspectCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct DefaultInspectCommand: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "inspect", abstract: "Display the default local DNS domain" ) - func run() async throws { + public func run() async throws { print(DefaultsStore.getOptional(key: .defaultDNSDomain) ?? "") } } diff --git a/Sources/CLI/System/DNS/DNSDelete.swift b/Sources/CLI/System/DNS/DNSDelete.swift index b3360bb57..689254e81 100644 --- a/Sources/CLI/System/DNS/DNSDelete.swift +++ b/Sources/CLI/System/DNS/DNSDelete.swift @@ -20,8 +20,9 @@ import ContainerizationError import Foundation extension Application { - struct DNSDelete: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct DNSDelete: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "delete", abstract: "Delete a local DNS domain (must run as an administrator)", aliases: ["rm"] @@ -30,7 +31,7 @@ extension Application { @Argument(help: "the local domain name") var domainName: String - func run() async throws { + public func run() async throws { let resolver = HostDNSResolver() do { try resolver.deleteDomain(name: domainName) diff --git a/Sources/CLI/System/DNS/DNSList.swift b/Sources/CLI/System/DNS/DNSList.swift index 616415775..87e669339 100644 --- a/Sources/CLI/System/DNS/DNSList.swift +++ b/Sources/CLI/System/DNS/DNSList.swift @@ -19,14 +19,15 @@ import ContainerClient import Foundation extension Application { - struct DNSList: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct DNSList: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "list", abstract: "List local DNS domains", aliases: ["ls"] ) - func run() async throws { + public func run() async throws { let resolver: HostDNSResolver = HostDNSResolver() let domains = resolver.listDomains() print(domains.joined(separator: "\n")) diff --git a/Sources/CLI/System/Kernel/KernelSet.swift b/Sources/CLI/System/Kernel/KernelSet.swift index 955022e34..9e41be479 100644 --- a/Sources/CLI/System/Kernel/KernelSet.swift +++ b/Sources/CLI/System/Kernel/KernelSet.swift @@ -25,8 +25,9 @@ import Foundation import TerminalProgress extension Application { - struct KernelSet: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct KernelSet: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "set", abstract: "Set the default kernel" ) @@ -43,12 +44,15 @@ extension Application { @Flag(name: .customLong("recommended"), help: "Download and install the recommended kernel as the default. This flag ignores any other arguments") var recommended: Bool = false - func run() async throws { + @Flag(name: .long, help: "Force install of kernel. If a kernel exists with the same name, it will be overwritten.") + var force: Bool = false + + public func run() async throws { if recommended { let url = DefaultsStore.get(key: .defaultKernelURL) let path = DefaultsStore.get(key: .defaultKernelBinaryPath) print("Installing the recommended kernel from \(url)...") - try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path) + try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path, force: force) return } guard tarPath != nil else { @@ -63,7 +67,7 @@ extension Application { } let absolutePath = URL(fileURLWithPath: binaryPath, relativeTo: .currentDirectory()).absoluteURL.absoluteString let platform = try getSystemPlatform() - try await ClientKernel.installKernel(kernelFilePath: absolutePath, platform: platform) + try await ClientKernel.installKernel(kernelFilePath: absolutePath, platform: platform, force: force) } private func setKernelFromTar() async throws { @@ -74,19 +78,19 @@ extension Application { throw ArgumentParser.ValidationError("Missing argument '--tar") } let platform = try getSystemPlatform() - let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).absoluteString + let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).path let fm = FileManager.default if fm.fileExists(atPath: localTarPath) { - try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform) + try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform, force: force) return } guard let remoteURL = URL(string: tarPath) else { throw ContainerizationError(.invalidArgument, message: "Invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?") } - try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL.absoluteString, kernelFilePath: binaryPath, platform: platform) + try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL.absoluteString, kernelFilePath: binaryPath, platform: platform, force: force) } - private func getSystemPlatform() throws -> SystemPlatform { + func getSystemPlatform() throws -> SystemPlatform { switch architecture { case "arm64": return .linuxArm @@ -97,7 +101,7 @@ extension Application { } } - public static func downloadAndInstallWithProgressBar(tarRemoteURL: String, kernelFilePath: String, platform: SystemPlatform = .current) async throws { + static func downloadAndInstallWithProgressBar(tarRemoteURL: String, kernelFilePath: String, platform: SystemPlatform = .current, force: Bool) async throws { let progressConfig = try ProgressConfig( showTasks: true, totalTasks: 2 @@ -107,7 +111,7 @@ extension Application { progress.finish() } progress.start() - try await ClientKernel.installKernelFromTar(tarFile: tarRemoteURL, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler) + try await ClientKernel.installKernelFromTar(tarFile: tarRemoteURL, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler, force: force) progress.finish() } diff --git a/Sources/CLI/System/SystemCommand.swift b/Sources/CLI/System/SystemCommand.swift index 3a92bfb92..c8526796e 100644 --- a/Sources/CLI/System/SystemCommand.swift +++ b/Sources/CLI/System/SystemCommand.swift @@ -17,8 +17,9 @@ import ArgumentParser extension Application { - struct SystemCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct SystemCommand: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "system", abstract: "Manage system components", subcommands: [ diff --git a/Sources/CLI/System/SystemDNS.swift b/Sources/CLI/System/SystemDNS.swift index 4f9b3e3b3..667ed87db 100644 --- a/Sources/CLI/System/SystemDNS.swift +++ b/Sources/CLI/System/SystemDNS.swift @@ -19,8 +19,9 @@ import ContainerizationError import Foundation extension Application { - struct SystemDNS: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct SystemDNS: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "dns", abstract: "Manage local DNS domains", subcommands: [ diff --git a/Sources/CLI/System/SystemKernel.swift b/Sources/CLI/System/SystemKernel.swift index 942bd6965..c44e3a2d8 100644 --- a/Sources/CLI/System/SystemKernel.swift +++ b/Sources/CLI/System/SystemKernel.swift @@ -17,8 +17,9 @@ import ArgumentParser extension Application { - struct SystemKernel: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct SystemKernel: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "kernel", abstract: "Manage the default kernel configuration", subcommands: [ diff --git a/Sources/CLI/System/SystemLogs.swift b/Sources/CLI/System/SystemLogs.swift index e2b87ffb9..6f869f668 100644 --- a/Sources/CLI/System/SystemLogs.swift +++ b/Sources/CLI/System/SystemLogs.swift @@ -22,10 +22,12 @@ import Foundation import OSLog extension Application { - struct SystemLogs: AsyncParsableCommand { - static let subsystem = "com.apple.container" + public struct SystemLogs: AsyncParsableCommand { + public init() {} + + public static let subsystem = "com.apple.container" - static let configuration = CommandConfiguration( + public static let configuration = CommandConfiguration( commandName: "logs", abstract: "Fetch system logs for `container` services" ) @@ -42,7 +44,7 @@ extension Application { @Flag(name: .shortAndLong, help: "Follow log output") var follow: Bool = false - func run() async throws { + public func run() async throws { let process = Process() let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM]) diff --git a/Sources/CLI/System/SystemStart.swift b/Sources/CLI/System/SystemStart.swift index 619480f9b..929bb6069 100644 --- a/Sources/CLI/System/SystemStart.swift +++ b/Sources/CLI/System/SystemStart.swift @@ -23,8 +23,9 @@ import Foundation import TerminalProgress extension Application { - struct SystemStart: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct SystemStart: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "start", abstract: "Start `container` services" ) @@ -39,7 +40,7 @@ extension Application { name: .long, help: "Path to the installation root directory", transform: { URL(filePath: $0) }) - public var installRoot = InstallRoot.defaultURL + var installRoot = InstallRoot.defaultURL @Flag(name: .long, help: "Enable debug logging for the runtime daemon.") var debug = false @@ -49,7 +50,7 @@ extension Application { help: "Specify whether the default kernel should be installed or not. The default behavior is to prompt the user for a response.") var kernelInstall: Bool? - func run() async throws { + public func run() async throws { // Without the true path to the binary in the plist, `container-apiserver` won't launch properly. // TODO: Use plugin loader for API server. let executableUrl = CommandLine.executablePathUrl @@ -112,7 +113,8 @@ extension Application { private func installInitialFilesystem() async throws { let dep = Dependencies.initFs - let pullCommand = ImagePull(reference: dep.source) + var pullCommand = try ImagePull.parse() + pullCommand.reference = dep.source print("Installing base container filesystem...") do { try await pullCommand.run() @@ -145,7 +147,7 @@ extension Application { return } print("Installing kernel...") - try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: defaultKernelURL, kernelFilePath: defaultKernelBinaryPath) + try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: defaultKernelURL, kernelFilePath: defaultKernelBinaryPath, force: true) } private func initImageExists() async -> Bool { diff --git a/Sources/CLI/System/SystemStatus.swift b/Sources/CLI/System/SystemStatus.swift index cf4a86fc5..c46e28b86 100644 --- a/Sources/CLI/System/SystemStatus.swift +++ b/Sources/CLI/System/SystemStatus.swift @@ -22,8 +22,9 @@ import Foundation import Logging extension Application { - struct SystemStatus: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct SystemStatus: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "status", abstract: "Show the status of `container` services" ) @@ -31,7 +32,7 @@ extension Application { @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") var prefix: String = "com.apple.container." - func run() async throws { + public func run() async throws { let isRegistered = try ServiceManager.isRegistered(fullServiceLabel: "\(prefix)apiserver") if !isRegistered { print("apiserver is not running and not registered with launchd") diff --git a/Sources/CLI/System/SystemStop.swift b/Sources/CLI/System/SystemStop.swift index 32824dd0c..530310041 100644 --- a/Sources/CLI/System/SystemStop.swift +++ b/Sources/CLI/System/SystemStop.swift @@ -22,11 +22,13 @@ import Foundation import Logging extension Application { - struct SystemStop: AsyncParsableCommand { + public struct SystemStop: AsyncParsableCommand { + public init() {} + private static let stopTimeoutSeconds: Int32 = 5 private static let shutdownTimeoutSeconds: Int32 = 20 - static let configuration = CommandConfiguration( + public static let configuration = CommandConfiguration( commandName: "stop", abstract: "Stop all `container` services" ) @@ -34,7 +36,7 @@ extension Application { @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") var prefix: String = "com.apple.container." - func run() async throws { + public func run() async throws { let log = Logger( label: "com.apple.container.cli", factory: { label in diff --git a/Sources/CLI/Volume/VolumeCommand.swift b/Sources/CLI/Volume/VolumeCommand.swift index fe429369a..f4e0f4261 100644 --- a/Sources/CLI/Volume/VolumeCommand.swift +++ b/Sources/CLI/Volume/VolumeCommand.swift @@ -17,8 +17,9 @@ import ArgumentParser extension Application { - struct VolumeCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct VolumeCommand: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "volume", abstract: "Manage container volumes", subcommands: [ diff --git a/Sources/CLI/Volume/VolumeCreate.swift b/Sources/CLI/Volume/VolumeCreate.swift index de7bbe309..5fd61e153 100644 --- a/Sources/CLI/Volume/VolumeCreate.swift +++ b/Sources/CLI/Volume/VolumeCreate.swift @@ -19,8 +19,9 @@ import ContainerClient import Foundation extension Application.VolumeCommand { - struct VolumeCreate: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct VolumeCreate: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a volume" ) @@ -37,7 +38,7 @@ extension Application.VolumeCommand { @Option(name: .customLong("label"), parsing: .upToNextOption, help: "Set metadata on a volume") var labels: [String] = [] - func run() async throws { + public func run() async throws { var parsedDriverOpts = Utility.parseKeyValuePairs(driverOpts) let parsedLabels = Utility.parseKeyValuePairs(labels) diff --git a/Sources/CLI/Volume/VolumeDelete.swift b/Sources/CLI/Volume/VolumeDelete.swift index 5dddab7e7..80e3408dc 100644 --- a/Sources/CLI/Volume/VolumeDelete.swift +++ b/Sources/CLI/Volume/VolumeDelete.swift @@ -19,8 +19,9 @@ import ContainerClient import Foundation extension Application.VolumeCommand { - struct VolumeDelete: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct VolumeDelete: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "delete", abstract: "Remove one or more volumes", aliases: ["rm"] @@ -29,7 +30,7 @@ extension Application.VolumeCommand { @Argument(help: "Volume name(s)") var names: [String] - func run() async throws { + public func run() async throws { for name in names { try await ClientVolume.delete(name: name) print(name) diff --git a/Sources/CLI/Volume/VolumeInspect.swift b/Sources/CLI/Volume/VolumeInspect.swift index 1fe08b014..f6405e245 100644 --- a/Sources/CLI/Volume/VolumeInspect.swift +++ b/Sources/CLI/Volume/VolumeInspect.swift @@ -19,8 +19,9 @@ import ContainerClient import Foundation extension Application.VolumeCommand { - struct VolumeInspect: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct VolumeInspect: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "inspect", abstract: "Display detailed information on one or more volumes" ) @@ -28,7 +29,7 @@ extension Application.VolumeCommand { @Argument(help: "Volume name(s)") var names: [String] - func run() async throws { + public func run() async throws { var volumes: [Volume] = [] for name in names { diff --git a/Sources/CLI/Volume/VolumeList.swift b/Sources/CLI/Volume/VolumeList.swift index 7bd3ce26f..d44b4ef19 100644 --- a/Sources/CLI/Volume/VolumeList.swift +++ b/Sources/CLI/Volume/VolumeList.swift @@ -20,8 +20,9 @@ import ContainerizationExtras import Foundation extension Application.VolumeCommand { - struct VolumeList: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct VolumeList: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "list", abstract: "List volumes", aliases: ["ls"] @@ -33,16 +34,16 @@ extension Application.VolumeCommand { @Option(name: .long, help: "Format of the output") var format: Application.ListFormat = .table - func run() async throws { + public func run() async throws { let volumes = try await ClientVolume.list() try printVolumes(volumes: volumes, format: format) } - private func createHeader() -> [[String]] { + func createHeader() -> [[String]] { [["NAME", "DRIVER", "OPTIONS"]] } - private func printVolumes(volumes: [Volume], format: Application.ListFormat) throws { + func printVolumes(volumes: [Volume], format: Application.ListFormat) throws { if format == .json { let data = try JSONEncoder().encode(volumes) print(String(data: data, encoding: .utf8)!) diff --git a/Sources/ContainerClient/ContainerEvents.swift b/Sources/ContainerClient/ContainerEvents.swift index e7fc5193e..83d06f509 100644 --- a/Sources/ContainerClient/ContainerEvents.swift +++ b/Sources/ContainerClient/ContainerEvents.swift @@ -14,8 +14,6 @@ // limitations under the License. //===----------------------------------------------------------------------===// -// - public enum ContainerEvent: Sendable, Codable { case containerStart(id: String) case containerExit(id: String, exitCode: Int64) diff --git a/Sources/ContainerClient/Core/ClientContainer.swift b/Sources/ContainerClient/Core/ClientContainer.swift index bdd1ee0cc..696b0dce8 100644 --- a/Sources/ContainerClient/Core/ClientContainer.swift +++ b/Sources/ContainerClient/Core/ClientContainer.swift @@ -165,11 +165,12 @@ extension ClientContainer { } /// Delete the container along with any resources. - public func delete() async throws { + public func delete(force: Bool = false) async throws { do { let client = XPCClient(service: Self.serviceIdentifier) let request = XPCMessage(route: .deleteContainer) request.set(key: .id, value: self.id) + request.set(key: .forceDelete, value: force) try await client.send(request) } catch { throw ContainerizationError( diff --git a/Sources/ContainerClient/Core/ClientImage.swift b/Sources/ContainerClient/Core/ClientImage.swift index 7cdf312a4..27ad39feb 100644 --- a/Sources/ContainerClient/Core/ClientImage.swift +++ b/Sources/ContainerClient/Core/ClientImage.swift @@ -256,6 +256,22 @@ extension ClientImage { let _ = try await client.send(request) } + public static func save(references: [String], out: String, platform: Platform? = nil) async throws { + let (clientImages, errors) = try await get(names: references) + guard errors.isEmpty else { + // TODO: Improve error handling here + throw ContainerizationError(.invalidArgument, message: "one or more image references are invalid: \(errors.joined(separator: ", "))") + } + + let descriptions = clientImages.map { $0.description } + let client = Self.newXPCClient() + let request = Self.newRequest(.imageSave) + try request.set(descriptions: descriptions) + request.set(key: .filePath, value: out) + try request.set(platform: platform) + let _ = try await client.send(request) + } + public static func load(from tarFile: String) async throws -> [ClientImage] { let client = newXPCClient() let request = newRequest(.imageLoad) @@ -334,15 +350,6 @@ extension ClientImage { // MARK: Snapshot Methods - public func save(out: String, platform: Platform? = nil) async throws { - let client = Self.newXPCClient() - let request = Self.newRequest(.imageSave) - try request.set(description: self.description) - request.set(key: .filePath, value: out) - try request.set(platform: platform) - let _ = try await client.send(request) - } - public func unpack(platform: Platform?, progressUpdate: ProgressUpdateHandler? = nil) async throws { let client = Self.newXPCClient() let request = Self.newRequest(.imageUnpack) diff --git a/Sources/ContainerClient/Core/ClientKernel.swift b/Sources/ContainerClient/Core/ClientKernel.swift index 4ca10108a..4b980a6c1 100644 --- a/Sources/ContainerClient/Core/ClientKernel.swift +++ b/Sources/ContainerClient/Core/ClientKernel.swift @@ -30,23 +30,27 @@ extension ClientKernel { XPCClient(service: serviceIdentifier) } - public static func installKernel(kernelFilePath: String, platform: SystemPlatform) async throws { + public static func installKernel(kernelFilePath: String, platform: SystemPlatform, force: Bool) async throws { let client = newClient() let message = XPCMessage(route: .installKernel) message.set(key: .kernelFilePath, value: kernelFilePath) + message.set(key: .kernelForce, value: force) let platformData = try JSONEncoder().encode(platform) message.set(key: .systemPlatform, value: platformData) try await client.send(message) } - public static func installKernelFromTar(tarFile: String, kernelFilePath: String, platform: SystemPlatform, progressUpdate: ProgressUpdateHandler? = nil) async throws { + public static func installKernelFromTar(tarFile: String, kernelFilePath: String, platform: SystemPlatform, progressUpdate: ProgressUpdateHandler? = nil, force: Bool) + async throws + { let client = newClient() let message = XPCMessage(route: .installKernel) message.set(key: .kernelTarURL, value: tarFile) message.set(key: .kernelFilePath, value: kernelFilePath) + message.set(key: .kernelForce, value: force) let platformData = try JSONEncoder().encode(platform) message.set(key: .systemPlatform, value: platformData) diff --git a/Sources/APIServer/Kernel/FileDownloader.swift b/Sources/ContainerClient/FileDownloader.swift similarity index 98% rename from Sources/APIServer/Kernel/FileDownloader.swift rename to Sources/ContainerClient/FileDownloader.swift index 366a054ee..acd58266e 100644 --- a/Sources/APIServer/Kernel/FileDownloader.swift +++ b/Sources/ContainerClient/FileDownloader.swift @@ -20,7 +20,7 @@ import ContainerizationExtras import Foundation import TerminalProgress -internal struct FileDownloader { +public struct FileDownloader { public static func downloadFile(url: URL, to destination: URL, progressUpdate: ProgressUpdateHandler? = nil) async throws { let request = try HTTPClient.Request(url: url) diff --git a/Sources/ContainerClient/Flags.swift b/Sources/ContainerClient/Flags.swift index b3b0e09bc..1364157f6 100644 --- a/Sources/ContainerClient/Flags.swift +++ b/Sources/ContainerClient/Flags.swift @@ -108,11 +108,14 @@ public struct Flags { @Flag(name: [.customLong("remove"), .customLong("rm")], help: "Remove the container after it stops") public var remove = false + @Option(name: .customLong("platform"), help: "Platform for the image if it's multi-platform. This takes precedence over --os and --arch") + public var platform: String? + @Option(name: .customLong("os"), help: "Set OS if image can target multiple operating systems") public var os = "linux" @Option( - name: [.customLong("arch"), .short], help: "Set arch if image can target multiple architectures") + name: [.long, .short], help: "Set arch if image can target multiple architectures") public var arch: String = Arch.hostArchitecture().rawValue @Option(name: [.customLong("volume"), .short], help: "Bind mount a volume into the container") diff --git a/Sources/ContainerClient/Parser.swift b/Sources/ContainerClient/Parser.swift index ddb60e637..8ae312d0b 100644 --- a/Sources/ContainerClient/Parser.swift +++ b/Sources/ContainerClient/Parser.swift @@ -50,7 +50,6 @@ public struct Parser { user: String?, uid: UInt32?, gid: UInt32?, defaultUser: ProcessConfiguration.User = .id(uid: 0, gid: 0) ) -> (user: ProcessConfiguration.User, groups: [UInt32]) { - var supplementalGroups: [UInt32] = [] let user: ProcessConfiguration.User = { if let user = user, !user.isEmpty { @@ -79,6 +78,10 @@ public struct Parser { .init(arch: arch, os: os) } + public static func platform(from platform: String) throws -> ContainerizationOCI.Platform { + try .init(from: platform) + } + public static func resources(cpus: Int64?, memory: String?) throws -> ContainerConfiguration.Resources { var resource = ContainerConfiguration.Resources() if let cpus { @@ -313,6 +316,9 @@ public struct Parser { fs.type = Filesystem.FSType.virtiofs case "tmpfs": fs.type = Filesystem.FSType.tmpfs + case "volume": + // Volume type will be set later in source parsing when we create the actual volume filesystem + break default: throw ContainerizationError(.invalidArgument, message: "unsupported mount type \(val)") } @@ -340,29 +346,28 @@ public struct Parser { case "source": switch type { case "virtiofs", "bind": - // Check if it's an absolute directory path first - if val.hasPrefix("/") { - let url = URL(filePath: val) - let absolutePath = url.absoluteURL.path - - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory) else { - throw ContainerizationError(.invalidArgument, message: "path '\(val)' does not exist") - } - guard isDirectory.boolValue else { - throw ContainerizationError(.invalidArgument, message: "path '\(val)' is not a directory") - } - fs.source = absolutePath - } else { - guard VolumeStorage.isValidVolumeName(val) else { - throw ContainerizationError(.invalidArgument, message: "Invalid volume name '\(val)': must match \(VolumeStorage.volumeNamePattern)") - } - - // This is a named volume - isVolume = true - volumeName = val - fs.source = val + // For bind mounts, resolve both absolute and relative paths + let url = URL(filePath: val) + let absolutePath = url.absoluteURL.path + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory) else { + throw ContainerizationError(.invalidArgument, message: "path '\(val)' does not exist") } + guard isDirectory.boolValue else { + throw ContainerizationError(.invalidArgument, message: "path '\(val)' is not a directory") + } + fs.source = absolutePath + case "volume": + // For volume mounts, validate as volume name + guard VolumeStorage.isValidVolumeName(val) else { + throw ContainerizationError(.invalidArgument, message: "Invalid volume name '\(val)': must match \(VolumeStorage.volumeNamePattern)") + } + + // This is a named volume + isVolume = true + volumeName = val + fs.source = val case "tmpfs": throw ContainerizationError(.invalidArgument, message: "cannot specify source for tmpfs mount") default: diff --git a/Sources/ContainerClient/Utility.swift b/Sources/ContainerClient/Utility.swift index 504c2195b..f25591a5e 100644 --- a/Sources/ContainerClient/Utility.swift +++ b/Sources/ContainerClient/Utility.swift @@ -72,7 +72,11 @@ public struct Utility { registry: Flags.Registry, progressUpdate: @escaping ProgressUpdateHandler ) async throws -> (ContainerConfiguration, Kernel) { - let requestedPlatform = Parser.platform(os: management.os, arch: management.arch) + var requestedPlatform = Parser.platform(os: management.os, arch: management.arch) + // Prefer --platform + if let platform = management.platform { + requestedPlatform = try Parser.platform(from: platform) + } let scheme = try RequestScheme(registry.scheme) await progressUpdate([ diff --git a/Sources/ContainerClient/XPC+.swift b/Sources/ContainerClient/XPC+.swift index 3b1efb6a9..af0281378 100644 --- a/Sources/ContainerClient/XPC+.swift +++ b/Sources/ContainerClient/XPC+.swift @@ -46,6 +46,8 @@ public enum XPCKeys: String { case logs /// Options for stopping a container key. case stopOptions + /// Whether to force stop a container when deleting. + case forceDelete /// Plugins case pluginName case plugins @@ -98,6 +100,7 @@ public enum XPCKeys: String { case kernelTarURL case kernelFilePath case systemPlatform + case kernelForce /// Volume case volume diff --git a/Sources/Services/ContainerImagesService/Server/ImageService.swift b/Sources/Services/ContainerImagesService/Server/ImageService.swift index 5dd71b2ac..59e5ef779 100644 --- a/Sources/Services/ContainerImagesService/Server/ImageService.swift +++ b/Sources/Services/ContainerImagesService/Server/ImageService.swift @@ -92,13 +92,13 @@ public actor ImagesService { try await self.imageStore.delete(reference: reference, performCleanup: garbageCollect) } - public func save(reference: String, out: URL, platform: Platform?) async throws { - self.log.info("ImagesService: \(#function) - reference: \(reference) , platform: \(String(describing: platform))") + public func save(references: [String], out: URL, platform: Platform?) async throws { + self.log.info("ImagesService: \(#function) - references: \(references) , platform: \(String(describing: platform))") let tempDir = FileManager.default.uniqueTemporaryDirectory() defer { try? FileManager.default.removeItem(at: tempDir) } - try await self.imageStore.save(references: [reference], out: tempDir, platform: platform) + 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() diff --git a/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift index 5cac3af84..44d6704fd 100644 --- a/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift +++ b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift @@ -129,14 +129,15 @@ public struct ImagesServiceHarness: Sendable { @Sendable public func save(_ message: XPCMessage) async throws -> XPCMessage { - let data = message.dataNoCopy(key: .imageDescription) + let data = message.dataNoCopy(key: .imageDescriptions) guard let data else { throw ContainerizationError( .invalidArgument, message: "missing image description" ) } - let imageDescription = try JSONDecoder().decode(ImageDescription.self, from: data) + let imageDescriptions = try JSONDecoder().decode([ImageDescription].self, from: data) + let references = imageDescriptions.map { $0.reference } let platformData = message.dataNoCopy(key: .ociPlatform) var platform: Platform? = nil @@ -150,7 +151,7 @@ public struct ImagesServiceHarness: Sendable { message: "missing output file path" ) } - try await service.save(reference: imageDescription.reference, out: URL(filePath: out), platform: platform) + try await service.save(references: references, out: URL(filePath: out), platform: platform) let reply = message.reply() return reply } From be1c392b0afc878717336b866a6ad414fae1104c Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Sun, 14 Sep 2025 13:15:36 -0700 Subject: [PATCH 78/80] make CLI option groups public properties --- Sources/CLI/Container/ContainerCreate.swift | 10 +++++----- Sources/CLI/Container/ContainerDelete.swift | 2 +- Sources/CLI/Container/ContainerExec.swift | 4 ++-- Sources/CLI/Container/ContainerInspect.swift | 2 +- Sources/CLI/Container/ContainerKill.swift | 2 +- Sources/CLI/Container/ContainerList.swift | 2 +- Sources/CLI/Container/ContainerLogs.swift | 2 +- Sources/CLI/Container/ContainerStart.swift | 2 +- Sources/CLI/Container/ContainerStop.swift | 2 +- Sources/CLI/DefaultCommand.swift | 2 +- Sources/CLI/Image/ImageInspect.swift | 2 +- Sources/CLI/Image/ImageList.swift | 4 ++-- Sources/CLI/Image/ImageLoad.swift | 2 +- Sources/CLI/Image/ImagePrune.swift | 2 +- Sources/CLI/Image/ImagePull.swift | 6 +++--- Sources/CLI/Image/ImagePush.swift | 6 +++--- Sources/CLI/Image/ImageRemove.swift | 4 ++-- Sources/CLI/Image/ImageSave.swift | 2 +- Sources/CLI/Image/ImageTag.swift | 2 +- Sources/CLI/Network/NetworkCreate.swift | 2 +- Sources/CLI/Network/NetworkDelete.swift | 2 +- Sources/CLI/Network/NetworkInspect.swift | 2 +- Sources/CLI/Network/NetworkList.swift | 2 +- Sources/CLI/Registry/Login.swift | 2 +- Sources/CLI/Registry/Logout.swift | 2 +- Sources/CLI/Registry/RegistryDefault.swift | 4 ++-- Sources/CLI/RunCommand.swift | 12 ++++++------ Sources/CLI/System/SystemLogs.swift | 2 +- 28 files changed, 45 insertions(+), 45 deletions(-) diff --git a/Sources/CLI/Container/ContainerCreate.swift b/Sources/CLI/Container/ContainerCreate.swift index 2a020e97e..6515c9108 100644 --- a/Sources/CLI/Container/ContainerCreate.swift +++ b/Sources/CLI/Container/ContainerCreate.swift @@ -35,19 +35,19 @@ extension Application { var arguments: [String] = [] @OptionGroup - var processFlags: Flags.Process + public var processFlags: Flags.Process @OptionGroup - var resourceFlags: Flags.Resource + public var resourceFlags: Flags.Resource @OptionGroup - var managementFlags: Flags.Management + public var managementFlags: Flags.Management @OptionGroup - var registryFlags: Flags.Registry + public var registryFlags: Flags.Registry @OptionGroup - var global: Flags.Global + public var global: Flags.Global public func run() async throws { let progressConfig = try ProgressConfig( diff --git a/Sources/CLI/Container/ContainerDelete.swift b/Sources/CLI/Container/ContainerDelete.swift index 106624af3..61c59e77f 100644 --- a/Sources/CLI/Container/ContainerDelete.swift +++ b/Sources/CLI/Container/ContainerDelete.swift @@ -35,7 +35,7 @@ extension Application { var all = false @OptionGroup - var global: Flags.Global + public var global: Flags.Global @Argument(help: "Container IDs/names") var containerIDs: [String] = [] diff --git a/Sources/CLI/Container/ContainerExec.swift b/Sources/CLI/Container/ContainerExec.swift index 9e998ac6e..4ee14dcdc 100644 --- a/Sources/CLI/Container/ContainerExec.swift +++ b/Sources/CLI/Container/ContainerExec.swift @@ -29,10 +29,10 @@ extension Application { abstract: "Run a new command in a running container") @OptionGroup - var processFlags: Flags.Process + public var processFlags: Flags.Process @OptionGroup - var global: Flags.Global + public var global: Flags.Global @Argument(help: "Running containers ID") var containerID: String diff --git a/Sources/CLI/Container/ContainerInspect.swift b/Sources/CLI/Container/ContainerInspect.swift index 0b522de59..8d5d5daa1 100644 --- a/Sources/CLI/Container/ContainerInspect.swift +++ b/Sources/CLI/Container/ContainerInspect.swift @@ -28,7 +28,7 @@ extension Application { abstract: "Display information about one or more containers") @OptionGroup - var global: Flags.Global + public var global: Flags.Global @Argument(help: "Containers to inspect") var containers: [String] diff --git a/Sources/CLI/Container/ContainerKill.swift b/Sources/CLI/Container/ContainerKill.swift index 3e5a89946..9e8a27a8a 100644 --- a/Sources/CLI/Container/ContainerKill.swift +++ b/Sources/CLI/Container/ContainerKill.swift @@ -38,7 +38,7 @@ extension Application { var containerIDs: [String] = [] @OptionGroup - var global: Flags.Global + public var global: Flags.Global public func validate() throws { if containerIDs.count == 0 && !all { diff --git a/Sources/CLI/Container/ContainerList.swift b/Sources/CLI/Container/ContainerList.swift index f6ce69bac..d4f2e2cd5 100644 --- a/Sources/CLI/Container/ContainerList.swift +++ b/Sources/CLI/Container/ContainerList.swift @@ -40,7 +40,7 @@ extension Application { var format: ListFormat = .table @OptionGroup - var global: Flags.Global + public var global: Flags.Global public func run() async throws { let containers = try await ClientContainer.list() diff --git a/Sources/CLI/Container/ContainerLogs.swift b/Sources/CLI/Container/ContainerLogs.swift index f49e3582d..f477190dd 100644 --- a/Sources/CLI/Container/ContainerLogs.swift +++ b/Sources/CLI/Container/ContainerLogs.swift @@ -31,7 +31,7 @@ extension Application { ) @OptionGroup - var global: Flags.Global + public var global: Flags.Global @Flag(name: .shortAndLong, help: "Follow log output") var follow: Bool = false diff --git a/Sources/CLI/Container/ContainerStart.swift b/Sources/CLI/Container/ContainerStart.swift index 789182ab8..027648623 100644 --- a/Sources/CLI/Container/ContainerStart.swift +++ b/Sources/CLI/Container/ContainerStart.swift @@ -35,7 +35,7 @@ extension Application { var interactive = false @OptionGroup - var global: Flags.Global + public var global: Flags.Global @Argument(help: "Container's ID") var containerID: String diff --git a/Sources/CLI/Container/ContainerStop.swift b/Sources/CLI/Container/ContainerStop.swift index 3c789f4b0..3729f9ad1 100644 --- a/Sources/CLI/Container/ContainerStop.swift +++ b/Sources/CLI/Container/ContainerStop.swift @@ -41,7 +41,7 @@ extension Application { var containerIDs: [String] = [] @OptionGroup - var global: Flags.Global + public var global: Flags.Global public func validate() throws { if containerIDs.count == 0 && !all { diff --git a/Sources/CLI/DefaultCommand.swift b/Sources/CLI/DefaultCommand.swift index 5b08c59e1..7ff478e62 100644 --- a/Sources/CLI/DefaultCommand.swift +++ b/Sources/CLI/DefaultCommand.swift @@ -27,7 +27,7 @@ struct DefaultCommand: AsyncParsableCommand { ) @OptionGroup(visibility: .hidden) - var global: Flags.Global + public var global: Flags.Global @Argument(parsing: .captureForPassthrough) var remaining: [String] = [] diff --git a/Sources/CLI/Image/ImageInspect.swift b/Sources/CLI/Image/ImageInspect.swift index a033d5631..ec364ab97 100644 --- a/Sources/CLI/Image/ImageInspect.swift +++ b/Sources/CLI/Image/ImageInspect.swift @@ -28,7 +28,7 @@ extension Application { abstract: "Display information about one or more images") @OptionGroup - var global: Flags.Global + public var global: Flags.Global @Argument(help: "Images to inspect") var images: [String] diff --git a/Sources/CLI/Image/ImageList.swift b/Sources/CLI/Image/ImageList.swift index 31f8dd18d..bfaa1dc8b 100644 --- a/Sources/CLI/Image/ImageList.swift +++ b/Sources/CLI/Image/ImageList.swift @@ -35,7 +35,7 @@ extension Application { var format: ListFormat = .table @OptionGroup - var global: Flags.Global + public var global: Flags.Global } public struct ListImageImplementation { @@ -170,7 +170,7 @@ extension Application { aliases: ["ls"]) @OptionGroup - var options: ListImageOptions + public var options: ListImageOptions public mutating func run() async throws { try ListImageImplementation.validate(options: options) diff --git a/Sources/CLI/Image/ImageLoad.swift b/Sources/CLI/Image/ImageLoad.swift index 6b7fdd902..a7a39de80 100644 --- a/Sources/CLI/Image/ImageLoad.swift +++ b/Sources/CLI/Image/ImageLoad.swift @@ -30,7 +30,7 @@ extension Application { ) @OptionGroup - var global: Flags.Global + public var global: Flags.Global @Option( name: .shortAndLong, help: "Path to the tar archive to load images from", completion: .file(), diff --git a/Sources/CLI/Image/ImagePrune.swift b/Sources/CLI/Image/ImagePrune.swift index 290ce91ea..5f48505a8 100644 --- a/Sources/CLI/Image/ImagePrune.swift +++ b/Sources/CLI/Image/ImagePrune.swift @@ -26,7 +26,7 @@ extension Application { abstract: "Remove unreferenced and dangling images") @OptionGroup - var global: Flags.Global + public var global: Flags.Global public func run() async throws { let (_, size) = try await ClientImage.pruneImages() diff --git a/Sources/CLI/Image/ImagePull.swift b/Sources/CLI/Image/ImagePull.swift index 4d9275f5a..2adfe00bf 100644 --- a/Sources/CLI/Image/ImagePull.swift +++ b/Sources/CLI/Image/ImagePull.swift @@ -29,13 +29,13 @@ extension Application { ) @OptionGroup - var global: Flags.Global + public var global: Flags.Global @OptionGroup - var registry: Flags.Registry + public var registry: Flags.Registry @OptionGroup - var progressFlags: Flags.Progress + public var progressFlags: Flags.Progress @Option( help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'. This takes precedence over --os and --arch" diff --git a/Sources/CLI/Image/ImagePush.swift b/Sources/CLI/Image/ImagePush.swift index 792f79ba5..2b1c71f8d 100644 --- a/Sources/CLI/Image/ImagePush.swift +++ b/Sources/CLI/Image/ImagePush.swift @@ -29,13 +29,13 @@ extension Application { ) @OptionGroup - var global: Flags.Global + public var global: Flags.Global @OptionGroup - var registry: Flags.Registry + public var registry: Flags.Registry @OptionGroup - var progressFlags: Flags.Progress + public var progressFlags: Flags.Progress @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? diff --git a/Sources/CLI/Image/ImageRemove.swift b/Sources/CLI/Image/ImageRemove.swift index 0d7970629..fe2a3facb 100644 --- a/Sources/CLI/Image/ImageRemove.swift +++ b/Sources/CLI/Image/ImageRemove.swift @@ -31,7 +31,7 @@ extension Application { var images: [String] = [] @OptionGroup - var global: Flags.Global + public var global: Flags.Global } public struct RemoveImageImplementation { @@ -85,7 +85,7 @@ extension Application { public init() {} @OptionGroup - var options: RemoveImageOptions + public var options: RemoveImageOptions public static let configuration = CommandConfiguration( commandName: "delete", diff --git a/Sources/CLI/Image/ImageSave.swift b/Sources/CLI/Image/ImageSave.swift index d6d4063e1..c655eb3ff 100644 --- a/Sources/CLI/Image/ImageSave.swift +++ b/Sources/CLI/Image/ImageSave.swift @@ -31,7 +31,7 @@ extension Application { ) @OptionGroup - var global: Flags.Global + public var global: Flags.Global @Option( help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'. This takes precedence over --os and --arch" diff --git a/Sources/CLI/Image/ImageTag.swift b/Sources/CLI/Image/ImageTag.swift index f28e5b45b..0890e17e9 100644 --- a/Sources/CLI/Image/ImageTag.swift +++ b/Sources/CLI/Image/ImageTag.swift @@ -31,7 +31,7 @@ extension Application { var target: String @OptionGroup - var global: Flags.Global + public var global: Flags.Global public func run() async throws { let existing = try await ClientImage.get(reference: source) diff --git a/Sources/CLI/Network/NetworkCreate.swift b/Sources/CLI/Network/NetworkCreate.swift index 27cb93126..d858d5a07 100644 --- a/Sources/CLI/Network/NetworkCreate.swift +++ b/Sources/CLI/Network/NetworkCreate.swift @@ -32,7 +32,7 @@ extension Application { var name: String @OptionGroup - var global: Flags.Global + public var global: Flags.Global public func run() async throws { let config = NetworkConfiguration(id: self.name, mode: .nat) diff --git a/Sources/CLI/Network/NetworkDelete.swift b/Sources/CLI/Network/NetworkDelete.swift index 9aff9c561..b18a7d6b0 100644 --- a/Sources/CLI/Network/NetworkDelete.swift +++ b/Sources/CLI/Network/NetworkDelete.swift @@ -32,7 +32,7 @@ extension Application { var all = false @OptionGroup - var global: Flags.Global + public var global: Flags.Global @Argument(help: "Network names") var networkNames: [String] = [] diff --git a/Sources/CLI/Network/NetworkInspect.swift b/Sources/CLI/Network/NetworkInspect.swift index 3c4b918e7..76fc3b062 100644 --- a/Sources/CLI/Network/NetworkInspect.swift +++ b/Sources/CLI/Network/NetworkInspect.swift @@ -28,7 +28,7 @@ extension Application { abstract: "Display information about one or more networks") @OptionGroup - var global: Flags.Global + public var global: Flags.Global @Argument(help: "Networks to inspect") var networks: [String] diff --git a/Sources/CLI/Network/NetworkList.swift b/Sources/CLI/Network/NetworkList.swift index 15c8351b5..825c3b27c 100644 --- a/Sources/CLI/Network/NetworkList.swift +++ b/Sources/CLI/Network/NetworkList.swift @@ -36,7 +36,7 @@ extension Application { var format: ListFormat = .table @OptionGroup - var global: Flags.Global + public var global: Flags.Global public func run() async throws { let networks = try await ClientNetwork.list() diff --git a/Sources/CLI/Registry/Login.swift b/Sources/CLI/Registry/Login.swift index 3346f3fe9..e352d4591 100644 --- a/Sources/CLI/Registry/Login.swift +++ b/Sources/CLI/Registry/Login.swift @@ -38,7 +38,7 @@ extension Application { var server: String @OptionGroup - var registry: Flags.Registry + public var registry: Flags.Registry public func run() async throws { var username = self.username diff --git a/Sources/CLI/Registry/Logout.swift b/Sources/CLI/Registry/Logout.swift index ac1155010..2dabd484f 100644 --- a/Sources/CLI/Registry/Logout.swift +++ b/Sources/CLI/Registry/Logout.swift @@ -29,7 +29,7 @@ extension Application { var registry: String @OptionGroup - var global: Flags.Global + public var global: Flags.Global public func run() async throws { let keychain = KeychainHelper(id: Constants.keychainID) diff --git a/Sources/CLI/Registry/RegistryDefault.swift b/Sources/CLI/Registry/RegistryDefault.swift index e33e90692..61d32c7f6 100644 --- a/Sources/CLI/Registry/RegistryDefault.swift +++ b/Sources/CLI/Registry/RegistryDefault.swift @@ -43,10 +43,10 @@ extension Application { ) @OptionGroup - var global: Flags.Global + public var global: Flags.Global @OptionGroup - var registry: Flags.Registry + public var registry: Flags.Registry @Argument var host: String diff --git a/Sources/CLI/RunCommand.swift b/Sources/CLI/RunCommand.swift index ead393d58..eb86963d3 100644 --- a/Sources/CLI/RunCommand.swift +++ b/Sources/CLI/RunCommand.swift @@ -33,22 +33,22 @@ extension Application { abstract: "Run a container") @OptionGroup - var processFlags: Flags.Process + public var processFlags: Flags.Process @OptionGroup - var resourceFlags: Flags.Resource + public var resourceFlags: Flags.Resource @OptionGroup - var managementFlags: Flags.Management + public var managementFlags: Flags.Management @OptionGroup - var registryFlags: Flags.Registry + public var registryFlags: Flags.Registry @OptionGroup - var global: Flags.Global + public var global: Flags.Global @OptionGroup - var progressFlags: Flags.Progress + public var progressFlags: Flags.Progress @Argument(help: "Image name") var image: String diff --git a/Sources/CLI/System/SystemLogs.swift b/Sources/CLI/System/SystemLogs.swift index 6f869f668..84b957286 100644 --- a/Sources/CLI/System/SystemLogs.swift +++ b/Sources/CLI/System/SystemLogs.swift @@ -33,7 +33,7 @@ extension Application { ) @OptionGroup - var global: Flags.Global + public var global: Flags.Global @Option( name: .long, From d1147f2cc04f5e971c128d969b64259d71c71d25 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Sun, 14 Sep 2025 13:27:46 -0700 Subject: [PATCH 79/80] update ComposeUp to use parse functions for CLI commands --- .../ComposeCLI/Commands/ComposeUp.swift | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/Plugins/Compose/ComposeCLI/Commands/ComposeUp.swift b/Plugins/Compose/ComposeCLI/Commands/ComposeUp.swift index d74220a47..5e941ca89 100644 --- a/Plugins/Compose/ComposeCLI/Commands/ComposeUp.swift +++ b/Plugins/Compose/ComposeCLI/Commands/ComposeUp.swift @@ -303,9 +303,10 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print("Network '\(networkName)' already exists") return } - var networkCreate = Application.NetworkCreate() + let commands = [actualNetworkName] + + var networkCreate = try Application.NetworkCreate.parse(commands) networkCreate.global = global - networkCreate.name = actualNetworkName try await networkCreate.run() print("Network '\(networkName)' created") @@ -551,18 +552,17 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } print("Pulling Image \(imageName)...") - var registry = Flags.Registry() - registry.scheme = "auto" // Set or SwiftArgumentParser gets mad - - var progress = Flags.Progress() - progress.disableProgressUpdates = false + + var commands = [ + imageName + ] + + if let platform { + commands.append(contentsOf: ["--platform", platform]) + } - var imagePull = Application.ImagePull() - imagePull.progressFlags = progress - imagePull.registry = registry + var imagePull = try Application.ImagePull.parse(commands) imagePull.global = global - imagePull.reference = imageName - imagePull.platform = platform try await imagePull.run() } @@ -582,39 +582,39 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return imageToRun } - var buildCommand = Application.BuildCommand() - - // Set Build Commands - buildCommand.buildArg = (buildConfig.args ?? [:]).map({ "\($0.key)=\(resolveVariable($0.value, with: environmentVariables))" }) - - // Locate Dockerfile and context - buildCommand.contextDir = "\(self.cwd)/\(buildConfig.context)" - buildCommand.file = "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")" - - // Handle Caching - buildCommand.noCache = noCache - buildCommand.cacheIn = [] - buildCommand.cacheOut = [] - - // Handle OS/Arch + // Build command arguments + var commands = ["\(self.cwd)/\(buildConfig.context)"] + + // Add build arguments + for (key, value) in buildConfig.args ?? [:] { + commands.append(contentsOf: ["--build-arg", "\(key)=\(resolveVariable(value, with: environmentVariables))"]) + } + + // Add Dockerfile path + commands.append(contentsOf: ["--file", "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")"]) + + // Add caching options + if noCache { + commands.append("--no-cache") + } + + // Add OS/Arch let split = service.platform?.split(separator: "/") - buildCommand.os = [String(split?.first ?? "linux")] - buildCommand.arch = [String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64")] - - // Set Image Name - buildCommand.targetImageName = imageToRun - - // Set CPU & Memory - buildCommand.cpus = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 - buildCommand.memory = service.deploy?.resources?.limits?.memory ?? "2048MB" - - // Set Miscelaneous - buildCommand.label = [] // No Label Equivalent? - buildCommand.progress = "auto" - buildCommand.vsockPort = 8088 - buildCommand.quiet = false - buildCommand.target = "" - buildCommand.output = ["type=oci"] + let os = String(split?.first ?? "linux") + let arch = String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64") + commands.append(contentsOf: ["--os", os]) + commands.append(contentsOf: ["--arch", arch]) + + // Add image name + commands.append(contentsOf: ["--tag", imageToRun]) + + // Add CPU & Memory + let cpuCount = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 + let memoryLimit = service.deploy?.resources?.limits?.memory ?? "2048MB" + commands.append(contentsOf: ["--cpus", "\(cpuCount)"]) + commands.append(contentsOf: ["--memory", memoryLimit]) + + let buildCommand = try Application.BuildCommand.parse(commands) print("\n----------------------------------------") print("Building image for service: \(serviceName) (Tag: \(imageToRun))") try buildCommand.validate() From b32f14c21e325376f8ce78a296b31f349371073f Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sun, 14 Sep 2025 16:34:07 -0400 Subject: [PATCH 80/80] Support compose.yml filenames (#1) * improve compose file not found error message * support compose.yaml and make usage consistent across up and down * make compose.yml the default in error message --- .../ComposeCLI/Commands/ComposeDown.swift | 26 +++++++++++++-- .../ComposeCLI/Commands/ComposeUp.swift | 32 ++++++++++++------- Plugins/Compose/ComposeCLI/Errors.swift | 6 ++-- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/Plugins/Compose/ComposeCLI/Commands/ComposeDown.swift b/Plugins/Compose/ComposeCLI/Commands/ComposeDown.swift index 87168885a..fd34b6aa6 100644 --- a/Plugins/Compose/ComposeCLI/Commands/ComposeDown.swift +++ b/Plugins/Compose/ComposeCLI/Commands/ComposeDown.swift @@ -43,15 +43,35 @@ public struct ComposeDown: AsyncParsableCommand { private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - var dockerComposePath: String { "\(cwd)/docker-compose.yml" } // Path to docker-compose.yml + @Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file") + var composeFilename: String = "compose.yml" + private var composePath: String { "\(cwd)/\(composeFilename)" } // Path to compose.yml private var fileManager: FileManager { FileManager.default } private var projectName: String? public mutating func run() async throws { + + // Check for supported filenames and extensions + let filenames = [ + "compose.yml", + "compose.yaml", + "docker-compose.yml", + "docker-compose.yaml", + ] + for filename in filenames { + if fileManager.fileExists(atPath: filename) { + composeFilename = filename + break + } + } + // Read docker-compose.yml content - guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { - throw YamlError.dockerfileNotFound(dockerComposePath) + guard let yamlData = fileManager.contents(atPath: composePath) else { + let path = URL(fileURLWithPath: composePath) + .deletingLastPathComponent() + .path + throw YamlError.composeFileNotFound(path) } // Decode the YAML file into the DockerCompose struct diff --git a/Plugins/Compose/ComposeCLI/Commands/ComposeUp.swift b/Plugins/Compose/ComposeCLI/Commands/ComposeUp.swift index 5e941ca89..b9a8ae683 100644 --- a/Plugins/Compose/ComposeCLI/Commands/ComposeUp.swift +++ b/Plugins/Compose/ComposeCLI/Commands/ComposeUp.swift @@ -46,7 +46,8 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { var detatch: Bool = false @Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file") - var composeFile: String = "docker-compose.yml" + var composeFilename: String = "compose.yml" + private var composePath: String { "\(cwd)/\(composeFilename)" } // Path to compose.yml @Flag(name: [.customShort("b"), .customLong("build")]) var rebuild: Bool = false @@ -61,7 +62,6 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { var global: Flags.Global private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - var dockerComposePath: String { "\(cwd)/\(composeFile)" } // Path to docker-compose.yml var envFilePath: String { "\(cwd)/\(process.envFile.first ?? ".env")" } // Path to optional .env file private var fileManager: FileManager { FileManager.default } @@ -75,18 +75,26 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ] public mutating func run() async throws { - // Check for .yml vs .yaml file extension - if !fileManager.fileExists(atPath: dockerComposePath) { - let url = URL(filePath: dockerComposePath) - - let fileNameNoExtension = url.deletingPathExtension().lastPathComponent - let newExtension = url.pathExtension == "yaml" ? "yml" : "yaml" - composeFile = "\(fileNameNoExtension).\(newExtension)" + // Check for supported filenames and extensions + let filenames = [ + "compose.yml", + "compose.yaml", + "docker-compose.yml", + "docker-compose.yaml", + ] + for filename in filenames { + if fileManager.fileExists(atPath: filename) { + composeFilename = filename + break + } } - // Read docker-compose.yml content - guard let yamlData = fileManager.contents(atPath: dockerComposePath) else { - throw YamlError.dockerfileNotFound(dockerComposePath) + // Read compose.yml content + guard let yamlData = fileManager.contents(atPath: composePath) else { + let path = URL(fileURLWithPath: composePath) + .deletingLastPathComponent() + .path + throw YamlError.composeFileNotFound(path) } // Decode the YAML file into the DockerCompose struct diff --git a/Plugins/Compose/ComposeCLI/Errors.swift b/Plugins/Compose/ComposeCLI/Errors.swift index 15a4e4810..5944ae322 100644 --- a/Plugins/Compose/ComposeCLI/Errors.swift +++ b/Plugins/Compose/ComposeCLI/Errors.swift @@ -26,12 +26,12 @@ import Foundation //extension Application { enum YamlError: Error, LocalizedError { - case dockerfileNotFound(String) + case composeFileNotFound(String) var errorDescription: String? { switch self { - case .dockerfileNotFound(let path): - return "docker-compose.yml not found at \(path)" + case .composeFileNotFound(let path): + return "compose.yml not found at \(path)" } } }