diff --git a/Makefile b/Makefile index 2e23acf5..02e2c764 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 @@ -44,7 +44,7 @@ SUDO ?= sudo include Protobuf.Makefile .PHONY: all -all: container +all: container compose all: init-block .PHONY: build @@ -57,6 +57,10 @@ build: 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 @@ -110,6 +114,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... diff --git a/Package.resolved b/Package.resolved index a102d8de..0ee2456c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -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", @@ -243,6 +252,15 @@ "revision" : "890830fff1a577dc83134890c7984020c5f6b43b", "version" : "1.6.2" } + }, + { + "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 b1384797..1a85035e 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"]), @@ -46,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"), @@ -59,11 +61,22 @@ 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"), + .package(url: "https://github.com/onevcat/Rainbow", .upToNextMajor(from: "4.0.0")), .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)), ], targets: [ .executableTarget( name: "container", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + "ContainerClient", + "ContainerCLI", + ], + path: "Sources/ExecutableCLI" + ), + .target( + name: "ContainerCLI", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), @@ -432,5 +445,27 @@ let package = Package( .define("BUILDER_SHIM_VERSION", to: "\"\(builderShimVersion)\""), ] ), + + // MARK: Plugins + .target( + name: "ComposeCLI", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + "container", + "ContainerCLI", + "Yams", + "Rainbow", + ], + path: "Plugins/Compose/ComposeCLI" + ), + + .executableTarget( + name: "compose", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + "ComposeCLI" + ], + path: "Plugins/Compose/compose" + ), ] ) diff --git a/Plugins/Compose/ComposeCLI/Codable Structs/Build.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Build.swift new file mode 100644 index 00000000..5dc9a7ff --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/Config.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Config.swift new file mode 100644 index 00000000..6b982bfd --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/Deploy.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Deploy.swift new file mode 100644 index 00000000..d30f9ffa --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/DeployResources.swift b/Plugins/Compose/ComposeCLI/Codable Structs/DeployResources.swift new file mode 100644 index 00000000..370e61a4 --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/DeployRestartPolicy.swift b/Plugins/Compose/ComposeCLI/Codable Structs/DeployRestartPolicy.swift new file mode 100644 index 00000000..56daa657 --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/DeviceReservation.swift b/Plugins/Compose/ComposeCLI/Codable Structs/DeviceReservation.swift new file mode 100644 index 00000000..47a58aca --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/DockerCompose.swift b/Plugins/Compose/ComposeCLI/Codable Structs/DockerCompose.swift new file mode 100644 index 00000000..503d9866 --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/ExternalConfig.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ExternalConfig.swift new file mode 100644 index 00000000..d05ccd46 --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/ExternalNetwork.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ExternalNetwork.swift new file mode 100644 index 00000000..07d6c8ce --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/ExternalSecret.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ExternalSecret.swift new file mode 100644 index 00000000..ce441136 --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/ExternalVolume.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ExternalVolume.swift new file mode 100644 index 00000000..04cfe4f9 --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/Healthcheck.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Healthcheck.swift new file mode 100644 index 00000000..27f5aa91 --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/Network.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Network.swift new file mode 100644 index 00000000..44752aec --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/ResourceLimits.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ResourceLimits.swift new file mode 100644 index 00000000..4643d961 --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/ResourceReservations.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ResourceReservations.swift new file mode 100644 index 00000000..26052e6b --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/Secret.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Secret.swift new file mode 100644 index 00000000..ff464c67 --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/Service.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Service.swift new file mode 100644 index 00000000..5292b81f --- /dev/null +++ b/Plugins/Compose/ComposeCLI/Codable Structs/Service.swift @@ -0,0 +1,207 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 + } + + 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) + 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/Plugins/Compose/ComposeCLI/Codable Structs/ServiceConfig.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ServiceConfig.swift new file mode 100644 index 00000000..712d42b7 --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/ServiceSecret.swift b/Plugins/Compose/ComposeCLI/Codable Structs/ServiceSecret.swift new file mode 100644 index 00000000..1849c495 --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Codable Structs/Volume.swift b/Plugins/Compose/ComposeCLI/Codable Structs/Volume.swift new file mode 100644 index 00000000..b43a1cca --- /dev/null +++ b/Plugins/Compose/ComposeCLI/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/Plugins/Compose/ComposeCLI/Commands/ComposeDown.swift b/Plugins/Compose/ComposeCLI/Commands/ComposeDown.swift new file mode 100644 index 00000000..fd34b6aa --- /dev/null +++ b/Plugins/Compose/ComposeCLI/Commands/ComposeDown.swift @@ -0,0 +1,126 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 ContainerCLI +import ContainerClient +import Foundation +import Yams + +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 } + + @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: composePath) else { + let path = URL(fileURLWithPath: composePath) + .deletingLastPathComponent() + .path + throw YamlError.composeFileNotFound(path) + } + + // 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/Plugins/Compose/ComposeCLI/Commands/ComposeUp.swift b/Plugins/Compose/ComposeCLI/Commands/ComposeUp.swift new file mode 100644 index 00000000..b9a8ae68 --- /dev/null +++ b/Plugins/Compose/ComposeCLI/Commands/ComposeUp.swift @@ -0,0 +1,770 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 ContainerCLI +import ContainerClient +import ContainerizationExtras +import Foundation +@preconcurrency import Rainbow +import Yams + +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 composeFilename: String = "compose.yml" + private var composePath: String { "\(cwd)/\(composeFilename)" } // Path to 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 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 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 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 + 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 + } + let commands = [actualNetworkName] + + var networkCreate = try Application.NetworkCreate.parse(commands) + networkCreate.global = global + + 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 commands = [ + imageName + ] + + if let platform { + commands.append(contentsOf: ["--platform", platform]) + } + + var imagePull = try Application.ImagePull.parse(commands) + imagePull.global = global + 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 + } + + // 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: "/") + 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() + 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 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/Plugins/Compose/ComposeCLI/Errors.swift b/Plugins/Compose/ComposeCLI/Errors.swift new file mode 100644 index 00000000..5944ae32 --- /dev/null +++ b/Plugins/Compose/ComposeCLI/Errors.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. +//===----------------------------------------------------------------------===// + +// +// Errors.swift +// Container-Compose +// +// Created by Morris Richman on 6/18/25. +// + +import ContainerCLI +import Foundation + +//extension Application { +enum YamlError: Error, LocalizedError { + case composeFileNotFound(String) + + var errorDescription: String? { + switch self { + case .composeFileNotFound(let path): + return "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? { + "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/Plugins/Compose/ComposeCLI/Helper Functions.swift b/Plugins/Compose/ComposeCLI/Helper Functions.swift new file mode 100644 index 00000000..c48bfead --- /dev/null +++ b/Plugins/Compose/ComposeCLI/Helper Functions.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. +//===----------------------------------------------------------------------===// + +// +// Helper Functions.swift +// container-compose-app +// +// Created by Morris Richman on 6/17/25. +// + +import Foundation +import Yams +import Rainbow +import ContainerCLI + +//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 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.. [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 5de5460a..2da0e424 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 6d56a8d8..fecc0892 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 b9b228a4..b4f5053f 100644 --- a/Sources/CLI/Application.swift +++ b/Sources/CLI/Application.swift @@ -29,23 +29,19 @@ 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 }() -@main -struct Application: AsyncParsableCommand { +public struct Application: AsyncParsableCommand { @OptionGroup - var global: Flags.Global + public var global: Flags.Global + + public init() {} - static let configuration = CommandConfiguration( + public static let configuration = CommandConfiguration( commandName: "container", abstract: "A container platform for macOS", version: ReleaseVersion.singleLine(appName: "container CLI"), @@ -72,7 +68,7 @@ struct Application: AsyncParsableCommand { name: "Image", subcommands: [ BuildCommand.self, - ImagesCommand.self, + ImageCommand.self, RegistryCommand.self, ] ), @@ -131,7 +127,7 @@ struct Application: AsyncParsableCommand { } } - static func createPluginLoader() async throws -> PluginLoader { + public static func createPluginLoader() async throws -> PluginLoader { let installRoot = CommandLine.executablePathUrl .deletingLastPathComponent() .appendingPathComponent("..") @@ -175,7 +171,7 @@ 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 { @@ -247,7 +243,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"] @@ -313,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 7d4cf43d..ab6bcf66 100644 --- a/Sources/CLI/BuildCommand.swift +++ b/Sources/CLI/BuildCommand.swift @@ -27,7 +27,8 @@ import NIO import TerminalProgress extension Application { - struct BuildCommand: AsyncParsableCommand { + public struct BuildCommand: AsyncParsableCommand { + public init() {} public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "build" @@ -38,7 +39,7 @@ 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")], @@ -77,14 +78,29 @@ extension Application { [] }() - @Option(name: .long, help: ArgumentHelp("set the build architecture", valueName: "value")) - 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")) - 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")) @@ -102,7 +118,7 @@ extension Application { @Flag(name: .shortAndLong, help: "Suppress build output") var quiet: Bool = false - func run() async throws { + public func run() async throws { do { let timeout: Duration = .seconds(300) let progressConfig = try ProgressConfig( @@ -117,16 +133,16 @@ 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() } - 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) @@ -141,8 +157,8 @@ extension Application { progress.set(totalTasks: 3) try await BuilderStart.start( - cpus: self.cpus, - memory: self.memory, + cpus: cpus, + memory: memory, progressUpdate: progress.handler ) @@ -217,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 { [terminal] in + group.addTask { [terminal, buildArg, contextDir, label, noCache, target, quiet, cacheIn, cacheOut] in let config = ContainerBuild.Builder.BuildConfig( buildID: buildID, contentStore: RemoteContentStoreClient(), @@ -238,7 +265,7 @@ extension Application { dockerfile: dockerfile, labels: label, noCache: noCache, - platforms: platforms, + platforms: [Platform](platforms), terminal: terminal, tag: imageName, target: target, @@ -315,7 +342,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/Builder/Builder.swift b/Sources/CLI/Builder/Builder.swift index ad9eb6c9..8f831c0c 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 8074fd60..ac284f8c 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 95aafecf..81caa3e0 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 b1210a3d..802d9c14 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 e7484c9c..8416bf95 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 8fd96cd5..6515c910 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") @@ -33,21 +35,21 @@ 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 - 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 2e337218..61c59e77 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"]) @@ -33,12 +35,12 @@ extension Application { var all = false @OptionGroup - var global: Flags.Global + public var global: Flags.Global @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 dc4a92ae..4ee14dcd 100644 --- a/Sources/CLI/Container/ContainerExec.swift +++ b/Sources/CLI/Container/ContainerExec.swift @@ -21,16 +21,18 @@ 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") @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 @@ -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 43bda51a..8d5d5daa 100644 --- a/Sources/CLI/Container/ContainerInspect.swift +++ b/Sources/CLI/Container/ContainerInspect.swift @@ -20,18 +20,20 @@ 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") @OptionGroup - var global: Flags.Global + public var global: Flags.Global @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 9b9ef4ed..9e8a27a8 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") @@ -36,9 +38,9 @@ extension Application { var containerIDs: [String] = [] @OptionGroup - var global: Flags.Global + public 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 43e5a4ce..d4f2e2cd 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"]) @@ -38,9 +40,9 @@ extension Application { var format: ListFormat = .table @OptionGroup - var global: Flags.Global + public 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 5f119966..f477190d 100644 --- a/Sources/CLI/Container/ContainerLogs.swift +++ b/Sources/CLI/Container/ContainerLogs.swift @@ -22,14 +22,16 @@ 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" ) @OptionGroup - var global: Flags.Global + public var global: Flags.Global @Flag(name: .shortAndLong, help: "Follow log output") var follow: Bool = false @@ -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 4f8a86cf..02764862 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") @@ -33,12 +35,12 @@ extension Application { var interactive = false @OptionGroup - var global: Flags.Global + public var global: Flags.Global @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 78f69090..3729f9ad 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") @@ -39,9 +41,9 @@ extension Application { var containerIDs: [String] = [] @OptionGroup - var global: Flags.Global + public 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 ef6aff93..27694799 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 a8c5f020..7ff478e6 100644 --- a/Sources/CLI/DefaultCommand.swift +++ b/Sources/CLI/DefaultCommand.swift @@ -17,15 +17,17 @@ 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 ) @OptionGroup(visibility: .hidden) - var global: Flags.Global + public var global: Flags.Global @Argument(parsing: .captureForPassthrough) var remaining: [String] = [] @@ -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 968dfd23..2db4cf8e 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 cea35686..ec364ab9 100644 --- a/Sources/CLI/Image/ImageInspect.swift +++ b/Sources/CLI/Image/ImageInspect.swift @@ -21,18 +21,19 @@ 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") @OptionGroup - var global: Flags.Global + public var global: Flags.Global @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 bacf9cac..bfaa1dc8 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 @@ -34,19 +35,20 @@ extension Application { var format: ListFormat = .table @OptionGroup - var global: Flags.Global + public 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,16 +162,17 @@ 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"]) @OptionGroup - var options: ListImageOptions + public 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 719fd19e..a7a39de8 100644 --- a/Sources/CLI/Image/ImageLoad.swift +++ b/Sources/CLI/Image/ImageLoad.swift @@ -22,14 +22,15 @@ 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" ) @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(), @@ -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 d233247f..5f48505a 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 + public 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 58f6dc2c..2adfe00b 100644 --- a/Sources/CLI/Image/ImagePull.swift +++ b/Sources/CLI/Image/ImagePull.swift @@ -22,28 +22,42 @@ 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'. 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 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,10 +65,14 @@ 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) + } 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 e61d162d..2b1c71f8 100644 --- a/Sources/CLI/Image/ImagePush.swift +++ b/Sources/CLI/Image/ImagePush.swift @@ -21,26 +21,27 @@ 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" ) @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? @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 2f0c86c2..fe2a3fac 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 @@ -29,10 +31,10 @@ extension Application { var images: [String] = [] @OptionGroup - var global: Flags.Global + public 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 + public 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 8c0b6eac..c655eb3f 100644 --- a/Sources/CLI/Image/ImageSave.swift +++ b/Sources/CLI/Image/ImageSave.swift @@ -17,21 +17,37 @@ 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" ) @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'") 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 01a76190..0890e17e 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") @@ -30,9 +31,9 @@ extension Application { var target: String @OptionGroup - var global: Flags.Global + public 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 7e502431..b2d0797c 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 535e029e..d858d5a0 100644 --- a/Sources/CLI/Network/NetworkCreate.swift +++ b/Sources/CLI/Network/NetworkCreate.swift @@ -22,8 +22,9 @@ import Foundation import TerminalProgress extension Application { - struct NetworkCreate: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct NetworkCreate: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a new network") @@ -31,9 +32,9 @@ extension Application { 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) diff --git a/Sources/CLI/Network/NetworkDelete.swift b/Sources/CLI/Network/NetworkDelete.swift index 836d6c8c..b18a7d6b 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"]) @@ -31,12 +32,12 @@ extension Application { var all = false @OptionGroup - var global: Flags.Global + public var global: Flags.Global @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 614c8b11..76fc3b06 100644 --- a/Sources/CLI/Network/NetworkInspect.swift +++ b/Sources/CLI/Network/NetworkInspect.swift @@ -21,18 +21,19 @@ 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") @OptionGroup - var global: Flags.Global + public var global: Flags.Global @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 9fb44dcb..825c3b27 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"]) @@ -35,18 +36,18 @@ extension Application { var format: ListFormat = .table @OptionGroup - var global: Flags.Global + public 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 7de7fe7e..e352d459 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" ) @@ -37,9 +38,9 @@ extension Application { var server: String @OptionGroup - var registry: Flags.Registry + public 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 a24996e1..2dabd484 100644 --- a/Sources/CLI/Registry/Logout.swift +++ b/Sources/CLI/Registry/Logout.swift @@ -20,17 +20,18 @@ 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") var registry: String @OptionGroup - var global: Flags.Global + public 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 c160c946..e4321725 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 43457221..61d32c7f 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,22 +35,23 @@ 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" ) @OptionGroup - var global: Flags.Global + public var global: Flags.Global @OptionGroup - var registry: Flags.Registry + public var registry: Flags.Registry @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 ef116b28..eb86963d 100644 --- a/Sources/CLI/RunCommand.swift +++ b/Sources/CLI/RunCommand.swift @@ -26,28 +26,29 @@ 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") @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 @@ -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 2dbe2d8a..22e27401 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 b3f07448..0cf4d3d9 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 b3360bb5..689254e8 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 61641577..87e66933 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 955022e3..9e41be47 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 3a92bfb9..c8526796 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 4f9b3e3b..667ed87d 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 942bd696..c44e3a2d 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 e2b87ffb..84b95728 100644 --- a/Sources/CLI/System/SystemLogs.swift +++ b/Sources/CLI/System/SystemLogs.swift @@ -22,16 +22,18 @@ 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" ) @OptionGroup - var global: Flags.Global + public var global: Flags.Global @Option( name: .long, @@ -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 619480f9..929bb606 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 cf4a86fc..c46e28b8 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 32824dd0..53031004 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 fe429369..f4e0f426 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 de7bbe30..5fd61e15 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 5dddab7e..80e3408d 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 1fe08b01..f6405e24 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 7bd3ce26..d44b4ef1 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 e7fc5193..83d06f50 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 bdd1ee0c..696b0dce 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 7cdf312a..27ad39fe 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 4ca10108..4b980a6c 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 366a054e..acd58266 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 b3b0e09b..1364157f 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 ddb60e63..8ae312d0 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 504c2195..f25591a5 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 3b1efb6a..af028137 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/ExecutableCLI/Executable.swift b/Sources/ExecutableCLI/Executable.swift new file mode 100644 index 00000000..9b7e5dd6 --- /dev/null +++ b/Sources/ExecutableCLI/Executable.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 ContainerCLI +import ContainerClient + +@main +public struct Executable: AsyncParsableCommand { + public init() {} + + @OptionGroup + var global: Flags.Global + + public static let configuration = Application.configuration + + public static func main() async throws { + try await Application.main() + } + + public func run() async throws { + var application = Application() + application.global = global + try application.validate() + try application.run() + } +} diff --git a/Sources/Services/ContainerImagesService/Server/ImageService.swift b/Sources/Services/ContainerImagesService/Server/ImageService.swift index 5dd71b2a..59e5ef77 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 5cac3af8..44d6704f 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 } diff --git a/config/compose-config.json b/config/compose-config.json new file mode 100644 index 00000000..0e59a61c --- /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" +} diff --git a/scripts/uninstall-compose.sh b/scripts/uninstall-compose.sh new file mode 100644 index 00000000..f03ce489 --- /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."