diff --git a/.github/ISSUE_TEMPLATE/01-bug.yml b/.github/ISSUE_TEMPLATE/01-bug.yml index a194fc4b6..2657edfa6 100644 --- a/.github/ISSUE_TEMPLATE/01-bug.yml +++ b/.github/ISSUE_TEMPLATE/01-bug.yml @@ -1,4 +1,4 @@ -name: Bug Report +name: Bug report description: File a bug report. title: "[Bug]: " labels: ["bug", "triage"] @@ -17,13 +17,6 @@ body: required: true - label: If possible, I've reproduced the issue using the 'main' branch of this project required: false - - type: input - id: contact - attributes: - label: Contact Details - description: How can we get in touch with you if we need more info? ex. email@example.com - validations: - required: false - type: textarea id: reproduce attributes: @@ -49,16 +42,14 @@ body: attributes: label: Environment description: | - examples: - - **OS**: MacOS 26 Beta 1 - - **swift**: Apple Swift version 6.2 - - **xcode**: Xcode 26 Beta 17A5241e - - **container**: container CLI version 0.1.0 + Examples: + - **OS**: macOS 26.0 Beta (25A5279m) + - **Xcode**: Version 26.0 beta (17A5241e) + - **Container**: Container CLI version 0.1.0 value: | - OS: - - swift: - - xcode: - - container: + - Xcode: + - Container: render: markdown validations: required: true @@ -72,7 +63,7 @@ body: id: terms attributes: label: Code of Conduct - description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/apple/container/blob/main/CONTRIBUTING.md). + description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/apple/.github/blob/main/CODE_OF_CONDUCT.md). options: - label: I agree to follow this project's Code of Conduct required: true diff --git a/.github/ISSUE_TEMPLATE/02-feature.yml b/.github/ISSUE_TEMPLATE/02-feature.yml index ba1548b59..d40fca4da 100644 --- a/.github/ISSUE_TEMPLATE/02-feature.yml +++ b/.github/ISSUE_TEMPLATE/02-feature.yml @@ -1,4 +1,4 @@ -name: Feature or Enhancement request +name: Feature or enhancement request description: File a request for a feature or enhancement title: "[Request]: " labels: ["feature", "triage"] @@ -7,14 +7,6 @@ body: attributes: value: | Thanks for contributing to the container project! - - type: input - id: contact - attributes: - label: Contact Details - description: How can we get in touch with you if we need more info? - placeholder: ex. email@example.com - validations: - required: false - type: textarea id: request attributes: @@ -26,7 +18,7 @@ body: id: terms attributes: label: Code of Conduct - description: By submitting this issue, you agree to follow our [Code of Conduct](https://example.com). + description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/apple/.github/blob/main/CODE_OF_CONDUCT.md). options: - label: I agree to follow this project's Code of Conduct required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 80658184d..4a3a318ea 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - - name: container community support + - name: Container community support url: https://github.com/apple/container/discussions about: Please ask and answer questions here. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 18e1d9c6f..b6821e544 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,6 @@ name: container project - PR/merge build -on: +on: pull_request: types: [opened, reopened, synchronize] push: @@ -8,7 +8,7 @@ on: - main - release/* -jobs: +jobs: build: name: Invoke build uses: ./.github/workflows/common.yml diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index b439222b2..9c6aac1db 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -1,82 +1,93 @@ name: container project - common jobs -on: +on: workflow_call: inputs: release: - type: boolean + type: boolean description: "Publish this build for release" default: false jobs: - buildAndTest: + buildAndTest: name: Build and test the project - timeout-minutes: 30 + if: github.repository == 'apple/container' + timeout-minutes: 60 runs-on: [self-hosted, macos, sequoia, ARM64] permissions: contents: read packages: read + env: + CURRENT_SDK: y steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Update containerization - run: | - /usr/bin/swift package update containerization + - name: Check formatting - run: | + run: | ./scripts/install-hawkeye.sh make fmt - if ! git diff --quiet -- . ':(exclude)Package.swift' ':(exclude)Package.resolved'; then echo "The following files require formatting or license header updates:\n$(git diff --name-only)" ; false ; fi - - name: Check protobuf - run: | - make protos - # TODO [launch]: TEMPORARILY we need to exclude these files since we had to modify them to add - # the github token for pulling the private repos. - if ! git diff --quiet -- . ':(exclude)Package.swift' ':(exclude)Package.resolved' ':(exclude)Protobuf.Makefile'; then echo "The following files require formatting or license header updates:\n$(git diff --name-only)" ; false ; fi + if ! git diff --quiet -- . ; then + echo "❌ The following files require formatting or license header updates:" + git diff --name-only -- . + false + fi + + - name: Check protobuf + run: | + make protos + if ! git diff --quiet -- . ; then + echo "❌ The following files require formatting or license header updates:" + git diff --name-only -- . + false + fi env: - CURRENT_SDK: y + DEVELOPER_DIR: "/Applications/Xcode_26.b1.app/Contents/Developer" + - name: Set build configuration run: | echo "BUILD_CONFIGURATION=debug" >> $GITHUB_ENV - if [ ${{ inputs.release }} == true ]; then + if [[ "${{ inputs.release }}" == "true" ]]; then echo "BUILD_CONFIGURATION=release" >> $GITHUB_ENV fi + - name: Make the container project and docs - run: | + run: | make container dsym docs tar cfz _site.tgz _site env: - DEVELOPER_DIR: "/Applications/Xcode_16.3.app/Contents/Developer" - CURRENT_SDK: y - - name: Create package + DEVELOPER_DIR: "/Applications/Xcode_26.b1.app/Contents/Developer" + + - name: Create package run: | mkdir -p outputs mv bin/${{ env.BUILD_CONFIGURATION }}/container-installer-unsigned.pkg outputs mv bin/${{ env.BUILD_CONFIGURATION }}/bundle/container-dSYM.zip outputs + - name: Test the container project run: | launchctl setenv HTTP_PROXY $HTTP_PROXY make test cleancontent install-kernel integration env: - CONTAINER_REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CONTAINER_REGISTRY_USER: ${{ github.actor }} - CONTAINER_REGISTRY_HOST: ghcr.io - DEVELOPER_DIR: "/Applications/Xcode_16.3.app/Contents/Developer" - CURRENT_SDK: y + DEVELOPER_DIR: "/Applications/Xcode_26.b1.app/Contents/Developer" + CURRENT_SDK: y # explicitly repeated due to local env block + - name: Save documentation artifact uses: actions/upload-artifact@v4 with: name: api-docs path: "./_site.tgz" retention-days: 14 + - name: Save package artifacts uses: actions/upload-artifact@v4 with: name: container-package path: ${{ github.workspace }}/outputs + uploadPages: # Separate upload step required because upload-pages-artifact needs # gtar which is not on the macOS runner. @@ -87,13 +98,16 @@ jobs: steps: - name: Setup Pages uses: actions/configure-pages@v5 + - name: Download a single artifact uses: actions/download-artifact@v4 with: name: api-docs + - name: Add API docs to documentation run: | tar xfz _site.tgz + - name: Upload Artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/.github/workflows/docs-release.yml b/.github/workflows/docs-release.yml index 8cfb0ec2a..68a19854d 100644 --- a/.github/workflows/docs-release.yml +++ b/.github/workflows/docs-release.yml @@ -1,15 +1,15 @@ -# Manual workflow for releasing docs ad-hoc. Workflow can only be run for main or release branches. +# Manual workflow for releasing docs ad-hoc. Workflow can only be run for main or release branches. # Workflow does NOT publish a release of container. name: Deploy application website -on: +on: workflow_dispatch: -jobs: +jobs: checkBranch: runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags') || startsWith(github.ref, 'refs/heads/release') steps: - - name: Branch validation + - name: Branch validation run: echo "Branch ${{ github.ref_name }} is allowed" buildSite: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 449007b90..7edb8cc95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,22 +1,24 @@ name: container project - release build -on: - push: +on: + push: tags: - - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+\\.[0-9]+\\.[0-9]+" -jobs: +jobs: build: name: Invoke build and release uses: ./.github/workflows/common.yml - with: + with: release: true secrets: inherit permissions: contents: read packages: read pages: write + release: + if: startsWith(github.ref, 'refs/tags/') name: Publish release timeout-minutes: 30 needs: build @@ -30,10 +32,18 @@ jobs: uses: actions/download-artifact@v4 with: path: outputs + + - name: Verify artifacts exist + run: | + echo "Checking for expected artifacts..." + ls -la outputs/container-package/ + test -e outputs/container-package/*.zip || (echo "Missing .zip file!" && exit 1) + test -e outputs/container-package/*.pkg || (echo "Missing .pkg file!" && exit 1) + - name: Create release uses: softprops/action-gh-release@v2 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ github.token }} name: ${{ github.ref_name }}-prerelease draft: true make_latest: false diff --git a/.gitignore b/.gitignore index 024b97af5..43bf9f1e5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,6 @@ Packages/ api-docs/ workdir/ installer/ -.xcode/ -.vscode/ .venv/ .clitests/ test_results/ diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 000000000..3899003d2 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,12 @@ +version: 1 +builder: + configs: + - documentation_targets: + - ContainerSandboxService + - ContainerNetworkService + - ContainerImagesService + - ContainerClient + - ContainerLog + - ContainerPlugin + - ContainerXPC + - TerminalProgress diff --git a/BUILDING.md b/BUILDING.md index 1d15e1298..3b439670e 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -1,9 +1,10 @@ # Building the project -To build the `container` project, your system needs either: +To build the `container` project, you need: -- macOS 15 or newer and Xcode 26 Beta -- macOS 26 Beta 1 or newer +- Mac with Apple silicon +- macOS 15 minimum, macOS 26 beta recommended +- Xcode 26 beta ## Compile and test diff --git a/Makefile b/Makefile index 500c1f8c0..114b39979 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 = $(shell $(SWIFT) build -c $(BUILD_CONFIGURATION) --show-bin-path) STAGING_DIR := bin/$(BUILD_CONFIGURATION)/staging/ PKG_PATH := bin/$(BUILD_CONFIGURATION)/container-installer-unsigned.pkg DSYM_DIR := bin/$(BUILD_CONFIGURATION)/bundle/container-dSYM @@ -49,7 +49,7 @@ all: init-block .PHONY: build build: @echo Building container binaries... - @#Remove this when the updated MacOS SDK is available publicly + @#Remove this when the updated macOS SDK is available publicly $(SWIFT) build -c $(BUILD_CONFIGURATION) $(CURRENT_SDK_ARGS) ; \ .PHONY: container @@ -143,7 +143,12 @@ integration: init-block @echo "Removing any existing containers" @bin/container rm --all @echo "Starting CLI integration tests" - @RUN_CLI_INTEGRATION_TESTS=1 $(SWIFT) test -c $(BUILD_CONFIGURATION) $(CURRENT_SDK_ARGS) --filter TestCLI + @$(SWIFT) test -c $(BUILD_CONFIGURATION) $(CURRENT_SDK_ARGS) --filter TestCLIRunLifecycle + @$(SWIFT) test -c $(BUILD_CONFIGURATION) $(CURRENT_SDK_ARGS) --filter TestCLIExecCommand + @$(SWIFT) test -c $(BUILD_CONFIGURATION) $(CURRENT_SDK_ARGS) --filter TestCLIRunCommand + @$(SWIFT) test -c $(BUILD_CONFIGURATION) $(CURRENT_SDK_ARGS) --filter TestCLIImagesCommand + @$(SWIFT) test -c $(BUILD_CONFIGURATION) $(CURRENT_SDK_ARGS) --filter TestCLIRunBase + @$(SWIFT) test -c $(BUILD_CONFIGURATION) $(CURRENT_SDK_ARGS) --filter TestCLIBuildBase @echo Ensuring apiserver stopped after the CLI integration tests... @scripts/ensure-container-stopped.sh @@ -177,12 +182,10 @@ serve-docs: @python3 -m http.server --bind 127.0.0.1 --directory ./_serve .PHONY: docs -docs: _site - -_site: +docs: @echo Updating API documentation... - rm -rf $@ - @scripts/make-docs.sh $@ container + @rm -rf _site + @scripts/make-docs.sh _site container .PHONY: cleancontent cleancontent: diff --git a/Package.resolved b/Package.resolved index 2aa7bdfb6..289c7fd2b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ea3144c0718528cd6faef7841a8c1fb6ae9339473ba78f9d5d87b16415d46d58", + "originHash" : "4ac93777a9a369fb7c46f1af4cd15c516926a8f4b23679f1ee2bc40b9a422313", "pins" : [ { "identity" : "async-http-client", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/containerization.git", "state" : { - "revision" : "56c06be9a9473b6df9031dd217551e6ad9a97d29", - "version" : "0.1.0" + "revision" : "4b05e5f2313e881ee048f7063b30e73070fbd1b1", + "version" : "0.1.1" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "cd142fd2f64be2100422d658e7411e39489da985", - "version" : "1.2.0" + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" } }, { diff --git a/Package.swift b/Package.swift index b06883dd5..cc2e0376a 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.2 //===----------------------------------------------------------------------===// // Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. // @@ -26,16 +26,13 @@ if let path = ProcessInfo.processInfo.environment["CONTAINERIZATION_PATH"] { scDependency = .package(path: path) scVersion = "latest" } else { - scVersion = "0.1.0" - if let containerizationRepo = ProcessInfo.processInfo.environment["CONTAINERIZATION_REPO"], containerizationRepo != "" { - scDependency = .package(url: containerizationRepo, exact: Version(stringLiteral: scVersion)) - } else { - scDependency = .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)) - } + scVersion = "0.1.1" + scDependency = .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)) } let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0" let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified" +let builderShimVersion = "0.2.1" let package = Package( name: "container", @@ -313,6 +310,7 @@ let package = Package( .define("CZ_VERSION", to: "\"\(scVersion)\""), .define("GIT_COMMIT", to: "\"\(gitCommit)\""), .define("RELEASE_VERSION", to: "\"\(releaseVersion)\""), + .define("BUILDER_SHIM_VERSION", to: "\"\(builderShimVersion)\""), ] ), ] diff --git a/Protobuf.Makefile b/Protobuf.Makefile index badf6a31f..1966cd010 100644 --- a/Protobuf.Makefile +++ b/Protobuf.Makefile @@ -17,7 +17,9 @@ LOCAL_DIR := $(ROOT_DIR)/.local LOCALBIN := $(LOCAL_DIR)/bin BUILDER_SHIM_REPO ?= https://github.com/apple/container-builder-shim.git + ## Versions +BUILDER_SHIM_VERSION ?= $(shell sed -n 's/let builderShimVersion *= *"\(.*\)"/\1/p' Package.swift) PROTOC_VERSION=26.1 # protoc binary installation @@ -41,7 +43,7 @@ protoc-gen-swift: protos: $(PROTOC) protoc-gen-swift protoc_gen_grpc_swift @echo Generating protocol buffers source code... @mkdir -p $(LOCAL_DIR) - @cd $(LOCAL_DIR) && git clone $(BUILDER_SHIM_REPO) + @cd $(LOCAL_DIR) && git clone --branch $(BUILDER_SHIM_VERSION) --depth 1 $(BUILDER_SHIM_REPO) @$(PROTOC) $(LOCAL_DIR)/container-builder-shim/pkg/api/Builder.proto \ --plugin=protoc-gen-grpc-swift=$(BUILD_BIN_DIR)/protoc-gen-grpc-swift \ --plugin=protoc-gen-swift=$(BUILD_BIN_DIR)/protoc-gen-swift \ diff --git a/README.md b/README.md index 594a6cf59..179b9b32e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The tool consumes and produces OCI-compliant container images, so you can pull a You need an Apple silicon Mac to run `container`. To build it, see the [BUILDING](./BUILDING.md) document. -`container` relies on the new features and enhancements present in the macOS 26 Beta 1. You can run the tool on macOS 15, but the `container` maintainers typically will not address issues discovered on macOS 15 that cannot be reproduced on the macOS 26 Beta 1. +`container` relies on the new features and enhancements present in the macOS 26 beta. You can run the tool on macOS 15, but the `container` maintainers typically will not address issues discovered on macOS 15 that cannot be reproduced on the macOS 26 beta. There are [significant networking limitations](/docs/technical-overview.md#macos-15-limitations) that impact the usability of `container` on macOS 15. @@ -31,6 +31,12 @@ Download the latest signed installer package for `container` from the [GitHub re To install the tool, double click the package file and follow the instructions. Enter your administrator password when prompted, to give the installer permission to place the installed files under `/usr/local`. +Start the system service with: + +``` +container system start +``` + ### Uninstall Use the `uninstall-container.sh` script to remove `container` from your system. To remove your user data along with the tool, run: @@ -55,3 +61,7 @@ uninstall-container.sh -k ## Contributing Contributions to `container` are welcomed and encouraged. Please see our [main contributing guide](https://github.com/apple/containerization/blob/main/CONTRIBUTING.md) for more information. + +## Project Status + +The container project is currently under active development. Its stability, both for consuming the project as a Swift package and the `container` tool, is only guaranteed within minor versions, such as between 0.1.1 and 0.1.2. Minor version number releases may include breaking changes until we achieve a 1.0.0 release. diff --git a/Sources/APIServer/APIServer.swift b/Sources/APIServer/APIServer.swift index c771c9462..3edb85a91 100644 --- a/Sources/APIServer/APIServer.swift +++ b/Sources/APIServer/APIServer.swift @@ -182,9 +182,9 @@ struct APIServer: AsyncParsableCommand { private func initializeKernelService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws { let svc = try KernelService(log: log, appRoot: Self.appRoot) - let harnsess = KernelHarness(service: svc, log: log) - routes[XPCRoute.installKernel] = harnsess.install - routes[XPCRoute.getDefaultKernel] = harnsess.getDefaultKernel + let harness = KernelHarness(service: svc, log: log) + routes[XPCRoute.installKernel] = harness.install + routes[XPCRoute.getDefaultKernel] = harness.getDefaultKernel } private func initializeContainerService(root: URL, pluginLoader: PluginLoader, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws { diff --git a/Sources/APIServer/Containers/ContainersService.swift b/Sources/APIServer/Containers/ContainersService.swift index 6970b9e1f..edadc3f00 100644 --- a/Sources/APIServer/Containers/ContainersService.swift +++ b/Sources/APIServer/Containers/ContainersService.swift @@ -209,10 +209,10 @@ actor ContainersService { switch item.state { case .alive(let client): let state = try await client.state() - if state.status == .running { + if state.status == .running || state.status == .stopping { throw ContainerizationError( .invalidState, - message: "container with ID \(id) is running" + message: "container \(id) is not yet stopped and can not be deleted" ) } try self._cleanup(id: id, item: item) diff --git a/Sources/CLI/Application.swift b/Sources/CLI/Application.swift index 3d029bcff..cd7c22fb2 100644 --- a/Sources/CLI/Application.swift +++ b/Sources/CLI/Application.swift @@ -21,6 +21,7 @@ import CVersion import ContainerClient import ContainerLog import ContainerPlugin +import ContainerizationError import ContainerizationOS import Foundation import Logging @@ -150,6 +151,13 @@ struct Application: AsyncParsableCommand { public static func main() async throws { restoreCursorAtExit() + #if DEBUG + let warning = "Running debug build. Performance may be degraded." + let formattedWarning = "\u{001B}[33mWarning!\u{001B}[0m \(warning)\n" + let warningData = Data(formattedWarning.utf8) + FileHandle.standardError.write(warningData) + #endif + let fullArgs = CommandLine.arguments let args = Array(fullArgs.dropFirst()) @@ -169,7 +177,13 @@ struct Application: AsyncParsableCommand { Self.printModifiedHelpText() return } - Application.exit(withError: error) + let errorAsString: String = String(describing: error) + if errorAsString.contains("XPC connection error") { + let modifiedError = ContainerizationError(.interrupted, message: "\(error)\nEnsure container system service has been started with `container system start`.") + Application.exit(withError: modifiedError) + } else { + Application.exit(withError: error) + } } } @@ -177,7 +191,9 @@ struct Application: AsyncParsableCommand { let signals = AsyncSignalHandler.create(notify: Application.signalSet) return try await withThrowingTaskGroup(of: Int32?.self, returning: Int32.self) { group in let waitAdded = group.addTaskUnlessCancelled { - try await process.wait() + let code = try await process.wait() + try await io.wait() + return code } guard waitAdded else { @@ -287,7 +303,7 @@ extension Application { versionDetails["build"] = "debug" #endif #if CURRENT_SDK - versionDetails["sdk"] = "MacOS 15" + versionDetails["sdk"] = "macOS 15" #endif let gitCommit = { let sha = get_git_commit().map { String(cString: $0) } diff --git a/Sources/CLI/Builder/BuilderStart.swift b/Sources/CLI/Builder/BuilderStart.swift index b1c98d3f0..76054e984 100644 --- a/Sources/CLI/Builder/BuilderStart.swift +++ b/Sources/CLI/Builder/BuilderStart.swift @@ -125,6 +125,11 @@ extension Application { return } try await existingContainer.delete() + case .stopping: + throw ContainerizationError( + .invalidState, + message: "builder is stopping, please wait until it is fully stopped before proceeding" + ) case .unknown: break } diff --git a/Sources/CLI/Container/ContainerCreate.swift b/Sources/CLI/Container/ContainerCreate.swift index de2247650..5071c10e6 100644 --- a/Sources/CLI/Container/ContainerCreate.swift +++ b/Sources/CLI/Container/ContainerCreate.swift @@ -48,17 +48,12 @@ extension Application { var global: Flags.Global func run() async throws { - var progressConfig: ProgressConfig - if managementFlags.disableProgressUpdates { - progressConfig = try ProgressConfig(disableProgressUpdates: managementFlags.disableProgressUpdates) - } else { - progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - ignoreSmallSize: true, - totalTasks: 3 - ) - } + let progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + ignoreSmallSize: true, + totalTasks: 3 + ) let progress = ProgressBar(config: progressConfig) defer { progress.finish() diff --git a/Sources/CLI/Container/ContainerDelete.swift b/Sources/CLI/Container/ContainerDelete.swift index 0da563770..eb7baf280 100644 --- a/Sources/CLI/Container/ContainerDelete.swift +++ b/Sources/CLI/Container/ContainerDelete.swift @@ -61,8 +61,8 @@ extension Application { containers = ctrs.filter { c in set.contains(c.id) } - // If one of the containers requested isn't present lets throw. We don't need to do - // this for --all as --all should be perfectly usable with no containers to remove, otherwise + // If one of the containers requested isn't present, let's throw. We don't need to do + // this for --all as --all should be perfectly usable with no containers to remove; otherwise, // it'd be quite clunky. if containers.count != set.count { let missing = set.filter { id in diff --git a/Sources/CLI/Image/ImageList.swift b/Sources/CLI/Image/ImageList.swift index 8b7bec570..e666feca7 100644 --- a/Sources/CLI/Image/ImageList.swift +++ b/Sources/CLI/Image/ImageList.swift @@ -144,8 +144,8 @@ extension Application { if options.quiet && options.verbose { throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite and --verbose together") } - let modifer = options.quiet || options.verbose - if modifer && options.format == .json { + let modifier = options.quiet || options.verbose + if modifier && options.format == .json { throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite or --verbose along with --format json") } } diff --git a/Sources/CLI/Image/ImagePull.swift b/Sources/CLI/Image/ImagePull.swift index ade652a85..58f6dc2c6 100644 --- a/Sources/CLI/Image/ImagePull.swift +++ b/Sources/CLI/Image/ImagePull.swift @@ -34,15 +34,19 @@ extension Application { @OptionGroup var registry: Flags.Registry + @OptionGroup + var progressFlags: Flags.Progress + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? @Argument var reference: String init() {} - init(platform: String? = nil, scheme: String = "auto", reference: String) { + init(platform: String? = nil, scheme: String = "auto", reference: String, disableProgress: Bool = false) { self.global = Flags.Global() self.registry = Flags.Registry(scheme: scheme) + self.progressFlags = Flags.Progress(disableProgressUpdates: disableProgress) self.platform = platform self.reference = reference } @@ -56,12 +60,19 @@ extension Application { let scheme = try RequestScheme(registry.scheme) let processedReference = try ClientImage.normalizeReference(reference) - let progressConfig = try ProgressConfig( - showTasks: true, - showItems: true, - ignoreSmallSize: true, - totalTasks: 2 - ) + + var progressConfig: ProgressConfig + if self.progressFlags.disableProgressUpdates { + progressConfig = try ProgressConfig(disableProgressUpdates: self.progressFlags.disableProgressUpdates) + } else { + progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + ignoreSmallSize: true, + totalTasks: 2 + ) + } + let progress = ProgressBar(config: progressConfig) defer { progress.finish() diff --git a/Sources/CLI/Image/ImagePush.swift b/Sources/CLI/Image/ImagePush.swift index cba664c2d..e61d162de 100644 --- a/Sources/CLI/Image/ImagePush.swift +++ b/Sources/CLI/Image/ImagePush.swift @@ -33,6 +33,9 @@ extension Application { @OptionGroup var registry: Flags.Registry + @OptionGroup + var progressFlags: Flags.Progress + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? @Argument var reference: String @@ -46,13 +49,18 @@ extension Application { let scheme = try RequestScheme(registry.scheme) let image = try await ClientImage.get(reference: reference) - let progressConfig = try ProgressConfig( - description: "Pushing image \(image.reference)", - itemsName: "blobs", - showItems: true, - showSpeed: false, - ignoreSmallSize: true - ) + var progressConfig: ProgressConfig + if progressFlags.disableProgressUpdates { + progressConfig = try ProgressConfig(disableProgressUpdates: progressFlags.disableProgressUpdates) + } else { + progressConfig = try ProgressConfig( + description: "Pushing image \(image.reference)", + itemsName: "blobs", + showItems: true, + showSpeed: false, + ignoreSmallSize: true + ) + } let progress = ProgressBar(config: progressConfig) defer { progress.finish() diff --git a/Sources/CLI/Registry/Login.swift b/Sources/CLI/Registry/Login.swift index 617bc801e..7de7fe7e4 100644 --- a/Sources/CLI/Registry/Login.swift +++ b/Sources/CLI/Registry/Login.swift @@ -63,10 +63,18 @@ extension Application { let server = Reference.resolveDomain(domain: server) let scheme = try RequestScheme(registry.scheme).schemeFor(host: server) + let _url = "\(scheme)://\(server)" + guard let url = URL(string: _url) else { + throw ContainerizationError(.invalidArgument, message: "Cannot convert \(_url) to URL") + } + guard let host = url.host else { + throw ContainerizationError(.invalidArgument, message: "Invalid host \(server)") + } let client = RegistryClient( - host: server, + host: host, scheme: scheme.rawValue, + port: url.port, authentication: BasicAuthentication(username: username, password: password), retryOptions: .init( maxRetries: 10, diff --git a/Sources/CLI/RunCommand.swift b/Sources/CLI/RunCommand.swift index 22231801e..3a818e939 100644 --- a/Sources/CLI/RunCommand.swift +++ b/Sources/CLI/RunCommand.swift @@ -18,6 +18,7 @@ import ArgumentParser import ContainerClient import Containerization import ContainerizationError +import ContainerizationExtras import ContainerizationOS import Foundation import NIOCore @@ -45,6 +46,9 @@ extension Application { @OptionGroup var global: Flags.Global + @OptionGroup + var progressFlags: Flags.Progress + @Argument(help: "Image name") var image: String @@ -56,8 +60,8 @@ extension Application { let id = Utility.createContainerID(name: self.managementFlags.name) var progressConfig: ProgressConfig - if managementFlags.disableProgressUpdates { - progressConfig = try ProgressConfig(disableProgressUpdates: managementFlags.disableProgressUpdates) + if progressFlags.disableProgressUpdates { + progressConfig = try ProgressConfig(disableProgressUpdates: progressFlags.disableProgressUpdates) } else { progressConfig = try ProgressConfig( showTasks: true, @@ -166,6 +170,13 @@ struct ProcessIO { let stdin: Pipe? let stdout: Pipe? let stderr: Pipe? + var ioTracker: IoTracker? + + struct IoTracker { + let stream: AsyncStream + let cont: AsyncStream.Continuation + let configuredStreams: Int + } let stdio: [FileHandle?] @@ -221,7 +232,11 @@ struct ProcessIO { } return Pipe() }() + + var configuredStreams = 0 + let (stream, cc) = AsyncStream.makeStream() if let stdout { + configuredStreams += 1 let pout: FileHandle = { if let current { return current.handle @@ -234,6 +249,7 @@ struct ProcessIO { let data = handle.availableData if data.isEmpty { rout.readabilityHandler = nil + cc.yield() return } try! pout.write(contentsOf: data) @@ -248,12 +264,14 @@ struct ProcessIO { return Pipe() }() if let stderr { + configuredStreams += 1 let perr: FileHandle = .standardError let rerr = stderr.fileHandleForReading rerr.readabilityHandler = { handle in let data = handle.availableData if data.isEmpty { rerr.readabilityHandler = nil + cc.yield() return } try! perr.write(contentsOf: data) @@ -261,12 +279,39 @@ struct ProcessIO { stdio[2] = stderr.fileHandleForWriting } + var ioTracker: IoTracker? = nil + if configuredStreams > 0 { + ioTracker = .init(stream: stream, cont: cc, configuredStreams: configuredStreams) + } + return .init( stdin: stdin, stdout: stdout, stderr: stderr, + ioTracker: ioTracker, stdio: stdio, console: current ) } + + public func wait() async throws { + guard let ioTracker = self.ioTracker else { + return + } + do { + try await Timeout.run(seconds: 3) { + var counter = ioTracker.configuredStreams + for await _ in ioTracker.stream { + counter -= 1 + if counter == 0 { + ioTracker.cont.finish() + break + } + } + } + } catch { + log.error("Timeout waiting for IO to complete : \(error)") + throw error + } + } } diff --git a/Sources/CLI/System/SystemCommand.swift b/Sources/CLI/System/SystemCommand.swift index c884c8279..3a92bfb92 100644 --- a/Sources/CLI/System/SystemCommand.swift +++ b/Sources/CLI/System/SystemCommand.swift @@ -24,7 +24,6 @@ extension Application { subcommands: [ SystemDNS.self, SystemLogs.self, - SystemRestart.self, SystemStart.self, SystemStop.self, SystemStatus.self, diff --git a/Sources/CLI/System/SystemRestart.swift b/Sources/CLI/System/SystemRestart.swift deleted file mode 100644 index 91307be11..000000000 --- a/Sources/CLI/System/SystemRestart.swift +++ /dev/null @@ -1,51 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -//===----------------------------------------------------------------------===// - -import ArgumentParser -import ContainerClient -import ContainerPlugin -import ContainerizationError -import Foundation -import Logging - -extension Application { - struct SystemRestart: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "restart", - abstract: "Restart API server for `container`" - ) - - @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") - var prefix: String = "com.apple.container." - - func run() async throws { - let launchdDomainString = try ServiceManager.getDomainString() - let fullLabel = "\(launchdDomainString)/\(prefix)apiserver" - try ServiceManager.kickstart(fullServiceLabel: fullLabel) - // Now ping our friendly daemon. Fail after 10 seconds with no response. - do { - print("Verifying apiserver is running...") - try await ClientHealthCheck.ping(timeout: .seconds(10)) - print("Done") - } catch { - throw ContainerizationError( - .internalError, - message: "failed to get a response from apiserver after 10 seconds: \(error)" - ) - } - } - } -} diff --git a/Sources/CVersion/Version.c b/Sources/CVersion/Version.c index 38722b5f7..e579378d6 100644 --- a/Sources/CVersion/Version.c +++ b/Sources/CVersion/Version.c @@ -42,3 +42,7 @@ const char* get_release_version() { const char* get_swift_containerization_version() { return CZ_VERSION; } + +const char* get_container_builder_shim_version() { + return BUILDER_SHIM_VERSION; +} diff --git a/Sources/CVersion/include/Version.h b/Sources/CVersion/include/Version.h index 031c209f4..c5b17d194 100644 --- a/Sources/CVersion/include/Version.h +++ b/Sources/CVersion/include/Version.h @@ -40,8 +40,14 @@ #define RELEASE_VERSION "0.0.0" #endif +#ifndef BUILDER_SHIM_VERSION +#define BUILDER_SHIM_VERSION "0.0.0" +#endif + const char* get_git_commit(); const char* get_release_version(); const char* get_swift_containerization_version(); + +const char* get_container_builder_shim_version(); diff --git a/Sources/ContainerBuild/BuildFSSync.swift b/Sources/ContainerBuild/BuildFSSync.swift index a41c629fc..9f4e91129 100644 --- a/Sources/ContainerBuild/BuildFSSync.swift +++ b/Sources/ContainerBuild/BuildFSSync.swift @@ -229,7 +229,7 @@ actor BuildFSSync: BuildPipelineHandler { pathInArchive: URL(fileURLWithPath: rel)) } - for await chunk in try tarURL.zeroCopyReader() { + for try await chunk in try tarURL.bufferedCopyReader() { let part = BuildTransfer( id: packet.id, source: tarURL.path, diff --git a/Sources/ContainerBuild/Builder.grpc.swift b/Sources/ContainerBuild/Builder.grpc.swift index eac5bfccf..33d5eee26 100644 --- a/Sources/ContainerBuild/Builder.grpc.swift +++ b/Sources/ContainerBuild/Builder.grpc.swift @@ -33,7 +33,7 @@ import SwiftProtobuf /// To perform a build: /// /// 1. CreateBuild to create a new build -/// 2. StartBuild to start the build exection where client and server +/// 2. StartBuild to start the build execution where client and server /// both have a stream for exchanging data during the build. /// /// The client may send: @@ -96,7 +96,7 @@ import SwiftProtobuf /// source path, and empty data /// 2. server archives the data at source path, and starts to send chunks to /// the client -/// 3. server coninues to send all chunks until last chunk, which server +/// 3. server continues to send all chunks until last chunk, which server /// will send with /// 'complete' set to true /// 4. client starts to receive the data and stream to a temporary file @@ -110,7 +110,7 @@ import SwiftProtobuf /// /// /// NOTE: the client should close the send stream once it has finished -/// receiving the build output or abadon the current build due to error. +/// receiving the build output or abandon the current build due to error. /// Server should keep the stream open until it receives the EOF that client /// has closed the stream, which the server should then close its send stream. /// @@ -263,7 +263,7 @@ public struct Com_Apple_Container_Build_V1_BuilderNIOClient: Com_Apple_Container /// To perform a build: /// /// 1. CreateBuild to create a new build -/// 2. StartBuild to start the build exection where client and server +/// 2. StartBuild to start the build execution where client and server /// both have a stream for exchanging data during the build. /// /// The client may send: @@ -326,7 +326,7 @@ public struct Com_Apple_Container_Build_V1_BuilderNIOClient: Com_Apple_Container /// source path, and empty data /// 2. server archives the data at source path, and starts to send chunks to /// the client -/// 3. server coninues to send all chunks until last chunk, which server +/// 3. server continues to send all chunks until last chunk, which server /// will send with /// 'complete' set to true /// 4. client starts to receive the data and stream to a temporary file @@ -340,7 +340,7 @@ public struct Com_Apple_Container_Build_V1_BuilderNIOClient: Com_Apple_Container /// /// /// NOTE: the client should close the send stream once it has finished -/// receiving the build output or abadon the current build due to error. +/// receiving the build output or abandon the current build due to error. /// Server should keep the stream open until it receives the EOF that client /// has closed the stream, which the server should then close its send stream. @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) @@ -526,7 +526,7 @@ public enum Com_Apple_Container_Build_V1_BuilderClientMetadata { /// To perform a build: /// /// 1. CreateBuild to create a new build -/// 2. StartBuild to start the build exection where client and server +/// 2. StartBuild to start the build execution where client and server /// both have a stream for exchanging data during the build. /// /// The client may send: @@ -589,7 +589,7 @@ public enum Com_Apple_Container_Build_V1_BuilderClientMetadata { /// source path, and empty data /// 2. server archives the data at source path, and starts to send chunks to /// the client -/// 3. server coninues to send all chunks until last chunk, which server +/// 3. server continues to send all chunks until last chunk, which server /// will send with /// 'complete' set to true /// 4. client starts to receive the data and stream to a temporary file @@ -603,7 +603,7 @@ public enum Com_Apple_Container_Build_V1_BuilderClientMetadata { /// /// /// NOTE: the client should close the send stream once it has finished -/// receiving the build output or abadon the current build due to error. +/// receiving the build output or abandon the current build due to error. /// Server should keep the stream open until it receives the EOF that client /// has closed the stream, which the server should then close its send stream. /// @@ -673,7 +673,7 @@ extension Com_Apple_Container_Build_V1_BuilderProvider { /// To perform a build: /// /// 1. CreateBuild to create a new build -/// 2. StartBuild to start the build exection where client and server +/// 2. StartBuild to start the build execution where client and server /// both have a stream for exchanging data during the build. /// /// The client may send: @@ -736,7 +736,7 @@ extension Com_Apple_Container_Build_V1_BuilderProvider { /// source path, and empty data /// 2. server archives the data at source path, and starts to send chunks to /// the client -/// 3. server coninues to send all chunks until last chunk, which server +/// 3. server continues to send all chunks until last chunk, which server /// will send with /// 'complete' set to true /// 4. client starts to receive the data and stream to a temporary file @@ -750,7 +750,7 @@ extension Com_Apple_Container_Build_V1_BuilderProvider { /// /// /// NOTE: the client should close the send stream once it has finished -/// receiving the build output or abadon the current build due to error. +/// receiving the build output or abandon the current build due to error. /// Server should keep the stream open until it receives the EOF that client /// has closed the stream, which the server should then close its send stream. /// diff --git a/Sources/ContainerBuild/URL+Extensions.swift b/Sources/ContainerBuild/URL+Extensions.swift index 75b736de8..a00b13ef6 100644 --- a/Sources/ContainerBuild/URL+Extensions.swift +++ b/Sources/ContainerBuild/URL+Extensions.swift @@ -138,4 +138,147 @@ extension URL { } } } + + func bufferedCopyReader(chunkSize: Int = 4 * 1024 * 1024) throws -> BufferedCopyReader { + try BufferedCopyReader(url: self, chunkSize: chunkSize) + } +} + +/// A synchronous buffered reader that reads one chunk at a time from a file +/// Uses a configurable buffer size (default 4MB) and only reads when nextChunk() is called +/// Implements AsyncSequence for use with `for await` loops +public final class BufferedCopyReader: AsyncSequence { + public typealias Element = Data + public typealias AsyncIterator = BufferedCopyReaderIterator + + private let inputStream: InputStream + private let chunkSize: Int + private var isFinished: Bool = false + private let reusableBuffer: UnsafeMutablePointer + + /// Initialize a buffered copy reader for the given URL + /// - Parameters: + /// - url: The file URL to read from + /// - chunkSize: Size of each chunk to read (default: 4MB) + public init(url: URL, chunkSize: Int = 4 * 1024 * 1024) throws { + guard let stream = InputStream(url: url) else { + throw CocoaError(.fileReadNoSuchFile) + } + self.inputStream = stream + self.chunkSize = chunkSize + self.reusableBuffer = UnsafeMutablePointer.allocate(capacity: chunkSize) + self.inputStream.open() + } + + deinit { + inputStream.close() + reusableBuffer.deallocate() + } + + /// Create an async iterator for this sequence + public func makeAsyncIterator() -> BufferedCopyReaderIterator { + BufferedCopyReaderIterator(reader: self) + } + + /// Read the next chunk of data from the file + /// - Returns: Data chunk, or nil if end of file reached + /// - Throws: Any file reading errors + public func nextChunk() throws -> Data? { + guard !isFinished else { return nil } + + // Read directly into our reusable buffer + let bytesRead = inputStream.read(reusableBuffer, maxLength: chunkSize) + + // Check for errors + if bytesRead < 0 { + if let error = inputStream.streamError { + throw error + } + throw CocoaError(.fileReadUnknown) + } + + // If we read no data, we've reached the end + if bytesRead == 0 { + isFinished = true + return nil + } + + // If we read less than the chunk size, this is the last chunk + if bytesRead < chunkSize { + isFinished = true + } + + // Create Data object only with the bytes actually read + return Data(bytes: reusableBuffer, count: bytesRead) + } + + /// Check if the reader has finished reading the file + public var hasFinished: Bool { + isFinished + } + + /// Reset the reader to the beginning of the file + /// Note: InputStream doesn't support seeking, so this recreates the stream + /// - Throws: Any file opening errors + public func reset() throws { + inputStream.close() + // Note: InputStream doesn't provide a way to get the original URL, + // so reset functionality is limited. Consider removing this method + // or storing the original URL if reset is needed. + throw CocoaError( + .fileReadUnsupportedScheme, + userInfo: [ + NSLocalizedDescriptionKey: "Reset not supported with InputStream-based implementation" + ]) + } + + /// Get the current file offset + /// Note: InputStream doesn't provide offset information + /// - Returns: Current position in the file + /// - Throws: Unsupported operation error + public func currentOffset() throws -> UInt64 { + throw CocoaError( + .fileReadUnsupportedScheme, + userInfo: [ + NSLocalizedDescriptionKey: "Offset tracking not supported with InputStream-based implementation" + ]) + } + + /// Seek to a specific offset in the file + /// Note: InputStream doesn't support seeking + /// - Parameter offset: The byte offset to seek to + /// - Throws: Unsupported operation error + public func seek(to offset: UInt64) throws { + throw CocoaError( + .fileReadUnsupportedScheme, + userInfo: [ + NSLocalizedDescriptionKey: "Seeking not supported with InputStream-based implementation" + ]) + } + + /// Close the input stream explicitly (called automatically in deinit) + public func close() { + inputStream.close() + isFinished = true + } +} + +/// AsyncIteratorProtocol implementation for BufferedCopyReader +public struct BufferedCopyReaderIterator: AsyncIteratorProtocol { + public typealias Element = Data + + private let reader: BufferedCopyReader + + init(reader: BufferedCopyReader) { + self.reader = reader + } + + /// Get the next chunk of data asynchronously + /// - Returns: Next data chunk, or nil when finished + /// - Throws: Any file reading errors + public mutating func next() async throws -> Data? { + // Yield control to allow other tasks to run, then read synchronously + await Task.yield() + return try reader.nextChunk() + } } diff --git a/Sources/ContainerClient/Core/ClientDefaults.swift b/Sources/ContainerClient/Core/ClientDefaults.swift index 8d9a0bcb7..369c295e2 100644 --- a/Sources/ContainerClient/Core/ClientDefaults.swift +++ b/Sources/ContainerClient/Core/ClientDefaults.swift @@ -63,7 +63,8 @@ extension ClientDefaults.Keys { case .defaultKernelBinaryPath: return "opt/kata/share/kata-containers/vmlinux-6.12.28-153" case .defaultBuilderImage: - return "ghcr.io/apple/container-builder-shim/builder:0.1.0" + let tag = String(cString: get_container_builder_shim_version()) + return "ghcr.io/apple/container-builder-shim/builder:\(tag)" case .defaultDNSDomain: return "test" case .defaultRegistryDomain: diff --git a/Sources/ContainerClient/Core/ClientImage.swift b/Sources/ContainerClient/Core/ClientImage.swift index 684f302c3..386bc118a 100644 --- a/Sources/ContainerClient/Core/ClientImage.swift +++ b/Sources/ContainerClient/Core/ClientImage.swift @@ -99,7 +99,7 @@ extension ClientImage { public static func normalizeReference(_ ref: String) throws -> String { guard ref != Self.initImageRef else { - // Don't modify the the default init image reference. + // Don't modify the default init image reference. // This is to allow for easier local development against // an updated containerization. return ref diff --git a/Sources/ContainerClient/Core/ContainerConfiguration.swift b/Sources/ContainerClient/Core/ContainerConfiguration.swift index 86d823281..bb73d8f4c 100644 --- a/Sources/ContainerClient/Core/ContainerConfiguration.swift +++ b/Sources/ContainerClient/Core/ContainerConfiguration.swift @@ -23,6 +23,8 @@ public struct ContainerConfiguration: Sendable, Codable { public var image: ImageDescription /// External mounts to add to the container. public var mounts: [Filesystem] = [] + /// Sockets to publish from container to host. + public var publishedSockets: [PublishSocket] = [] /// Key/Value labels for the container. public var labels: [String: String] = [:] /// System controls for the container. @@ -44,6 +46,44 @@ public struct ContainerConfiguration: Sendable, Codable { /// Name of the runtime that supports the container public var runtimeHandler: String = "container-runtime-linux" + enum CodingKeys: String, CodingKey { + case id + case image + case mounts + case publishedSockets + case labels + case sysctls + case networks + case dns + case rosetta + case hostname + case initProcess + case platform + case resources + case runtimeHandler + } + + /// Create a configuration from the supplied Decoder, initializing missing + /// values where possible to reasonable defaults. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(String.self, forKey: .id) + image = try container.decode(ImageDescription.self, forKey: .image) + mounts = try container.decodeIfPresent([Filesystem].self, forKey: .mounts) ?? [] + publishedSockets = try container.decodeIfPresent([PublishSocket].self, forKey: .publishedSockets) ?? [] + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:] + sysctls = try container.decodeIfPresent([String: String].self, forKey: .sysctls) ?? [:] + networks = try container.decodeIfPresent([String].self, forKey: .networks) ?? [] + dns = try container.decodeIfPresent(DNSConfiguration.self, forKey: .dns) + rosetta = try container.decodeIfPresent(Bool.self, forKey: .rosetta) ?? false + hostname = try container.decodeIfPresent(String.self, forKey: .hostname) + initProcess = try container.decode(ProcessConfiguration.self, forKey: .initProcess) + platform = try container.decodeIfPresent(ContainerizationOCI.Platform.self, forKey: .platform) ?? .current + resources = try container.decodeIfPresent(Resources.self, forKey: .resources) ?? .init() + runtimeHandler = try container.decodeIfPresent(String.self, forKey: .runtimeHandler) ?? "container-runtime-linux" + } + public struct DNSConfiguration: Sendable, Codable { public static let defaultNameservers = ["1.1.1.1"] diff --git a/Sources/ContainerClient/Core/PublishSocket.swift b/Sources/ContainerClient/Core/PublishSocket.swift new file mode 100644 index 000000000..cd6fcf148 --- /dev/null +++ b/Sources/ContainerClient/Core/PublishSocket.swift @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import SystemPackage + +/// Represents a socket that should be published from container to host. +public struct PublishSocket: Sendable, Codable { + /// The path to the socket in the container. + public var containerPath: URL + + /// The path where the socket should appear on the host. + public var hostPath: URL + + /// File permissions for the socket on the host. + public var permissions: FilePermissions? + + public init( + containerPath: URL, + hostPath: URL, + permissions: FilePermissions? = nil + ) { + self.containerPath = containerPath + self.hostPath = hostPath + self.permissions = permissions + } +} diff --git a/Sources/ContainerClient/Core/RuntimeStatus.swift b/Sources/ContainerClient/Core/RuntimeStatus.swift index 0d42496e4..12287dc92 100644 --- a/Sources/ContainerClient/Core/RuntimeStatus.swift +++ b/Sources/ContainerClient/Core/RuntimeStatus.swift @@ -24,4 +24,6 @@ public enum RuntimeStatus: String, CaseIterable, Sendable, Codable { case stopped /// The object is currently running. case running + /// The object is currently stopping. + case stopping } diff --git a/Sources/ContainerClient/Flags.swift b/Sources/ContainerClient/Flags.swift index 12d5bf1c9..d0924e5ac 100644 --- a/Sources/ContainerClient/Flags.swift +++ b/Sources/ContainerClient/Flags.swift @@ -76,7 +76,7 @@ public struct Flags { self.scheme = scheme } - @Option(help: "Scheme to use when conntecting to the container registry. One of (http, https, auto)") + @Option(help: "Scheme to use when connecting to the container registry. One of (http, https, auto)") public var scheme: String = "auto" } @@ -92,6 +92,9 @@ public struct Flags { @Option(name: .customLong("mount"), help: "Add a mount to the container (type=<>,source=<>,target=<>,readonly)") public var mounts: [String] = [] + @Option(name: .customLong("publish-socket"), help: "Publish a socket from container to host (format: host_path:container_path)") + public var publishSockets: [String] = [] + @Option(name: .customLong("tmpfs"), help: "Add a tmpfs mount to the container at the given path") public var tmpFs: [String] = [] @@ -138,6 +141,14 @@ public struct Flags { @Option(name: [.customLong("label"), .customShort("l")], help: "Add a key=value label to the container") public var labels: [String] = [] + } + + public struct Progress: ParsableArguments { + public init() {} + + public init(disableProgressUpdates: Bool) { + self.disableProgressUpdates = disableProgressUpdates + } @Flag(name: .customLong("disable-progress-updates"), help: "Disable progress bar updates") public var disableProgressUpdates = false diff --git a/Sources/ContainerClient/Parser.swift b/Sources/ContainerClient/Parser.swift index 65286b651..b79fdad56 100644 --- a/Sources/ContainerClient/Parser.swift +++ b/Sources/ContainerClient/Parser.swift @@ -396,4 +396,94 @@ public struct Parser { throw ContainerizationError(.invalidArgument, message: "mount destination cannot be empty") } } + + // Parse --publish-socket arguments into PublishSocket objects + // Format: "host_path:container_path" (e.g., "/tmp/docker.sock:/var/run/docker.sock") + // + // - Parameter rawPublishSockets: Array of socket specifications + // - Returns: Array of PublishSocket objects + // - Throws: ContainerizationError if parsing fails + static func publishSockets(_ rawPublishSockets: [String]) throws -> [PublishSocket] { + var sockets: [PublishSocket] = [] + + // Process each raw socket string + for socket in rawPublishSockets { + let parsedSocket = try Parser.publishSocket(socket) + sockets.append(parsedSocket) + } + return sockets + } + + // Parse a single --publish-socket argument and validate paths + // Format: "host_path:container_path" -> PublishSocket + private static func publishSocket(_ socket: String) throws -> PublishSocket { + // Split by colon to two parts: [host_path, container_path] + let parts = socket.split(separator: ":") + + switch parts.count { + case 2: + // Extract host and container paths + let hostPath = String(parts[0]) + let containerPath = String(parts[1]) + + // Validate paths are not empty + if hostPath.isEmpty { + throw ContainerizationError( + .invalidArgument, message: "host socket path cannot be empty") + } + if containerPath.isEmpty { + throw ContainerizationError( + .invalidArgument, message: "container socket path cannot be empty") + } + + // Ensure container path must start with / + if !containerPath.hasPrefix("/") { + throw ContainerizationError( + .invalidArgument, + message: "container socket path must be absolute: \(containerPath)") + } + + // Convert host path to absolute path for consistency + let hostURL = URL(fileURLWithPath: hostPath) + let absoluteHostPath = hostURL.absoluteURL.path + + // Check if host socket already exists and might be in use + if FileManager.default.fileExists(atPath: absoluteHostPath) { + do { + let attrs = try FileManager.default.attributesOfItem(atPath: absoluteHostPath) + if let fileType = attrs[.type] as? FileAttributeType, fileType == .typeSocket { + throw ContainerizationError( + .invalidArgument, + message: "host socket \(absoluteHostPath) already exists and may be in use") + } + // If it exists but is not a socket, we can remove it and create socket + try FileManager.default.removeItem(atPath: absoluteHostPath) + } catch let error as ContainerizationError { + throw error + } catch { + // For other file system errors, continue with creation + } + } + + // Create host directory if it doesn't exist + let hostDir = hostURL.deletingLastPathComponent() + if !FileManager.default.fileExists(atPath: hostDir.path) { + try FileManager.default.createDirectory( + at: hostDir, withIntermediateDirectories: true) + } + + // Create and return PublishSocket object with validated paths + return PublishSocket( + containerPath: URL(fileURLWithPath: containerPath), + hostPath: URL(fileURLWithPath: absoluteHostPath), + permissions: nil + ) + + default: + throw ContainerizationError( + .invalidArgument, + message: + "invalid publish-socket format \(socket). Expected: host_path:container_path") + } + } } diff --git a/Sources/ContainerClient/Utility.swift b/Sources/ContainerClient/Utility.swift index 1b8bfc525..91158e88d 100644 --- a/Sources/ContainerClient/Utility.swift +++ b/Sources/ContainerClient/Utility.swift @@ -182,6 +182,10 @@ public struct Utility { config.labels = try Parser.labels(management.labels) + // Parse --publish-socket arguments and add to container configuration + // to enable socket forwarding from container to host. + config.publishedSockets = try Parser.publishSockets(management.publishSockets) + return (config, kernel) } diff --git a/Sources/ContainerClient/XPC+.swift b/Sources/ContainerClient/XPC+.swift index cbf977d4d..183a239f1 100644 --- a/Sources/ContainerClient/XPC+.swift +++ b/Sources/ContainerClient/XPC+.swift @@ -36,7 +36,7 @@ public enum XPCKeys: String { case port /// Exit code for a process case exitCode - /// An event that occured in a container + /// An event that occurred in a container case containerEvent /// Error key. case error diff --git a/Sources/ContainerPersistence/EntityStore.swift b/Sources/ContainerPersistence/EntityStore.swift index ab1b5ff12..7a09665ff 100644 --- a/Sources/ContainerPersistence/EntityStore.swift +++ b/Sources/ContainerPersistence/EntityStore.swift @@ -101,12 +101,13 @@ public actor FilesystemEntityStore: EntityStore where T: Codable & Identifiab private static func load(path: URL, log: Logger) throws -> Index { let directories = try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil) var index: FilesystemEntityStore.Index = Index() + let decoder = JSONDecoder() for entityUrl in directories { do { let metadataUrl = entityUrl.appendingPathComponent(metadataFilename) let data = try Data(contentsOf: metadataUrl) - let entity = try JSONDecoder().decode(T.self, from: data) + let entity = try decoder.decode(T.self, from: data) index[entity.id] = entity } catch { log.warning( diff --git a/Sources/ContainerPlugin/PluginLoader.swift b/Sources/ContainerPlugin/PluginLoader.swift index 6a8b97b0d..db86036a8 100644 --- a/Sources/ContainerPlugin/PluginLoader.swift +++ b/Sources/ContainerPlugin/PluginLoader.swift @@ -169,7 +169,7 @@ extension PluginLoader { instanceId: String? = nil ) throws { // We only care about loading plugins that have a service - // to expose, otherwise they may just be CLI commands. + // to expose; otherwise, they may just be CLI commands. guard let serviceConfig = plugin.config.servicesConfig else { return } @@ -201,7 +201,7 @@ extension PluginLoader { public func deregisterWithLaunchd(plugin: Plugin, instanceId: String? = nil) throws { // We only care about loading plugins that have a service - // to expose, otherwise they may just be CLI commands. + // to expose; otherwise, they may just be CLI commands. guard plugin.config.servicesConfig != nil else { return } diff --git a/Sources/ContainerPlugin/ServiceManager.swift b/Sources/ContainerPlugin/ServiceManager.swift index d7a936f3c..73071fa72 100644 --- a/Sources/ContainerPlugin/ServiceManager.swift +++ b/Sources/ContainerPlugin/ServiceManager.swift @@ -60,23 +60,24 @@ public struct ServiceManager { launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl") launchctl.arguments = ["list"] - let null = FileHandle.nullDevice let stdoutPipe = Pipe() + let stderrPipe = Pipe() launchctl.standardOutput = stdoutPipe - launchctl.standardError = null + launchctl.standardError = stderrPipe try launchctl.run() let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() launchctl.waitUntilExit() let status = launchctl.terminationStatus guard status == 0 else { - // TODO: review error handling - return [] + throw ContainerizationError( + .internalError, message: "Command `launchctl list` failed with status \(status). Message: \(String(data: stderrData, encoding: .utf8) ?? "No error message")") } guard let outputText = String(data: outputData, encoding: .utf8) else { - // TODO: review error handling - return [] + throw ContainerizationError( + .internalError, message: "Could not decode output of command `launchctl list`. Message: \(String(data: stderrData, encoding: .utf8) ?? "No error message")") } // The third field of each line of launchctl list output is the label diff --git a/Sources/ContainerXPC/XPCClient.swift b/Sources/ContainerXPC/XPCClient.swift index 16bea4682..dbe07bce4 100644 --- a/Sources/ContainerXPC/XPCClient.swift +++ b/Sources/ContainerXPC/XPCClient.swift @@ -19,7 +19,6 @@ import ContainerizationError import Foundation public struct XPCClient: Sendable { - // Access to `connection` is protected by a lock private nonisolated(unsafe) let connection: xpc_connection_t private let q: DispatchQueue? private let service: String diff --git a/Sources/ContainerXPC/XPCMessage.swift b/Sources/ContainerXPC/XPCMessage.swift index 5b81cabdf..eb1f67373 100644 --- a/Sources/ContainerXPC/XPCMessage.swift +++ b/Sources/ContainerXPC/XPCMessage.swift @@ -18,7 +18,7 @@ import ContainerizationError import Foundation -/// A message that can be pass across application boundries via XPC. +/// A message that can be pass across application boundaries via XPC. public struct XPCMessage: Sendable { /// Defined message key storing the route value. public static let routeKey = "com.apple.container.xpc.route" diff --git a/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift b/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift index f6a00bdef..467f1c6d0 100644 --- a/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift +++ b/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift @@ -25,7 +25,7 @@ import vmnet #if !CURRENT_SDK /// Interface strategy for containers that use macOS's custom network feature. -@available(macOS 16, *) +@available(macOS 26, *) struct NonisolatedInterfaceStrategy: InterfaceStrategy { private let log: Logger diff --git a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift index eeefde6be..6bd7a1602 100644 --- a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift +++ b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift @@ -64,7 +64,7 @@ struct RuntimeLinuxHelper: AsyncParsableCommand { log.info("configuring XPC server") let interfaceStrategy: any InterfaceStrategy #if !CURRENT_SDK - if #available(macOS 16, *) { + if #available(macOS 26, *) { interfaceStrategy = NonisolatedInterfaceStrategy(log: log) } else { interfaceStrategy = IsolatedInterfaceStrategy() diff --git a/Sources/Services/ContainerNetworkService/AllocationOnlyVmnetNetwork.swift b/Sources/Services/ContainerNetworkService/AllocationOnlyVmnetNetwork.swift index 0e74e3545..d7269413c 100644 --- a/Sources/Services/ContainerNetworkService/AllocationOnlyVmnetNetwork.swift +++ b/Sources/Services/ContainerNetworkService/AllocationOnlyVmnetNetwork.swift @@ -64,7 +64,7 @@ public actor AllocationOnlyVmnetNetwork: Network { ] ) - if let suite = UserDefaults.init(suiteName: "com.apple.container.defaults") { + if let suite = UserDefaults.init(suiteName: UserDefaults.appSuiteName) { // TODO: Make the suiteName a constant defined in ClientDefaults and use that. // This will need some re-working of dependencies between NetworkService and Client defaultSubnet = suite.string(forKey: "network.subnet") ?? defaultSubnet diff --git a/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift b/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift index 45e0afe1c..4d2bee886 100644 --- a/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift +++ b/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift @@ -22,9 +22,9 @@ actor AttachmentAllocator { private var hostnames: [String: UInt32] = [:] init(lower: UInt32, size: Int) throws { - allocator = try UInt32.allocator( + allocator = try UInt32.rotatingAllocator( lower: lower, - size: size + size: UInt32(size) ) } diff --git a/Sources/Services/ContainerNetworkService/UserDefaults+Backpack.swift b/Sources/Services/ContainerNetworkService/UserDefaults+Container.swift similarity index 100% rename from Sources/Services/ContainerNetworkService/UserDefaults+Backpack.swift rename to Sources/Services/ContainerNetworkService/UserDefaults+Container.swift diff --git a/Sources/Services/ContainerSandboxService/SandboxService.swift b/Sources/Services/ContainerSandboxService/SandboxService.swift index 9d89ca380..3e63fe34d 100644 --- a/Sources/Services/ContainerSandboxService/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/SandboxService.swift @@ -93,7 +93,7 @@ public actor SandboxService { let fqdn: String if let hostname = config.hostname { - if let suite = UserDefaults.init(suiteName: "com.apple.container.defaults"), + if let suite = UserDefaults.init(suiteName: UserDefaults.appSuiteName), let dnsDomain = suite.string(forKey: "dns.domain"), !hostname.contains(".") { @@ -154,90 +154,113 @@ public actor SandboxService { @Sendable public func startProcess(_ message: XPCMessage) async throws -> XPCMessage { self.log.info("`start` xpc handler") - return try await self.lock.withLock { _ in + return try await self.lock.withLock { lock in let id = try message.id() let stdio = message.stdio() let containerInfo = try await self.getContainer() let containerId = containerInfo.container.id - let container = containerInfo.container - let bundle = containerInfo.bundle if id == containerId { - guard await self.state == .booted else { - throw ContainerizationError( - .invalidState, - message: "container expected to be in booted state, got: \(await self.state)" - ) - } - let containerLog = try FileHandle(forWritingTo: bundle.containerLog) - let config = containerInfo.config - let stdout = { - if let h = stdio[1] { - return MultiWriter(handles: [h, containerLog]) - } - return MultiWriter(handles: [containerLog]) - }() - let stderr: MultiWriter? = { - if !config.initProcess.terminal { - if let h = stdio[2] { - return MultiWriter(handles: [h, containerLog]) - } - return MultiWriter(handles: [containerLog]) - } - return nil - }() - if let h = stdio[0] { - container.stdin = h - } - container.stdout = stdout - if let stderr { - container.stderr = stderr - } - await self.setState(.starting) - do { - try await container.start() - let waitFunc: ExitMonitor.WaitHandler = { - let code = try await container.wait() - return code - } - try await self.monitor.track(id: id, waitingOn: waitFunc) - } catch { - try? await self.cleanupContainer() - await self.setState(.created) - try await self.sendContainerEvent(.containerExit(id: id, exitCode: -1)) - throw error - } + try await self.startInitProcess(stdio: stdio, lock: lock) await self.setState(.running) try await self.sendContainerEvent(.containerStart(id: id)) } else { - // we are starting a process other than the init process. Check if it exists - guard let processInfo = await self.processes[id] else { - throw ContainerizationError(.notFound, message: "Process with id \(id)") + try await self.startExecProcess(processId: id, stdio: stdio, lock: lock) + } + return message.reply() + } + } + + private func startInitProcess(stdio: [FileHandle?], lock: AsyncLock.Context) async throws { + let info = try self.getContainer() + let container = info.container + let bundle = info.bundle + let id = container.id + guard self.state == .booted else { + throw ContainerizationError( + .invalidState, + message: "container expected to be in booted state, got: \(self.state)" + ) + } + let containerLog = try FileHandle(forWritingTo: bundle.containerLog) + let config = info.config + let stdout = { + if let h = stdio[1] { + return MultiWriter(handles: [h, containerLog]) + } + return MultiWriter(handles: [containerLog]) + }() + let stderr: MultiWriter? = { + if !config.initProcess.terminal { + if let h = stdio[2] { + return MultiWriter(handles: [h, containerLog]) } - let ociConfig = self.configureProcessConfig(config: processInfo.config) - let stdin: ReaderStream? = { - if let h = stdio[0] { - return h - } - return nil - }() - let process = try await container.exec( - id, - configuration: ociConfig, - stdin: stdin, - stdout: stdio[1], - stderr: stdio[2] - ) - try await self.setUnderlingProcess(id, process) - try await process.start() - let waitFunc: ExitMonitor.WaitHandler = { - try await process.wait() + return MultiWriter(handles: [containerLog]) + } + return nil + }() + if let h = stdio[0] { + container.stdin = h + } + container.stdout = stdout + if let stderr { + container.stderr = stderr + } + self.setState(.starting) + do { + try await container.start() + let waitFunc: ExitMonitor.WaitHandler = { + let code = try await container.wait() + if let out = stdio[1] { + try self.closeHandle(out.fileDescriptor) + } + if let err = stdio[2] { + try self.closeHandle(err.fileDescriptor) } - try await self.monitor.track(id: id, waitingOn: waitFunc) + return code } - return message.reply() + try await self.monitor.track(id: id, waitingOn: waitFunc) + } catch { + try? await self.cleanupContainer() + self.setState(.created) + try await self.sendContainerEvent(.containerExit(id: id, exitCode: -1)) + throw error } } + private func startExecProcess(processId id: String, stdio: [FileHandle?], lock: AsyncLock.Context) async throws { + let container = try self.getContainer().container + guard let processInfo = self.processes[id] else { + throw ContainerizationError(.notFound, message: "Process with id \(id)") + } + let ociConfig = self.configureProcessConfig(config: processInfo.config) + let stdin: ReaderStream? = { + if let h = stdio[0] { + return h + } + return nil + }() + let process = try await container.exec( + id, + configuration: ociConfig, + stdin: stdin, + stdout: stdio[1], + stderr: stdio[2] + ) + try self.setUnderlyingProcess(id, process) + try await process.start() + let waitFunc: ExitMonitor.WaitHandler = { + let code = try await process.wait() + if let out = stdio[1] { + try self.closeHandle(out.fileDescriptor) + } + if let err = stdio[2] { + try self.closeHandle(err.fileDescriptor) + } + return code + } + try await self.monitor.track(id: id, waitingOn: waitFunc) + } + /// Create a process inside the virtual machine for the container. /// /// Use this procedure to run ad hoc processes in the virtual @@ -267,13 +290,14 @@ public actor SandboxService { try await self.monitor.registerProcess( id: id, onExit: { id, code in - guard await self.processes[id] != nil else { + guard let process = await self.processes[id]?.process else { throw ContainerizationError(.invalidState, message: "ProcessInfo missing for process \(id)") } for cc in await self.waiters[id] ?? [] { cc.resume(returning: code) } await self.removeWaiters(for: id) + try await process.delete() try await self.setProcessState(id: id, state: .stopped(code)) }) return message.reply() @@ -297,8 +321,10 @@ public actor SandboxService { var cs: ContainerSnapshot? switch state { - case .created, .stopped(_), .starting, .booted, .stopping: + case .created, .stopped(_), .starting, .booted: status = .stopped + case .stopping: + status = .stopping case .running: let ctr = try getContainer() @@ -399,7 +425,7 @@ public actor SandboxService { return message.reply() } - // TODO: fix underying signal value to int64 + // TODO: fix underlying signal value to int64 try await ctr.container.kill(Int32(try message.signal())) return message.reply() } @@ -594,6 +620,16 @@ public actor SandboxService { } } + for publishedSocket in config.publishedSockets { + let socketConfig = UnixSocketConfiguration( + source: publishedSocket.containerPath, + destination: publishedSocket.hostPath, + permissions: publishedSocket.permissions, + direction: .outOf + ) + container.sockets.append(socketConfig) + } + container.hostname = config.hostname ?? config.id if let dns = config.dns { @@ -664,6 +700,15 @@ public actor SandboxService { return proc } + private nonisolated func closeHandle(_ handle: Int32) throws { + guard close(handle) == 0 else { + guard let errCode = POSIXErrorCode(rawValue: errno) else { + fatalError("failed to convert errno to POSIXErrorCode") + } + throw POSIXError(errCode) + } + } + private nonisolated func modifyingEnvironment(_ config: ProcessConfiguration) -> [String] { guard config.terminal else { return config.environment @@ -866,7 +911,7 @@ extension SandboxService { self.waiters[id] = [] } - private func setUnderlingProcess(_ id: String, _ process: LinuxProcess) throws { + private func setUnderlyingProcess(_ id: String, _ process: LinuxProcess) throws { guard var info = self.processes[id] else { throw ContainerizationError(.invalidState, message: "Process \(id) not found") } diff --git a/Sources/TerminalProgress/ProgressBar+Add.swift b/Sources/TerminalProgress/ProgressBar+Add.swift index 953b308dd..42d2e2d8a 100644 --- a/Sources/TerminalProgress/ProgressBar+Add.swift +++ b/Sources/TerminalProgress/ProgressBar+Add.swift @@ -61,27 +61,29 @@ extension ProgressBar { /// Performs a check to see if the progress bar should be finished. public func checkIfFinished() { - if let totalTasks = state.totalTasks { - // For tasks, we're showing the current task rather then the number of completed tasks. - guard state.tasks > totalTasks else { - return - } + var finished = true + var defined = false + if let totalTasks = state.totalTasks, totalTasks > 0 { + // For tasks, we're showing the current task rather than the number of completed tasks. + finished = finished && state.tasks == totalTasks + defined = true } - if let totalItems = state.totalItems { - guard state.items == totalItems else { - return - } + if let totalItems = state.totalItems, totalItems > 0 { + finished = finished && state.items == totalItems + defined = true } - if let totalSize = state.totalSize { - guard state.size == totalSize else { - return - } + if let totalSize = state.totalSize, totalSize > 0 { + finished = finished && state.size == totalSize + defined = true + } + if defined && finished { + finish() } - finish() } /// Sets the current tasks. - /// - Parameter tasks: The current tasks to set. + /// - Parameter newTasks: The current tasks to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. public func set(tasks newTasks: Int, render: Bool = true) { state.tasks = newTasks if render { @@ -91,14 +93,21 @@ extension ProgressBar { } /// Performs an addition to the current tasks. - /// - Parameter tasks: The tasks to add to the current tasks. - public func add(tasks toAdd: Int, render: Bool = true) { - let newTasks = state.tasks + toAdd - set(tasks: newTasks, render: render) + /// - Parameter delta: The tasks to add to the current tasks. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(tasks delta: Int, render: Bool = true) { + _state.withLock { + let newTasks = $0.tasks + delta + $0.tasks = newTasks + } + if render { + self.render() + } } /// Sets the total tasks. - /// - Parameter totalTasks: The total tasks to set. + /// - Parameter newTotalTasks: The total tasks to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. public func set(totalTasks newTotalTasks: Int, render: Bool = true) { state.totalTasks = newTotalTasks if render { @@ -107,15 +116,22 @@ extension ProgressBar { } /// Performs an addition to the total tasks. - /// - Parameter totalTasks: The tasks to add to the total tasks. - public func add(totalTasks toAdd: Int, render: Bool = true) { - let totalTasks = state.totalTasks ?? 0 - let newTotalTasks = totalTasks + toAdd - set(totalTasks: newTotalTasks, render: render) + /// - Parameter delta: The tasks to add to the total tasks. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(totalTasks delta: Int, render: Bool = true) { + _state.withLock { + let totalTasks = $0.totalTasks ?? 0 + let newTotalTasks = totalTasks + delta + $0.totalTasks = newTotalTasks + } + if render { + self.render() + } } /// Sets the items name. - /// - Parameter items: The current items to set. + /// - Parameter newItemsName: The current items to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. public func set(itemsName newItemsName: String, render: Bool = true) { state.itemsName = newItemsName if render { @@ -124,7 +140,7 @@ extension ProgressBar { } /// Sets the current items. - /// - Parameter items: The current items to set. + /// - Parameter newItems: The current items to set. public func set(items newItems: Int, render: Bool = true) { state.items = newItems if render { @@ -133,14 +149,21 @@ extension ProgressBar { } /// Performs an addition to the current items. - /// - Parameter items: The items to add to the current items. - public func add(items toAdd: Int, render: Bool = true) { - let newItems = state.items + toAdd - set(items: newItems, render: render) + /// - Parameter delta: The items to add to the current items. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(items delta: Int, render: Bool = true) { + _state.withLock { + let newItems = $0.items + delta + $0.items = newItems + } + if render { + self.render() + } } /// Sets the total items. - /// - Parameter totalItems: The total items to set. + /// - Parameter newTotalItems: The total items to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. public func set(totalItems newTotalItems: Int, render: Bool = true) { state.totalItems = newTotalItems if render { @@ -149,15 +172,22 @@ extension ProgressBar { } /// Performs an addition to the total items. - /// - Parameter totalItems: The items to add to the total items. - public func add(totalItems toAdd: Int, render: Bool = true) { - let totalItems = state.totalItems ?? 0 - let newTotalItems = totalItems + toAdd - set(totalItems: newTotalItems, render: render) + /// - Parameter delta: The items to add to the total items. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(totalItems delta: Int, render: Bool = true) { + _state.withLock { + let totalItems = $0.totalItems ?? 0 + let newTotalItems = totalItems + delta + $0.totalItems = newTotalItems + } + if render { + self.render() + } } /// Sets the current size. - /// - Parameter size: The current size to set. + /// - Parameter newSize: The current size to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. public func set(size newSize: Int64, render: Bool = true) { state.size = newSize if render { @@ -166,14 +196,21 @@ extension ProgressBar { } /// Performs an addition to the current size. - /// - Parameter size: The size to add to the current size. - public func add(size toAdd: Int64, render: Bool = true) { - let newSize = state.size + toAdd - set(size: newSize, render: render) + /// - Parameter delta: The size to add to the current size. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(size delta: Int64, render: Bool = true) { + _state.withLock { + let newSize = $0.size + delta + $0.size = newSize + } + if render { + self.render() + } } /// Sets the total size. - /// - Parameter totalSize: The total size to set. + /// - Parameter newTotalSize: The total size to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. public func set(totalSize newTotalSize: Int64, render: Bool = true) { state.totalSize = newTotalSize if render { @@ -182,10 +219,16 @@ extension ProgressBar { } /// Performs an addition to the total size. - /// - Parameter totalSize: The size to add to the total size. - public func add(totalSize toAdd: Int64, render: Bool = true) { - let totalSize = state.totalSize ?? 0 - let newTotalSize = totalSize + toAdd - set(totalSize: newTotalSize, render: render) + /// - Parameter delta: The size to add to the total size. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(totalSize delta: Int64, render: Bool = true) { + _state.withLock { + let totalSize = $0.totalSize ?? 0 + let newTotalSize = totalSize + delta + $0.totalSize = newTotalSize + } + if render { + self.render() + } } } diff --git a/Sources/TerminalProgress/ProgressBar+Terminal.swift b/Sources/TerminalProgress/ProgressBar+Terminal.swift index e7598f5df..ac6db87e9 100644 --- a/Sources/TerminalProgress/ProgressBar+Terminal.swift +++ b/Sources/TerminalProgress/ProgressBar+Terminal.swift @@ -26,8 +26,8 @@ enum EscapeSequence { extension ProgressBar { private var terminalWidth: Int { guard - let termimalHandle = term, - let terminal = try? Terminal(descriptor: termimalHandle.fileDescriptor) + let terminalHandle = term, + let terminal = try? Terminal(descriptor: terminalHandle.fileDescriptor) else { return 0 } diff --git a/Sources/TerminalProgress/ProgressBar.swift b/Sources/TerminalProgress/ProgressBar.swift index c28106915..5fc3b742b 100644 --- a/Sources/TerminalProgress/ProgressBar.swift +++ b/Sources/TerminalProgress/ProgressBar.swift @@ -20,8 +20,9 @@ import SendableProperty /// A progress bar that updates itself as tasks are completed. public final class ProgressBar: Sendable { let config: ProgressConfig + // `@SendableProperty` adds `_state: Synchronized`, which can be updated inside a lock using `_state.withLock()`. @SendableProperty - var state: State + var state = State() @SendableProperty var printedWidth = 0 let term: FileHandle? @@ -97,7 +98,7 @@ public final class ProgressBar: Sendable { printFullDescription() } - while !isFinished { + while !state.finished { let intervalNanoseconds = UInt64(intervalSeconds * 1_000_000_000) render() state.iteration += 1 @@ -117,11 +118,15 @@ public final class ProgressBar: Sendable { /// Finishes the progress bar. public func finish() { - guard !isFinished else { + guard !state.finished else { return } state.finished = true + + // The last render. + render(force: true) + if !config.disableProgressUpdates && !config.clearOnFinish { displayText(state.output, terminating: "\n") } @@ -143,8 +148,8 @@ extension ProgressBar { return timeDifferenceSeconds } - func render() { - guard term != nil && !config.disableProgressUpdates && !isFinished else { + func render(force: Bool = false) { + guard term != nil && !config.disableProgressUpdates && (force || !state.finished) else { return } let output = draw() @@ -154,8 +159,12 @@ extension ProgressBar { func draw() -> String { var components = [String]() if config.showSpinner && !config.showProgressBar { - let spinnerIcon = config.theme.getSpinnerIcon(state.iteration) - components.append("\(spinnerIcon)") + if !state.finished { + let spinnerIcon = config.theme.getSpinnerIcon(state.iteration) + components.append("\(spinnerIcon)") + } else { + components.append("\(config.theme.done)") + } } if config.showTasks, let totalTasks = state.totalTasks { @@ -176,13 +185,13 @@ extension ProgressBar { let total = state.totalSize ?? Int64(state.totalItems ?? 0) if config.showPercent && total > 0 && allowProgress { - components.append("\(state.percent)") + components.append("\(state.finished ? "100%" : state.percent)") } if config.showProgressBar, total > 0, allowProgress { let usedWidth = components.joined(separator: " ").count + 45 /* the maximum number of characters we may need */ - let remainingWidth = max(config.width - usedWidth, 1 /* the minumum width of a progress bar */) - let barLength = Int(Int64(remainingWidth) * value / total) + let remainingWidth = max(config.width - usedWidth, 1 /* the minimum width of a progress bar */) + let barLength = state.finished ? remainingWidth : Int(Int64(remainingWidth) * value / total) let barPaddingLength = remainingWidth - barLength let bar = "\(String(repeating: config.theme.bar, count: barLength))\(String(repeating: " ", count: barPaddingLength))" components.append("|\(bar)|") @@ -195,40 +204,56 @@ extension ProgressBar { if !state.itemsName.isEmpty { itemsName = " \(state.itemsName)" } - if let totalItems = state.totalItems { - additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)") + if state.finished { + if let totalItems = state.totalItems { + additionalComponents.append("\(totalItems.formattedNumber())\(itemsName)") + } } else { - additionalComponents.append("\(state.items.formattedNumber())\(itemsName)") + if let totalItems = state.totalItems { + additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)") + } else { + additionalComponents.append("\(state.items.formattedNumber())\(itemsName)") + } } } if state.size > 0 && allowProgress { - var formattedCombinedSize = "" - if config.showSize { - var formattedSize = state.size.formattedSize() - formattedSize = adjustFormattedSize(formattedSize) - if let totalSize = state.totalSize { - var formattedTotalSize = totalSize.formattedSize() - formattedTotalSize = adjustFormattedSize(formattedTotalSize) - formattedCombinedSize = combineSize(size: formattedSize, totalSize: formattedTotalSize) - } else { - formattedCombinedSize = formattedSize + if state.finished { + if config.showSize { + if let totalSize = state.totalSize { + var formattedTotalSize = totalSize.formattedSize() + formattedTotalSize = adjustFormattedSize(formattedTotalSize) + additionalComponents.append(formattedTotalSize) + } + } + } else { + var formattedCombinedSize = "" + if config.showSize { + var formattedSize = state.size.formattedSize() + formattedSize = adjustFormattedSize(formattedSize) + if let totalSize = state.totalSize { + var formattedTotalSize = totalSize.formattedSize() + formattedTotalSize = adjustFormattedSize(formattedTotalSize) + formattedCombinedSize = combineSize(size: formattedSize, totalSize: formattedTotalSize) + } else { + formattedCombinedSize = formattedSize + } } - } - var formattedSpeed = "" - if config.showSpeed { - formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)" - formattedSpeed = adjustFormattedSize(formattedSpeed) - } + var formattedSpeed = "" + if config.showSpeed { + formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)" + formattedSpeed = adjustFormattedSize(formattedSpeed) + } - if config.showSize && config.showSpeed { - additionalComponents.append(formattedCombinedSize) - additionalComponents.append(formattedSpeed) - } else if config.showSize { - additionalComponents.append(formattedCombinedSize) - } else if config.showSpeed { - additionalComponents.append(formattedSpeed) + if config.showSize && config.showSpeed { + additionalComponents.append(formattedCombinedSize) + additionalComponents.append(formattedSpeed) + } else if config.showSize { + additionalComponents.append(formattedCombinedSize) + } else if config.showSpeed { + additionalComponents.append(formattedSpeed) + } } } diff --git a/Sources/TerminalProgress/ProgressConfig.swift b/Sources/TerminalProgress/ProgressConfig.swift index 544604f11..353cc339a 100644 --- a/Sources/TerminalProgress/ProgressConfig.swift +++ b/Sources/TerminalProgress/ProgressConfig.swift @@ -27,7 +27,7 @@ public struct ProgressConfig: Sendable { /// The initial items name (e.g., "files"). let initialItemsName: String /// A flag indicating whether to show a spinner (e.g., "⠋"). - /// The spinner is hidden when when a progress bar is shown. + /// The spinner is hidden when a progress bar is shown. public let showSpinner: Bool /// A flag indicating whether to show tasks and total tasks (e.g., "[1]" or "[1/3]"). public let showTasks: Bool @@ -61,7 +61,7 @@ public struct ProgressConfig: Sendable { public let width: Int /// The theme of the progress bar. public let theme: ProgressTheme - /// The flag indicating whether to clear the progress bar before reseting the cursor. + /// The flag indicating whether to clear the progress bar before resetting the cursor. public let clearOnFinish: Bool /// The flag indicating whether to update the progress bar. public let disableProgressUpdates: Bool @@ -86,7 +86,7 @@ public struct ProgressConfig: Sendable { /// - totalSize: The initial total size of the progress bar. The default value is `nil`. /// - width: The width of the progress bar in characters. The default value is `120`. /// - theme: The theme of the progress bar. The default value is `nil`. - /// - clearOnFinish: The flag indicating whether to clear the progress bar before reseting the cursor. The default is `true`. + /// - clearOnFinish: The flag indicating whether to clear the progress bar before resetting the cursor. The default is `true`. /// - disableProgressUpdates: The flag indicating whether to update the progress bar. The default is `false`. public init( terminal: FileHandle = .standardError, diff --git a/Sources/TerminalProgress/ProgressTheme.swift b/Sources/TerminalProgress/ProgressTheme.swift index fc537d405..96577261f 100644 --- a/Sources/TerminalProgress/ProgressTheme.swift +++ b/Sources/TerminalProgress/ProgressTheme.swift @@ -18,13 +18,16 @@ public protocol ProgressTheme: Sendable { /// The icons used to represent a spinner. var spinner: [String] { get } - /// The icons used to represent a progress bar. + /// The icon used to represent a progress bar. var bar: String { get } + /// The icon used to indicate that a progress bar finished. + var done: String { get } } public struct DefaultProgressTheme: ProgressTheme { public let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] public let bar = "█" + public let done = "✔" } extension ProgressTheme { diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift index b66a3a6d2..dbee0cc42 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift @@ -24,14 +24,14 @@ extension TestCLIBuildBase { override init() throws {} @Test func testBuilderStartStopCommand() throws { #expect(throws: Never.self) { - try builderStart() - try waitForBuilderRunning() - let status = try getContainerStatus("buildkit") + try self.builderStart() + try self.waitForBuilderRunning() + let status = try self.getContainerStatus("buildkit") #expect(status == "running", "BuildKit container is not running") } #expect(throws: Never.self) { - try builderStop() - let status = try getContainerStatus("buildkit") + try self.builderStop() + let status = try self.getContainerStatus("buildkit") #expect(status == "stopped", "BuildKit container is not stopped") } } diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift index a37cdc4d4..b557699f9 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift @@ -86,7 +86,7 @@ extension TestCLIBuildBase { """ let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - let imageName = "regitry.local/scratch-add:\(UUID().uuidString)" + let imageName = "registry.local/scratch-add:\(UUID().uuidString)" try self.build(tag: imageName, tempDir: tempDir) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @@ -108,11 +108,24 @@ extension TestCLIBuildBase { .file("emptyFile", content: .zeroFilled(size: 1)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - let imageName: String = "regitry.local/add-all:\(UUID().uuidString)" + let imageName: String = "registry.local/add-all:\(UUID().uuidString)" try self.build(tag: imageName, tempDir: tempDir) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } + @Test func testBuildArg() throws { + let tempDir: URL = try createTempDir() + let dockerfile: String = + """ + ARG TAG=unknown + FROM ghcr.io/linuxcontainers/alpine:${TAG} + """ + try createContext(tempDir: tempDir, dockerfile: dockerfile) + let imageName: String = "registry.local/build-arg:\(UUID().uuidString)" + try self.build(tag: imageName, tempDir: tempDir, args: ["TAG=3.20"]) + #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") + } + @Test func testBuildNetworkAccess() throws { let tempDir: URL = try createTempDir() let dockerfile: String = @@ -122,7 +135,7 @@ extension TestCLIBuildBase { RUN nc -zv ${ADDRESS%:*} ${ADDRESS##*:} || exit 1 """ try createContext(tempDir: tempDir, dockerfile: dockerfile) - let imageName = "regitry.local/build-network-access:\(UUID().uuidString)" + let imageName = "registry.local/build-network-access:\(UUID().uuidString)" let proxyEnv = ProcessInfo.processInfo.environment["HTTP_PROXY"] var address = "8.8.8.8:53" @@ -226,7 +239,7 @@ extension TestCLIBuildBase { ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - let imageName = "regitry.local/dockerfile-keywords:\(UUID().uuidString)" + let imageName = "registry.local/dockerfile-keywords:\(UUID().uuidString)" try self.build(tag: imageName, tempDir: tempDir) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @@ -279,7 +292,7 @@ extension TestCLIBuildBase { .symbolicLink("Test3Source2/Dest", target: "Test3Source/Source"), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - let imageName = "regitry.local/build-symlinks:\(UUID().uuidString)" + let imageName = "registry.local/build-symlinks:\(UUID().uuidString)" #expect(throws: Never.self) { try self.build(tag: imageName, tempDir: tempDir) @@ -338,7 +351,7 @@ extension TestCLIBuildBase { ] try createContext(tempDir: buildContextDir, dockerfile: "", context: buildContext) - let imageName = "regitry.local/build-diff-context:\(UUID().uuidString)" + let imageName = "registry.local/build-diff-context:\(UUID().uuidString)" #expect(throws: Never.self) { try self.buildWithPaths(tag: imageName, tempContext: buildContextDir, tempDockerfileContext: dockerfileCtxDir) } diff --git a/Tests/CLITests/Subcommands/Build/CLIRunBase.swift b/Tests/CLITests/Subcommands/Build/CLIRunBase.swift index c8abb1fd6..82ea0132e 100644 --- a/Tests/CLITests/Subcommands/Build/CLIRunBase.swift +++ b/Tests/CLITests/Subcommands/Build/CLIRunBase.swift @@ -20,8 +20,7 @@ import ContainerizationOS import Foundation import Testing -// This test class is not thread safe -class TestCLIRunBase: CLITest, @unchecked Sendable { +class TestCLIRunBase: CLITest { var terminal: Terminal! var containerName: String = UUID().uuidString @@ -62,7 +61,12 @@ class TestCLIRunBase: CLITest, @unchecked Sendable { func containerRun(stdin: [String], findMessage: String) async throws -> Bool { let stdout = FileHandle(fileDescriptor: terminal.handle.fileDescriptor, closeOnDealloc: false) let stdoutListenTask = Task { - try await findStdoutOutput(stdout: stdout, findMessage: findMessage) + for try await line in stdout.bytes.lines { + if line.contains(findMessage) && !line.contains("echo") { + return true + } + } + return false } let timeoutTask = Task { @@ -82,15 +86,6 @@ class TestCLIRunBase: CLITest, @unchecked Sendable { } } - func findStdoutOutput(stdout: FileHandle, findMessage: String) async throws -> Bool { - for try await line in stdout.bytes.lines { - if line.contains(findMessage) && !line.contains("echo") { - return true - } - } - return false - } - func exec(commands: [String]) throws { let stdin = FileHandle(fileDescriptor: terminal.handle.fileDescriptor, closeOnDealloc: false) try commands.forEach { cmd in diff --git a/Tests/CLITests/Subcommands/Build/TestCLITermIO.swift b/Tests/CLITests/Subcommands/Build/TestCLITermIO.swift index 26bc68d02..35519f677 100644 --- a/Tests/CLITests/Subcommands/Build/TestCLITermIO.swift +++ b/Tests/CLITests/Subcommands/Build/TestCLITermIO.swift @@ -21,8 +21,7 @@ import Foundation import Testing extension TestCLIRunBase { - // This test class is NOT thread safe - class TestCLITermIO: TestCLIRunBase, @unchecked Sendable { + class TestCLITermIO: TestCLIRunBase { override var ContainerImage: String { "ghcr.io/linuxcontainers/alpine:3.20" } diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIRmRace.swift b/Tests/CLITests/Subcommands/Containers/TestCLIRmRace.swift new file mode 100644 index 000000000..9ee3284bb --- /dev/null +++ b/Tests/CLITests/Subcommands/Containers/TestCLIRmRace.swift @@ -0,0 +1,139 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// + +import Foundation +import Testing + +class TestCLIRmRaceCondition: CLITest { + + /// Helper method to check if a container exists + private func containerExists(_ name: String) -> Bool { + do { + _ = try getContainerStatus(name) + return true + } catch { + return false + } + } + + /// Safe container removal that handles already-removed containers gracefully + private func safeRemove(name: String, force: Bool = false) throws { + guard containerExists(name) else { + // Container already removed, nothing to do + return + } + try doRemove(name: name, force: force) + } + + @Test func testStopRmRace() async throws { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + + do { + // Create and start a container in detached mode that runs indefinitely + try doCreate(name: name, args: ["sleep", "infinity"]) + try doStart(name: name) + + // Wait for container to be running + try waitForContainerRunning(name) + + // Call doStop - this should return immediately without waiting + try doStop(name: name) + + // Immediately call doRemove and handle both possible outcomes: + // 1. Container removal succeeds immediately (race condition fixed) + // 2. Container removal fails because it's still stopping (race condition detected) + var raceConditionPrevented = false + var raceConditionDetected = false + + do { + try doRemove(name: name) + // Success: The race condition prevention is working perfectly! + // Container was removed cleanly without any race condition + raceConditionPrevented = true + } catch CLITest.CLIError.executionFailed(let message) { + if message.contains("is not yet stopped and can not be deleted") { + // Expected behavior: Race condition detected and prevented + raceConditionDetected = true + } else if message.contains("not found") || message.contains("failed to delete one or more containers") { + // Container was already removed by background cleanup - this is also success! + raceConditionPrevented = true + } else { + Issue.record("Unexpected error message: \(message)") + return + } + } catch { + Issue.record("Unexpected error type: \(error)") + return + } + + // Either outcome is acceptable - both indicate the race condition fix is working + #expect( + raceConditionPrevented || raceConditionDetected, + "Expected either immediate success (race prevented) or controlled failure (race detected)") + + // If the container was already removed, we're done + if raceConditionPrevented { + return + } + + // If we detected a race condition, wait for cleanup and retry removal + #expect(raceConditionDetected, "Should have detected race condition if we reach this point") + + // Give the background cleanup a moment to finish + try await Task.sleep(for: .seconds(2)) + + // Retry removal with exponential backoff for cleanup + var removeAttempts = 0 + let maxRemoveAttempts = 5 + let baseDelay = 1.0 // seconds + + while removeAttempts < maxRemoveAttempts { + do { + try safeRemove(name: name) + break + } catch CLITest.CLIError.executionFailed(let message) { + // If container doesn't exist, we're done + if message.contains("not found") { + break + } + + guard removeAttempts < maxRemoveAttempts - 1 else { + throw CLITest.CLIError.executionFailed("Failed to remove container after \(maxRemoveAttempts) attempts: \(message)") + } + + let delay = baseDelay * pow(2.0, Double(removeAttempts)) + try await Task.sleep(for: .seconds(delay)) + removeAttempts += 1 + } catch { + guard removeAttempts < maxRemoveAttempts - 1 else { + throw error + } + let delay = baseDelay * pow(2.0, Double(removeAttempts)) + try await Task.sleep(for: .seconds(delay)) + removeAttempts += 1 + } + } + + } catch { + Issue.record("failed to test stop-rm race condition: \(error)") + // Safe cleanup - only try to remove if container actually exists + try? safeRemove(name: name, force: true) + return + } + } +} diff --git a/Tests/CLITests/Utilities/CLITest.swift b/Tests/CLITests/Utilities/CLITest.swift index 950df6e3f..bf017d7dd 100644 --- a/Tests/CLITests/Utilities/CLITest.swift +++ b/Tests/CLITests/Utilities/CLITest.swift @@ -367,4 +367,17 @@ class CLITest { throw CLIError.executionFailed("command failed: \(error)") } } + + func doRemove(name: String, force: Bool = false) throws { + var args = ["delete"] + if force { + args.append("--force") + } + args.append(name) + + let (_, error, status) = try run(arguments: args) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } } diff --git a/Tests/ContainerBuildTests/GlobberTests.swift b/Tests/ContainerBuildTests/GlobberTests.swift index 24850382b..9ddcc1118 100644 --- a/Tests/ContainerBuildTests/GlobberTests.swift +++ b/Tests/ContainerBuildTests/GlobberTests.swift @@ -129,7 +129,7 @@ let testCases = [ func testGlobMatching(_ test: TestCase) throws { let globber = Globber(URL(fileURLWithPath: "/")) let found = try globber.glob(test.fileName, test.pattern) - #expect(found == test.expectSuccess, "expected found to be \(test.expectSuccess), instaed got \(found)") + #expect(found == test.expectSuccess, "expected found to be \(test.expectSuccess), instead got \(found)") } @Test("Invalid computed regex patterns throw error", arguments: errorGlobTestCases) diff --git a/Tests/TerminalProgressTests/ProgressBarTests.swift b/Tests/TerminalProgressTests/ProgressBarTests.swift index a1eb9b3ff..2b432e033 100644 --- a/Tests/TerminalProgressTests/ProgressBarTests.swift +++ b/Tests/TerminalProgressTests/ProgressBarTests.swift @@ -30,6 +30,16 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task [0s]") } + func testSpinnerFinished() async throws { + let config = try ProgressConfig( + description: "Task" + ) + let progress = ProgressBar(config: config) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task [0s]") + } + func testNoSpinner() async throws { let config = try ProgressConfig( description: "Task", @@ -40,6 +50,17 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "Task [0s]") } + func testNoSpinnerFinished() async throws { + let config = try ProgressConfig( + description: "Task", + showSpinner: false + ) + let progress = ProgressBar(config: config) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "Task [0s]") + } + func testNoTasks() async throws { let config = try ProgressConfig( description: "Task", @@ -93,6 +114,18 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ [0/2] Task [0s]") } + func testTotalTasksFinished() async throws { + let config = try ProgressConfig( + description: "Task", + showTasks: true, + totalTasks: 2 + ) + let progress = ProgressBar(config: config) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ [0/2] Task [0s]") + } + func testTotalTasksAdd() async throws { let config = try ProgressConfig( description: "Task", @@ -176,6 +209,19 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task 50% [0s]") } + func testPercentItemsFinished() async throws { + let config = try ProgressConfig( + description: "Task", + showPercent: true, + totalItems: 2 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task 100% [0s]") + } + func testPercentSize() async throws { let config = try ProgressConfig( description: "Task", @@ -190,6 +236,21 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task 50% [0s]") } + func testPercentSizeFinished() async throws { + let config = try ProgressConfig( + description: "Task", + showPercent: true, + showSize: false, + showSpeed: false, + totalSize: 2 + ) + let progress = ProgressBar(config: config) + progress.set(size: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task 100% [0s]") + } + func testNoProgressBar() async throws { let config = try ProgressConfig( description: "Task", @@ -216,6 +277,20 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "Task 50% |██ | [0s]") } + func testProgressBarFinished() async throws { + let config = try ProgressConfig( + description: "Task", + showProgressBar: true, + totalItems: 2, + width: 57 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "Task 100% |███| [0s]") + } + func testProgressBarMinWidth() async throws { let config = try ProgressConfig( description: "Task", @@ -229,6 +304,20 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "Task 50% | | [0s]") } + func testProgressBarMinWidthFinished() async throws { + let config = try ProgressConfig( + description: "Task", + showProgressBar: true, + totalItems: 2, + width: 13 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "Task 100% |█| [0s]") + } + func testNoItems() async throws { let config = try ProgressConfig( description: "Task", @@ -260,6 +349,18 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task (1 it) [0s]") } + func testItemsAddFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true + ) + let progress = ProgressBar(config: config) + progress.add(items: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task [0s]") + } + func testItemsSet() async throws { let config = try ProgressConfig( description: "Task", @@ -294,6 +395,19 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task 50% (1 of 2 it) [0s]") } + func testTotalItemsFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true, + totalItems: 2 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task 100% (2 it) [0s]") + } + func testTotalItemsAdd() async throws { let config = try ProgressConfig( description: "Task", @@ -361,6 +475,19 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task (1 byte) [0s]") } + func testSizeAddFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: true, + showSpeed: false + ) + let progress = ProgressBar(config: config) + progress.add(size: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task [0s]") + } + func testSizeSet() async throws { let config = try ProgressConfig( description: "Task", @@ -397,6 +524,20 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task 50% (1 byte/2 bytes) [0s]") } + func testTotalSizeDifferentUnitsFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: true, + showSpeed: false, + totalSize: 2 + ) + let progress = ProgressBar(config: config) + progress.set(size: 1) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task 100% (2 bytes) [0s]") + } + func testTotalSizeSameUnits() async throws { let config = try ProgressConfig( description: "Task", @@ -410,6 +551,20 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task 50% (2/4 bytes) [0s]") } + func testTotalSizeSameUnitsFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: true, + showSpeed: false, + totalSize: 4 + ) + let progress = ProgressBar(config: config) + progress.set(size: 2) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task 100% (4 bytes) [0s]") + } + func testTotalSizeAdd() async throws { let config = try ProgressConfig( description: "Task", @@ -463,6 +618,23 @@ final class ProgressBarTests: XCTestCase { XCTAssertEqual(output, "⠋ Task 50% (1 of 2 it, 2/4 bytes) [0s]") } + func testItemsAndSizeFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true, + showSize: true, + showSpeed: false, + totalItems: 2, + totalSize: 4 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.set(size: 2) + progress.finish() + let output = progress.draw() + XCTAssertEqual(output, "✔ Task 100% (2 it, 4 bytes) [0s]") + } + func testNoSpeed() async throws { let config = try ProgressConfig( description: "Task", @@ -487,6 +659,19 @@ final class ProgressBarTests: XCTestCase { XCTAssertTrue(output.contains("/s")) } + func testSpeedFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showSpeed: true, + totalSize: 4 + ) + let progress = ProgressBar(config: config) + progress.set(size: 2) + progress.finish() + let output = progress.draw() + XCTAssertFalse(output.contains("/s")) + } + func testItemsSizeAndSpeed() async throws { let config = try ProgressConfig( description: "Task", @@ -504,6 +689,24 @@ final class ProgressBarTests: XCTestCase { XCTAssertTrue(output.contains("/s")) } + func testItemsSizeAndSpeedFinish() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true, + showSize: true, + showSpeed: true, + totalItems: 2, + totalSize: 4 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.set(size: 2) + progress.finish() + let output = progress.draw() + XCTAssertTrue(output.contains("2 it, 4 bytes")) + XCTAssertFalse(output.contains("/s")) + } + func testNoTime() async throws { let config = try ProgressConfig( description: "Task", diff --git a/docs/technical-overview.md b/docs/technical-overview.md index 55e2eda66..5e9437830 100644 --- a/docs/technical-overview.md +++ b/docs/technical-overview.md @@ -67,7 +67,7 @@ Currently, memory pages freed to the Linux operating system by processes running ### macOS 15 limitations -`container` relies on the new features and enhancements present in the macOS 26 Beta 1. You can run `container` on macOS 15, but you will need to be aware of some user experience and functional limitations. There is no plan to address issues found with macOS 15 that cannot be reproduced in the macOS 26 Beta 1. +`container` relies on the new features and enhancements present in the macOS 26 beta. You can run `container` on macOS 15, but you will need to be aware of some user experience and functional limitations. There is no plan to address issues found with macOS 15 that cannot be reproduced in the macOS 26 beta. #### Network isolation @@ -80,7 +80,7 @@ In macOS 15, limitations in the vmnet framework mean that the container network Normally, vmnet creates the container network using the CIDR address 192.168.64.1/24, and on macOS 15, `container` defaults to using this CIDR address in the network helper. To diagnose and resolve issues stemming from a subnet address mismatch between vmnet and the network helper: - Before creating the first container, scan the output of the command `ifconfig` for a bridge interface named similarly to `bridge100`. -- After creating the first container, run `ifconfig` again, and locate the new bridge interface to determine container the subnet address. +- After creating the first container, run `ifconfig` again, and locate the new bridge interface to determine the container subnet address. - Run `container ls` to check the IP address given to the container by the network helper. If the address corresponds to a different network: - Run `container system stop` to terminate the services for `container`. - Using the macOS `defaults` command, update the default subnet value used by the network helper process. For example, if the bridge address shown by `ifconfig` is 192.168.66.1, run: diff --git a/scripts/install-hawkeye.sh b/scripts/install-hawkeye.sh index 79aaef524..dbe4b9a66 100755 --- a/scripts/install-hawkeye.sh +++ b/scripts/install-hawkeye.sh @@ -17,6 +17,6 @@ if command -v .local/bin/hawkeye >/dev/null 2>&1; then echo "hawkeye already installed" else echo "Installing hawkeye" - export VERSION=v6.0.4 + export VERSION=v6.1.0 curl --proto '=https' --tlsv1.2 -LsSf https://github.com/korandoru/hawkeye/releases/download/${VERSION}/hawkeye-installer.sh | CARGO_HOME=.local sh -s -- --no-modify-path fi diff --git a/scripts/install-init.sh b/scripts/install-init.sh index 6cdd3f56e..3f8e2265c 100755 --- a/scripts/install-init.sh +++ b/scripts/install-init.sh @@ -13,18 +13,18 @@ # See the License for the specific language governing permissions and # limitations under the License. +SWIFT="/usr/bin/swift" +IMAGE_NAME="vminit:latest" DESTDIR="${1:-$(git rev-parse --show-toplevel)/bin}" mkdir -p "${DESTDIR}" -IMAGE_NAME="vminit:latest" - -CONTAINERIZATION_VERSION="${CONTAINERIZATION_VERSION:-$(swift package show-dependencies --format json | jq -r '.dependencies[] | select(.identity == "containerization") | .version')}" +CONTAINERIZATION_VERSION="${CONTAINERIZATION_VERSION:-$(${SWIFT} package show-dependencies --format json | jq -r '.dependencies[] | select(.identity == "containerization") | .version')}" if [ ! -z "${CONTAINERIZATION_PATH}" -o "${CONTAINERIZATION_VERSION}" == "unspecified" ] ; then - CONTAINERIZATION_PATH="${CONTAINERIZATION_PATH:-$(swift package show-dependencies --format json | jq -r '.dependencies[] | select(.identity == "containerization") | .path')}" + CONTAINERIZATION_PATH="${CONTAINERIZATION_PATH:-$(${SWIFT} package show-dependencies --format json | jq -r '.dependencies[] | select(.identity == "containerization") | .path')}" echo "Creating InitImage" make -C ${CONTAINERIZATION_PATH} init ${CONTAINERIZATION_PATH}/bin/cctl images save -o /tmp/init.tar ${IMAGE_NAME} - # sleep because commands after stop and start are racy + # Sleep because commands after stop and start are racy. bin/container system stop && sleep 3 && bin/container system start && sleep 3 bin/container i load -i /tmp/init.tar rm /tmp/init.tar diff --git a/scripts/license-header.txt b/scripts/license-header.txt index b26825ef0..1db06c349 100644 --- a/scripts/license-header.txt +++ b/scripts/license-header.txt @@ -1,4 +1,4 @@ -Copyright ©{{ " " }}{%- if attrs.git_file_modified_year != attrs.git_file_created_year -%}{{ attrs.git_file_created_year }}-{{ attrs.git_file_modified_year }}{%- else -%}{{ attrs.git_file_created_year }}{%- endif -%}{{ " " }}{{ props["copyrightOwner"] }}. All rights reserved. +Copyright ©{{ " " }}{%- set created = attrs.git_file_created_year or attrs.disk_file_created_year -%}{%- set modified = attrs.git_file_modified_year or created -%}{%- if created != modified -%} {{created}}-{{modified}}{%- else -%}{{created}}{%- endif -%}{{ " " }}{{ props["copyrightOwner"] }}. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/scripts/make-docs.sh b/scripts/make-docs.sh index 121ae0f6e..2e07c288f 100755 --- a/scripts/make-docs.sh +++ b/scripts/make-docs.sh @@ -40,3 +40,15 @@ fi /usr/bin/swift package ${opts[@]} echo '{}' > "$1/theme-settings.json" + +cat > "$1/index.html" <<'EOF' + + + + + + +

If you are not redirected automatically, click here.

+ + +EOF