Repository: apple/container Branch: main Commit: e5f9abd8ff31 Files: 366 Total size: 2.0 MB Directory structure: gitextract_3edvqd6j/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── 01-bug.yml │ │ ├── 02-feature.yml │ │ └── config.yml │ ├── dependabot.yml │ ├── labeler.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── common.yml │ ├── docs-release.yml │ ├── merge-build.yml │ ├── pr-build.yml │ ├── pr-label-analysis.yml │ ├── pr-label-apply.yml │ ├── release-build.yml │ └── release.yml ├── .gitignore ├── .spi.yml ├── .swift-format ├── .swift-format-nolint ├── BUILDING.md ├── CONTRIBUTING.md ├── LICENSE ├── MAINTAINERS.txt ├── Makefile ├── Package.resolved ├── Package.swift ├── Protobuf.Makefile ├── README.md ├── SECURITY.md ├── Sources/ │ ├── CAuditToken/ │ │ ├── AuditToken.c │ │ └── include/ │ │ └── AuditToken.h │ ├── CLI/ │ │ └── ContainerCLI.swift │ ├── CVersion/ │ │ ├── Version.c │ │ └── include/ │ │ └── Version.h │ ├── ContainerBuild/ │ │ ├── BuildAPI+Extensions.swift │ │ ├── BuildFSSync.swift │ │ ├── BuildFile.swift │ │ ├── BuildImageResolver.swift │ │ ├── BuildPipelineHandler.swift │ │ ├── BuildRemoteContentProxy.swift │ │ ├── BuildStdio.swift │ │ ├── Builder.grpc.swift │ │ ├── Builder.pb.swift │ │ ├── Builder.swift │ │ ├── Globber.swift │ │ ├── TerminalCommand.swift │ │ └── URL+Extensions.swift │ ├── ContainerCommands/ │ │ ├── AggregateError.swift │ │ ├── Application.swift │ │ ├── AsyncLoggableCommand.swift │ │ ├── BuildCommand.swift │ │ ├── Builder/ │ │ │ ├── Builder.swift │ │ │ ├── BuilderDelete.swift │ │ │ ├── BuilderStart.swift │ │ │ ├── BuilderStatus.swift │ │ │ └── BuilderStop.swift │ │ ├── Codable+JSON.swift │ │ ├── Container/ │ │ │ ├── ContainerCreate.swift │ │ │ ├── ContainerDelete.swift │ │ │ ├── ContainerExec.swift │ │ │ ├── ContainerExport.swift │ │ │ ├── ContainerInspect.swift │ │ │ ├── ContainerKill.swift │ │ │ ├── ContainerList.swift │ │ │ ├── ContainerLogs.swift │ │ │ ├── ContainerPrune.swift │ │ │ ├── ContainerRun.swift │ │ │ ├── ContainerStart.swift │ │ │ ├── ContainerStats.swift │ │ │ ├── ContainerStop.swift │ │ │ └── ProcessUtils.swift │ │ ├── DefaultCommand.swift │ │ ├── Image/ │ │ │ ├── ImageCommand.swift │ │ │ ├── ImageDelete.swift │ │ │ ├── ImageInspect.swift │ │ │ ├── ImageList.swift │ │ │ ├── ImageLoad.swift │ │ │ ├── ImagePrune.swift │ │ │ ├── ImagePull.swift │ │ │ ├── ImagePush.swift │ │ │ ├── ImageSave.swift │ │ │ └── ImageTag.swift │ │ ├── Network/ │ │ │ ├── NetworkCommand.swift │ │ │ ├── NetworkCreate.swift │ │ │ ├── NetworkDelete.swift │ │ │ ├── NetworkInspect.swift │ │ │ ├── NetworkList.swift │ │ │ └── NetworkPrune.swift │ │ ├── Registry/ │ │ │ ├── RegistryCommand.swift │ │ │ ├── RegistryList.swift │ │ │ ├── RegistryLogin.swift │ │ │ └── RegistryLogout.swift │ │ ├── System/ │ │ │ ├── DNS/ │ │ │ │ ├── DNSCreate.swift │ │ │ │ ├── DNSDelete.swift │ │ │ │ └── DNSList.swift │ │ │ ├── Kernel/ │ │ │ │ └── KernelSet.swift │ │ │ ├── Property/ │ │ │ │ ├── PropertyClear.swift │ │ │ │ ├── PropertyGet.swift │ │ │ │ ├── PropertyList.swift │ │ │ │ └── PropertySet.swift │ │ │ ├── SystemCommand.swift │ │ │ ├── SystemDF.swift │ │ │ ├── SystemDNS.swift │ │ │ ├── SystemKernel.swift │ │ │ ├── SystemLogs.swift │ │ │ ├── SystemProperty.swift │ │ │ ├── SystemStart.swift │ │ │ ├── SystemStatus.swift │ │ │ ├── SystemStop.swift │ │ │ └── SystemVersion.swift │ │ └── Volume/ │ │ ├── VolumeCommand.swift │ │ ├── VolumeCreate.swift │ │ ├── VolumeDelete.swift │ │ ├── VolumeInspect.swift │ │ ├── VolumeList.swift │ │ └── VolumePrune.swift │ ├── ContainerLog/ │ │ ├── FileLogHandler.swift │ │ ├── OSLogHandler.swift │ │ ├── ServiceLogger.swift │ │ └── StderrLogHandler.swift │ ├── ContainerOS/ │ │ ├── DirectoryWatcher.swift │ │ └── LocalNetworkPrivacy.swift │ ├── ContainerPersistence/ │ │ ├── DefaultsStore.swift │ │ └── EntityStore.swift │ ├── ContainerPlugin/ │ │ ├── ApplicationRoot.swift │ │ ├── InstallRoot.swift │ │ ├── LaunchPlist.swift │ │ ├── LogRoot.swift │ │ ├── Plugin.swift │ │ ├── PluginConfig.swift │ │ ├── PluginFactory.swift │ │ ├── PluginLoader.swift │ │ └── ServiceManager.swift │ ├── ContainerResource/ │ │ ├── Common/ │ │ │ ├── ManagedResource.swift │ │ │ └── ResourceLabels.swift │ │ ├── Container/ │ │ │ ├── Bundle.swift │ │ │ ├── ContainerConfiguration.swift │ │ │ ├── ContainerCreateOptions.swift │ │ │ ├── ContainerListFilters.swift │ │ │ ├── ContainerSnapshot.swift │ │ │ ├── ContainerStats.swift │ │ │ ├── ContainerStopOptions.swift │ │ │ ├── Filesystem.swift │ │ │ ├── ProcessConfiguration.swift │ │ │ ├── PublishPort.swift │ │ │ ├── PublishSocket.swift │ │ │ └── RuntimeStatus.swift │ │ ├── Image/ │ │ │ ├── ImageDescription.swift │ │ │ └── ImageDetail.swift │ │ ├── Network/ │ │ │ ├── AllocatedAttachment.swift │ │ │ ├── Attachment.swift │ │ │ ├── AttachmentConfiguration.swift │ │ │ ├── NetworkConfiguration.swift │ │ │ ├── NetworkMode.swift │ │ │ └── NetworkState.swift │ │ ├── Registry/ │ │ │ └── RegistryResource.swift │ │ └── Volume/ │ │ └── Volume.swift │ ├── ContainerVersion/ │ │ ├── Bundle+AppBundle.swift │ │ ├── CommandLine+Executable.swift │ │ └── ReleaseVersion.swift │ ├── ContainerXPC/ │ │ ├── XPCClient.swift │ │ ├── XPCMessage.swift │ │ └── XPCServer.swift │ ├── DNSServer/ │ │ ├── DNSHandler.swift │ │ ├── DNSServer+Handle.swift │ │ ├── DNSServer.swift │ │ ├── Handlers/ │ │ │ ├── CompositeResolver.swift │ │ │ ├── HostTableResolver.swift │ │ │ ├── NxDomainResolver.swift │ │ │ └── StandardQueryValidator.swift │ │ ├── Records/ │ │ │ ├── DNSBindError.swift │ │ │ ├── DNSEnums.swift │ │ │ ├── DNSName.swift │ │ │ ├── IPAddressProtocol.swift │ │ │ ├── Message.swift │ │ │ ├── Question.swift │ │ │ ├── ResourceRecord.swift │ │ │ └── UInt8+Binding.swift │ │ └── Types.swift │ ├── Helpers/ │ │ ├── APIServer/ │ │ │ ├── APIServer+Start.swift │ │ │ ├── APIServer.swift │ │ │ ├── ContainerDNSHandler.swift │ │ │ └── LocalhostDNSHandler.swift │ │ ├── Images/ │ │ │ └── ImagesHelper.swift │ │ ├── NetworkVmnet/ │ │ │ ├── NetworkVmnetHelper+Start.swift │ │ │ └── NetworkVmnetHelper.swift │ │ └── RuntimeLinux/ │ │ ├── IsolatedInterfaceStrategy.swift │ │ ├── NonisolatedInterfaceStrategy.swift │ │ ├── RuntimeLinuxHelper+Start.swift │ │ └── RuntimeLinuxHelper.swift │ ├── Services/ │ │ ├── ContainerAPIService/ │ │ │ ├── Client/ │ │ │ │ ├── Arch.swift │ │ │ │ ├── Archiver.swift │ │ │ │ ├── Array+Dedupe.swift │ │ │ │ ├── ClientDiskUsage.swift │ │ │ │ ├── ClientHealthCheck.swift │ │ │ │ ├── ClientImage.swift │ │ │ │ ├── ClientKernel.swift │ │ │ │ ├── ClientNetwork.swift │ │ │ │ ├── ClientProcess.swift │ │ │ │ ├── ClientVolume.swift │ │ │ │ ├── Constants.swift │ │ │ │ ├── ContainerClient.swift │ │ │ │ ├── ContainerizationProgressAdapter.swift │ │ │ │ ├── DefaultPlatform.swift │ │ │ │ ├── DiskUsage.swift │ │ │ │ ├── FileDownloader.swift │ │ │ │ ├── Flags.swift │ │ │ │ ├── HostDNSResolver.swift │ │ │ │ ├── ImageLoadResult.swift │ │ │ │ ├── Measurement+Parse.swift │ │ │ │ ├── PacketFilter.swift │ │ │ │ ├── Parser.swift │ │ │ │ ├── ProcessIO.swift │ │ │ │ ├── ProgressUpdateClient.swift │ │ │ │ ├── ProgressUpdateService.swift │ │ │ │ ├── RequestScheme.swift │ │ │ │ ├── SignalThreshold.swift │ │ │ │ ├── String+Extensions.swift │ │ │ │ ├── SystemHealth.swift │ │ │ │ ├── TableOutput.swift │ │ │ │ ├── Utility.swift │ │ │ │ └── XPC+.swift │ │ │ └── Server/ │ │ │ ├── Containers/ │ │ │ │ ├── ContainersHarness.swift │ │ │ │ └── ContainersService.swift │ │ │ ├── DiskUsage/ │ │ │ │ ├── DiskUsageHarness.swift │ │ │ │ └── DiskUsageService.swift │ │ │ ├── HealthCheck/ │ │ │ │ └── HealthCheckHarness.swift │ │ │ ├── Kernel/ │ │ │ │ ├── KernelHarness.swift │ │ │ │ └── KernelService.swift │ │ │ ├── Networks/ │ │ │ │ ├── NetworksHarness.swift │ │ │ │ └── NetworksService.swift │ │ │ ├── Plugin/ │ │ │ │ ├── PluginsHarness.swift │ │ │ │ └── PluginsService.swift │ │ │ └── Volumes/ │ │ │ ├── VolumesHarness.swift │ │ │ └── VolumesService.swift │ │ ├── ContainerImagesService/ │ │ │ ├── Client/ │ │ │ │ ├── ImageServiceXPCKeys.swift │ │ │ │ ├── ImageServiceXPCRoutes.swift │ │ │ │ └── RemoteContentStoreClient.swift │ │ │ └── Server/ │ │ │ ├── ContentServiceHarness.swift │ │ │ ├── ContentStoreService.swift │ │ │ ├── ImagesService.swift │ │ │ ├── ImagesServiceHarness.swift │ │ │ └── SnapshotStore.swift │ │ ├── ContainerNetworkService/ │ │ │ ├── Client/ │ │ │ │ ├── NetworkClient.swift │ │ │ │ ├── NetworkKeys.swift │ │ │ │ └── NetworkRoutes.swift │ │ │ └── Server/ │ │ │ ├── AllocationOnlyVmnetNetwork.swift │ │ │ ├── AttachmentAllocator.swift │ │ │ ├── Network.swift │ │ │ ├── NetworkService.swift │ │ │ └── ReservedVmnetNetwork.swift │ │ └── ContainerSandboxService/ │ │ ├── Client/ │ │ │ ├── Bundle+Log.swift │ │ │ ├── ExitMonitor.swift │ │ │ ├── SandboxClient.swift │ │ │ ├── SandboxKeys.swift │ │ │ ├── SandboxRoutes.swift │ │ │ ├── SandboxRuntimeConfiguration.swift │ │ │ └── SandboxSnapshot.swift │ │ └── Server/ │ │ ├── InterfaceStrategy.swift │ │ └── SandboxService.swift │ ├── SocketForwarder/ │ │ ├── ConnectHandler.swift │ │ ├── GlueHandler.swift │ │ ├── LRUCache.swift │ │ ├── SocketForwarder.swift │ │ ├── SocketForwarderResult.swift │ │ ├── TCPForwarder.swift │ │ └── UDPForwarder.swift │ └── TerminalProgress/ │ ├── Int+Formatted.swift │ ├── Int64+Formatted.swift │ ├── ProgressBar+Add.swift │ ├── ProgressBar+State.swift │ ├── ProgressBar+Terminal.swift │ ├── ProgressBar.swift │ ├── ProgressConfig.swift │ ├── ProgressTaskCoordinator.swift │ ├── ProgressTheme.swift │ ├── ProgressUpdate.swift │ └── StandardError.swift ├── Tests/ │ ├── CLITests/ │ │ ├── Subcommands/ │ │ │ ├── Build/ │ │ │ │ ├── CLIBuildBase.swift │ │ │ │ ├── CLIBuilderEnvOnlyTest.swift │ │ │ │ ├── CLIBuilderLifecycleTest.swift │ │ │ │ ├── CLIBuilderLocalOutputTest.swift │ │ │ │ ├── CLIBuilderTarExportTest.swift │ │ │ │ ├── CLIBuilderTest.swift │ │ │ │ ├── CLIRunBase.swift │ │ │ │ └── TestCLITermIO.swift │ │ │ ├── Containers/ │ │ │ │ ├── TestCLICreate.swift │ │ │ │ ├── TestCLIExec.swift │ │ │ │ ├── TestCLIExport.swift │ │ │ │ ├── TestCLIPrune.swift │ │ │ │ ├── TestCLIRmRace.swift │ │ │ │ └── TestCLIStats.swift │ │ │ ├── Images/ │ │ │ │ └── TestCLIImagesCommand.swift │ │ │ ├── Networks/ │ │ │ │ └── TestCLINetwork.swift │ │ │ ├── Plugins/ │ │ │ │ └── TestCLIPluginErrors.swift │ │ │ ├── Registry/ │ │ │ │ └── TestCLIRegistry.swift │ │ │ ├── Run/ │ │ │ │ ├── TestCLIRunCommand.swift │ │ │ │ ├── TestCLIRunInitImage.swift │ │ │ │ └── TestCLIRunLifecycle.swift │ │ │ ├── System/ │ │ │ │ ├── TestCLIStatus.swift │ │ │ │ ├── TestCLIVersion.swift │ │ │ │ └── TestKernelSet.swift │ │ │ └── Volumes/ │ │ │ ├── TestCLIAnonymousVolumes.swift │ │ │ └── TestCLIVolumes.swift │ │ ├── TestCLINoParallelCases.swift │ │ └── Utilities/ │ │ └── CLITest.swift │ ├── ContainerAPIClientTests/ │ │ ├── ArchTests.swift │ │ ├── DefaultPlatformTests.swift │ │ ├── DiskUsageTests.swift │ │ ├── HostDNSResolverTest.swift │ │ ├── Measurement+ParseTests.swift │ │ ├── PacketFilterTest.swift │ │ ├── ParserTest.swift │ │ ├── RequestSchemeTests.swift │ │ └── UtilityTests.swift │ ├── ContainerBuildTests/ │ │ ├── BuildFile.swift │ │ ├── BuilderExtensionsTests.swift │ │ └── GlobberTests.swift │ ├── ContainerNetworkServiceTests/ │ │ └── AttachmentAllocatorTest.swift │ ├── ContainerOSTests/ │ │ └── DirectoryWatcherTest.swift │ ├── ContainerPluginTests/ │ │ ├── CommandLine+ExecutableTest.swift │ │ ├── MockPluginFactory.swift │ │ ├── PluginConfigTest.swift │ │ ├── PluginFactoryTest.swift │ │ ├── PluginLoaderTest.swift │ │ └── PluginTest.swift │ ├── ContainerResourceTests/ │ │ ├── ManagedResourceTests.swift │ │ ├── NetworkConfigurationTest.swift │ │ ├── PublishPortTests.swift │ │ ├── RegistryResourceTests.swift │ │ └── VolumeValidationTests.swift │ ├── ContainerSandboxServiceTests/ │ │ └── RuntimeConfigurationTests.swift │ ├── DNSServerTests/ │ │ ├── CompositeResolverTest.swift │ │ ├── HostTableResolverTest.swift │ │ ├── MockHandlers.swift │ │ ├── NxDomainResolverTest.swift │ │ ├── RecordsTests.swift │ │ └── StandardQueryValidatorTest.swift │ ├── SocketForwarderTests/ │ │ ├── ConnectHandlerRaceTest.swift │ │ ├── LRUCacheTest.swift │ │ ├── TCPEchoHandler.swift │ │ ├── TCPEchoServer.swift │ │ ├── TCPForwarderTest.swift │ │ ├── UDPEchoHandler.swift │ │ ├── UDPEchoServer.swift │ │ └── UDPForwarderTest.swift │ └── TerminalProgressTests/ │ └── ProgressBarTests.swift ├── config/ │ ├── container-core-images-config.json │ ├── container-network-vmnet-config.json │ └── container-runtime-linux-config.json ├── docs/ │ ├── command-reference.md │ ├── how-to.md │ ├── technical-overview.md │ └── tutorial.md ├── licenserc.toml ├── scripts/ │ ├── container-header-style.toml │ ├── ensure-container-stopped.sh │ ├── ensure-hawkeye-exists.sh │ ├── install-hawkeye.sh │ ├── install-init.sh │ ├── license-header.txt │ ├── make-docs.sh │ ├── pre-commit.fmt │ ├── uninstall-container.sh │ └── update-container.sh └── signing/ ├── container-network-vmnet.entitlements └── container-runtime-linux.entitlements ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/01-bug.yml ================================================ name: Bug report description: File a bug report. title: "[Bug]: " type: "Bug" body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! - type: checkboxes id: prereqs attributes: label: I have done the following description: Select that you have completed the following prerequisites. options: - label: I have searched the existing issues required: true - label: If possible, I've reproduced the issue using the 'main' branch of this project required: false - type: textarea id: reproduce attributes: label: Steps to reproduce description: Explain how to reproduce the incorrect behavior. validations: required: true - type: textarea id: what-happened attributes: label: Current behavior description: A concise description of what you're experiencing. validations: required: true - type: textarea id: expected attributes: label: Expected behavior description: A concise description of what you expected to happen. validations: required: true - type: textarea attributes: label: Environment description: | Examples: - **OS**: macOS 26.0 (25A354) - **Xcode**: Version 26.0 (17A324) - **Container**: Container CLI version 0.1.0 value: | - OS: - Xcode: - Container: render: markdown validations: required: true - type: textarea id: logs attributes: label: Relevant log output description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. value: | N/A render: shell - type: checkboxes id: terms attributes: label: Code of Conduct 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 ================================================ FILE: .github/ISSUE_TEMPLATE/02-feature.yml ================================================ name: Feature or enhancement request description: File a request for a feature or enhancement title: "[Request]: " type: "Feature" body: - type: markdown attributes: value: | Thanks for contributing to the container project! - type: textarea id: request attributes: label: Feature or enhancement request details description: Describe your proposed feature or enhancement. Code samples that show what's missing, or what new capabilities will be possible, are very helpful! Provide links to existing issues or external references/discussions, if appropriate. validations: required: true - type: checkboxes id: terms attributes: label: Code of Conduct 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 ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Container community support url: https://github.com/apple/container/discussions about: Please ask and answer questions here. ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" groups: github-actions: patterns: - "*" commit-message: prefix: "ci" ================================================ FILE: .github/labeler.yml ================================================ cli: - changed-files: - any-glob-to-any-file: - 'Sources/CLI/**' - 'Sources/ContainerCommands/**' documentation: - changed-files: - any-glob-to-any-file: - '**/*.md' - 'docs/**' ci: - changed-files: - any-glob-to-any-file: - '.github/**' ================================================ FILE: .github/pull_request_template.md ================================================ ## Type of Change - [ ] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Documentation update ## Motivation and Context [Why is this change needed?] ## Testing - [ ] Tested locally - [ ] Added/updated tests - [ ] Added/updated docs ================================================ FILE: .github/workflows/common.yml ================================================ name: container project - common jobs permissions: contents: read on: workflow_call: inputs: release: type: boolean description: "Publish this build for release" default: false jobs: buildAndTest: name: Build and test the project if: github.repository == 'apple/container' timeout-minutes: 60 runs-on: [self-hosted, macos, tahoe, ARM64] permissions: contents: read packages: read steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - name: Check formatting run: | ./scripts/install-hawkeye.sh make fmt 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: DEVELOPER_DIR: "/Applications/Xcode-latest.app/Contents/Developer" - name: Set build configuration env: RELEASE: ${{ inputs.release }} run: | echo "BUILD_CONFIGURATION=debug" >> $GITHUB_ENV if [[ "${RELEASE}" == "true" ]]; then echo "BUILD_CONFIGURATION=release" >> $GITHUB_ENV fi - name: Make the container project and docs run: | make container dsym docs tar cfz _site.tgz _site env: DEVELOPER_DIR: "/Applications/Xcode-latest.app/Contents/Developer" - name: Create package run: | mkdir -p outputs mv "bin/${BUILD_CONFIGURATION}/container-installer-unsigned.pkg" outputs mv "bin/${BUILD_CONFIGURATION}/bundle/container-dSYM.zip" outputs - name: Test the container project run: | APP_ROOT=$(mktemp -d -p "${RUNNER_TEMP}") LOG_ROOT="${APP_ROOT}/logs" echo "created data directory: ${APP_ROOT}" echo "hostname: $(hostname)" export NO_PROXY="${NO_PROXY},192.168.0.0/16,fe80::/10" echo NO_PROXY=${NO_PROXY} export no_proxy="${no_proxy},192.168.0.0/16,fe80::/10" echo no_proxy=${no_proxy} echo "APP_ROOT=${APP_ROOT}" >> $GITHUB_ENV echo "LOG_ROOT=${LOG_ROOT}" >> $GITHUB_ENV make APP_ROOT="${APP_ROOT}" LOG_ROOT="${LOG_ROOT}" test install-kernel integration env: DEVELOPER_DIR: "/Applications/Xcode-latest.app/Contents/Developer" - name: Archive test logs if: always() run: | if [ -d "${APP_ROOT}/containers" ] && [ -n "${LOG_ROOT}" ]; then rsync -a --include='*/' --include='*.log' --exclude='*' \ "${APP_ROOT}/containers/" \ "${LOG_ROOT}/containers/" fi if [ -n "${LOG_ROOT}" ] && [ -d "${LOG_ROOT}" ] ; then echo "Collecting logs from ${LOG_ROOT}..." tar czf container-logs.tar.gz -C "$(dirname "${LOG_ROOT}")" "$(basename "${LOG_ROOT}")" echo "Log archive created: container-logs.tar.gz" fi if [ -n "${APP_ROOT}" ] ; then rm -rf "${APP_ROOT}" echo "Removed data directory ${APP_ROOT}" fi - name: Upload logs if present if: always() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: container-test-logs path: container-logs.tar.gz retention-days: 14 if-no-files-found: ignore - name: Save documentation artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: api-docs path: "./_site.tgz" retention-days: 14 - name: Save package artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 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. name: Upload artifact for GitHub Pages needs: buildAndTest timeout-minutes: 5 runs-on: ubuntu-latest steps: - name: Setup Pages uses: actions/configure-pages@v5 - name: Download a single artifact uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: api-docs - name: Add API docs to documentation run: | tar xfz _site.tgz - name: Upload Artifact uses: actions/upload-pages-artifact@v4 with: path: "./_site" ================================================ FILE: .github/workflows/docs-release.yml ================================================ # 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 permissions: contents: read on: workflow_dispatch: 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 env: REF_NAME: ${{ github.ref_name }} run: echo "Branch ${REF_NAME} is allowed" buildSite: name: Build application website needs: checkBranch uses: ./.github/workflows/common.yml secrets: inherit permissions: contents: read packages: write pages: write deployDocs: runs-on: ubuntu-latest needs: [checkBranch, buildSite] permissions: contents: read pages: write id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .github/workflows/merge-build.yml ================================================ name: container project - merge build permissions: contents: read on: push: branches: - main - release/* jobs: build: name: Invoke build uses: ./.github/workflows/common.yml with: release: true secrets: inherit permissions: contents: read packages: read pages: write ================================================ FILE: .github/workflows/pr-build.yml ================================================ name: container project - PR build permissions: contents: read on: pull_request: types: [opened, reopened, synchronize] jobs: verify-signatures: name: Verify commit signatures runs-on: ubuntu-latest steps: - name: Check all commits are signed env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | commits=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits" --paginate) unsigned_commits="" while IFS='|' read -r sha author verified; do if [ "$verified" != "true" ]; then unsigned_commits="$unsigned_commits - $sha by $author\n" fi done < <(echo "$commits" | jq -r '.[] | "\(.sha)|\(.commit.author.name)|\(.commit.verification.verified)"') if [ -n "$unsigned_commits" ]; then echo "::error::The following commits are not signed:" echo -e "$unsigned_commits" echo "" echo "Please sign your commits. See:" echo " - https://github.com/apple/containerization/blob/main/CONTRIBUTING.md#pull-requests" echo " - https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits" exit 1 fi echo "All commits are signed!" build: name: Invoke build uses: ./.github/workflows/common.yml with: release: false secrets: inherit permissions: contents: read packages: read pages: write ================================================ FILE: .github/workflows/pr-label-analysis.yml ================================================ name: PR Label Analysis on: pull_request: types: [opened] permissions: contents: read jobs: analyze: name: Analyze PR for labeling runs-on: ubuntu-latest steps: - name: Save PR metadata env: PR_NUMBER: ${{ github.event.pull_request.number }} run: | mkdir -p ./pr-metadata echo "${PR_NUMBER}" > ./pr-metadata/pr-number.txt - name: Upload PR metadata as artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: pr-metadata-${{ github.event.pull_request.number }} path: pr-metadata/ retention-days: 1 ================================================ FILE: .github/workflows/pr-label-apply.yml ================================================ name: PR Label Apply on: workflow_run: workflows: ["PR Label Analysis"] types: - completed permissions: contents: read jobs: apply-labels: name: Apply labels to PR runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion == 'success' }} permissions: contents: read pull-requests: write steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Download PR metadata artifact uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} pattern: pr-metadata-* merge-multiple: false id: download-artifact - name: Read PR number id: pr-number run: | PR_NUMBER=$(cat "pr-number.txt") echo "number=${PR_NUMBER}" >> $GITHUB_OUTPUT echo "PR Number: ${PR_NUMBER}" - name: Apply labels using labeler uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6 with: pr-number: ${{ steps.pr-number.outputs.number }} repo-token: ${{ secrets.GITHUB_TOKEN }} configuration-path: .github/labeler.yml sync-labels: true ================================================ FILE: .github/workflows/release-build.yml ================================================ name: container project - release build permissions: contents: read on: push: tags: - "[0-9]+\\.[0-9]+\\.[0-9]+" jobs: build: name: Invoke build and release uses: ./.github/workflows/common.yml 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 runs-on: ubuntu-latest permissions: contents: write packages: read pages: write steps: - name: Download artifacts uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 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: ${{ github.token }} name: ${{ github.ref_name }}-prerelease draft: true make_latest: false prerelease: true fail_on_unmatched_files: true files: | outputs/container-package/*.zip outputs/container-package/*.pkg ================================================ FILE: .github/workflows/release.yml ================================================ name: container project - release build permissions: contents: read on: push: tags: - "[0-9]+\\.[0-9]+\\.[0-9]+" jobs: build: name: Invoke build and release uses: ./.github/workflows/common.yml 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 runs-on: ubuntu-latest permissions: contents: write packages: read pages: write steps: - name: Download artifacts uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 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: ${{ github.token }} name: ${{ github.ref_name }}-prerelease draft: true make_latest: false prerelease: true fail_on_unmatched_files: true files: | outputs/container-package/*.zip outputs/container-package/*.pkg ================================================ FILE: .gitignore ================================================ .DS_Store bin libexec .build .local xcuserdata/ DerivedData/ Packages/ .swiftpm/ .netrc .swiftpm api-docs/ workdir/ installer/ .venv/ .claude/ .clitests/ test_results/ *.pid *.log *.zip *.o *.ext4 *.pkg *.swp # API docs for local preview only. _site/ _serve/ ================================================ FILE: .spi.yml ================================================ version: 1 builder: configs: - documentation_targets: - ContainerAPIService - ContainerAPIClient - ContainerSandboxService - ContainerSandboxServiceClient - ContainerNetworkService - ContainerNetworkServiceClient - ContainerImagesService - ContainerImagesServiceClient - ContainerResource - ContainerLog - ContainerPlugin - ContainerXPC - TerminalProgress swift_version: '6.2' ================================================ FILE: .swift-format ================================================ { "fileScopedDeclarationPrivacy" : { "accessLevel" : "private" }, "indentation" : { "spaces" : 4 }, "indentConditionalCompilationBlocks" : false, "indentSwitchCaseLabels" : false, "lineBreakAroundMultilineExpressionChainComponents" : false, "lineBreakBeforeControlFlowKeywords" : false, "lineBreakBeforeEachArgument" : false, "lineBreakBeforeEachGenericRequirement" : false, "lineLength" : 180, "maximumBlankLines" : 1, "multiElementCollectionTrailingCommas" : true, "noAssignmentInExpressions" : { "allowedFunctions" : [ "XCTAssertNoThrow" ] }, "prioritizeKeepingFunctionOutputTogether" : false, "respectsExistingLineBreaks" : true, "rules" : { "AllPublicDeclarationsHaveDocumentation" : false, "AlwaysUseLowerCamelCase" : true, "AmbiguousTrailingClosureOverload" : false, "BeginDocumentationCommentWithOneLineSummary" : false, "DoNotUseSemicolons" : true, "DontRepeatTypeInStaticProperties" : true, "FileScopedDeclarationPrivacy" : true, "FullyIndirectEnum" : true, "GroupNumericLiterals" : true, "IdentifiersMustBeASCII" : true, "NeverForceUnwrap" : true, "NeverUseForceTry" : true, "NeverUseImplicitlyUnwrappedOptionals" : true, "NoAccessLevelOnExtensionDeclaration" : true, "NoAssignmentInExpressions" : true, "NoBlockComments" : false, "NoCasesWithOnlyFallthrough" : true, "NoEmptyTrailingClosureParentheses" : true, "NoLabelsInCasePatterns" : true, "NoLeadingUnderscores" : false, "NoParensAroundConditions" : true, "NoPlaygroundLiterals" : true, "NoVoidReturnOnFunctionSignature" : true, "OmitExplicitReturns" : true, "OneCasePerLine" : true, "OneVariableDeclarationPerLine" : true, "OnlyOneTrailingClosureArgument" : true, "OrderedImports" : true, "ReplaceForEachWithForLoop" : true, "ReturnVoidInsteadOfEmptyTuple" : true, "TypeNamesShouldBeCapitalized" : true, "UseEarlyExits" : true, "UseLetInEveryBoundCaseVariable" : true, "UseShorthandTypeNames" : true, "UseSingleLinePropertyGetter" : true, "UseSynthesizedInitializer" : true, "UseTripleSlashForDocumentationComments" : true, "UseWhereClausesInForLoops" : false, "ValidateDocumentationComments" : true }, "spacesAroundRangeFormationOperators" : false, "tabWidth" : 2, "version" : 1 } ================================================ FILE: .swift-format-nolint ================================================ { "fileScopedDeclarationPrivacy" : { "accessLevel" : "private" }, "indentation" : { "spaces" : 4 }, "indentConditionalCompilationBlocks" : false, "indentSwitchCaseLabels" : false, "lineBreakAroundMultilineExpressionChainComponents" : false, "lineBreakBeforeControlFlowKeywords" : false, "lineBreakBeforeEachArgument" : false, "lineBreakBeforeEachGenericRequirement" : false, "lineLength" : 180, "maximumBlankLines" : 1, "multiElementCollectionTrailingCommas" : true, "noAssignmentInExpressions" : { "allowedFunctions" : [ "XCTAssertNoThrow" ] }, "prioritizeKeepingFunctionOutputTogether" : false, "respectsExistingLineBreaks" : true, "rules" : { "AllPublicDeclarationsHaveDocumentation" : false, "AlwaysUseLowerCamelCase" : false, "AmbiguousTrailingClosureOverload" : false, "BeginDocumentationCommentWithOneLineSummary" : false, "DoNotUseSemicolons" : true, "DontRepeatTypeInStaticProperties" : false, "FileScopedDeclarationPrivacy" : false, "FullyIndirectEnum" : false, "GroupNumericLiterals" : false, "IdentifiersMustBeASCII" : false, "NeverForceUnwrap" : false, "NeverUseForceTry" : false, "NeverUseImplicitlyUnwrappedOptionals" : false, "NoAccessLevelOnExtensionDeclaration" : false, "NoAssignmentInExpressions" : false, "NoBlockComments" : false, "NoCasesWithOnlyFallthrough" : false, "NoEmptyTrailingClosureParentheses" : true, "NoLabelsInCasePatterns" : false, "NoLeadingUnderscores" : false, "NoParensAroundConditions" : true, "NoPlaygroundLiterals" : false, "NoVoidReturnOnFunctionSignature" : true, "OmitExplicitReturns" : false, "OneCasePerLine" : true, "OneVariableDeclarationPerLine" : true, "OnlyOneTrailingClosureArgument" : false, "OrderedImports" : true, "ReplaceForEachWithForLoop" : false, "ReturnVoidInsteadOfEmptyTuple" : false, "TypeNamesShouldBeCapitalized" : false, "UseEarlyExits" : false, "UseLetInEveryBoundCaseVariable" : false, "UseShorthandTypeNames" : true, "UseSingleLinePropertyGetter" : true, "UseSynthesizedInitializer" : false, "UseTripleSlashForDocumentationComments" : true, "UseWhereClausesInForLoops" : false, "ValidateDocumentationComments" : false }, "spacesAroundRangeFormationOperators" : false, "tabWidth" : 2, "version" : 1 } ================================================ FILE: BUILDING.md ================================================ # Building the project To build the `container` project, you need: - Mac with Apple silicon - macOS 15 minimum, macOS 26 recommended - Xcode 26, set as the [active developer directory](https://developer.apple.com/library/archive/technotes/tn2339/_index.html#//apple_ref/doc/uid/DTS40014588-CH1-HOW_DO_I_SELECT_THE_DEFAULT_VERSION_OF_XCODE_TO_USE_FOR_MY_COMMAND_LINE_TOOLS_) > [!IMPORTANT] > There is a bug in the `vmnet` framework on macOS 26 that causes network creation to fail if the `container` helper applications are located under your `Documents` or `Desktop` directories. If you use `make install`, you can simply run the `container` binary in `/usr/local`. If you prefer to use the binaries that `make all` creates in your project `bin` and `libexec` directories, locate your project elsewhere, such as `~/projects/container`, until this issue is resolved. ## Compile and test Build `container` and the background services from source, and run basic and integration tests: ```bash make all test integration ``` Copy the binaries to `/usr/local/bin` and `/usr/local/libexec` (requires entering an administrator password): ```bash make install ``` Or to install a release build, with better performance than the debug build: ```bash BUILD_CONFIGURATION=release make all test integration BUILD_CONFIGURATION=release make install ``` ## Compile protobufs `container` uses gRPC to communicate to the builder virtual machine that creates images from `Dockerfile`s, and depends on specific versions of `grpc-swift` and `swift-protobuf`. If you make changes to the gRPC APIs in the [container-builder-shim](https://github.com/apple/container-builder-shim) project, install the tools and re-generate the gRPC code in this project using: ```bash make protos ``` ## Develop using a local copy of Containerization To make changes to `container` that require changes to the Containerization project, or vice versa: 1. Clone the [Containerization](https://github.com/apple/containerization) repository such that it sits next to your clone of the `container` repository. Ensure that you [follow containerization instructions](https://github.com/apple/containerization/blob/main/README.md#prepare-to-build-package) to prepare your build environment. 2. In your development shell, go to the `container` project directory. ```bash cd container ``` 3. If the `container` services are already running, stop them. ```bash bin/container system stop ``` 4. Reconfigure the Swift project to use your local `containerization` package and update your `Package.resolved` file. ```bash /usr/bin/swift package edit --path ../containerization containerization /usr/bin/swift package update containerization ``` > [!IMPORTANT] > If you are using Xcode, do **not** run `swift package edit`. Instead, temporarily modify `Package.swift` to replace the versioned `containerization` dependency: > > ```swift > .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)), > ``` > > with the local path dependency: > > ```swift > .package(path: "../containerization"), > ``` > > **Note:** If you have already run `swift package edit`, whether intentionally or by accident, follow the steps in the next section to restore the normal `containerization` dependency. Otherwise, the modified `Package.swift` file will not work, and the project may fail to build. 5. If you want `container` to use any changes you made in the `vminit` subproject of Containerization, update the system property to use the locally built init filesystem image: ```bash container system property set image.init vminit:latest ``` 6. Build `container`. ``` make clean all ``` 7. Restart the `container` services. ``` bin/container system stop bin/container system start ``` To revert to using the Containerization dependency from your `Package.swift`: 1. If you were using the local init filesystem, revert the system property to its default value: ```bash container system property clear image.init ``` 2. Use the Swift package manager to restore the normal `containerization` dependency and update your `Package.resolved` file. If you are using Xcode, revert your `Package.swift` change instead of using `swift package unedit`. ```bash /usr/bin/swift package unedit containerization /usr/bin/swift package update containerization ``` 3. Rebuild `container`. ```bash make clean all ``` 4. Restart the `container` services. ```bash bin/container system stop bin/container system start ``` ## Develop using a local copy of container-builder-shim To test changes that require the `container-builder-shim` project: 1. Clone the [container-builder-shim](https://github.com/apple/container-builder-shim) repository and navigate to its directory. 2. After making the necessary changes, build the custom builder image, set it as the active builder image, and remove the existing `buildkit` container so the new image will be used: ```bash container build -t builder . container system property set image.builder builder:latest container rm -f buildkit ``` 3. Run the `container` build as usual: ```bash container build ... ``` > [!IMPORTANT] > If your modified builder image is broken, make sure to rebuild and correctly tag the builder image before attempting to build `container-builder-shim` again. ## Debug XPC Helpers Attach debugger to the XPC helpers using their launchd service labels: 1. Find launchd service labels: ```console % container system start % container run -d --name test debian:bookworm sleep infinity test % launchctl list | grep container 27068 0 com.apple.container.container-network-vmnet.default 27072 0 com.apple.container.container-core-images 26980 0 com.apple.container.apiserver 27331 0 com.apple.container.container-runtime-linux.test ``` 2. Stop container and start again after setting the environment variable `CONTAINER_DEBUG_LAUNCHD_LABEL` to the label of service to attach debugger. Services whose label starts with the `CONTAINER_DEBUG_LAUNCHD_LABEL` will wait the debugger: ```console % export CONTAINER_DEBUG_LAUNCHD_LABEL=com.apple.container.container-runtime-linux.test % container system start # Only the service `com.apple.container.container-runtime-linux.test` waits debugger ``` ```console % export CONTAINER_DEBUG_LAUNCHD_LABEL=com.apple.container.container-runtime-linux % container system start # Every service starting with `com.apple.container.container-runtime-linux` waits debugger ``` 3. Run the command to launch the service, and attach debugger: ```console % container run -it --name test debian:bookworm ⠧ [6/6] Starting container [0s] # It hangs as the service is waiting for debugger ``` ## Pre-commit hook Run `make pre-commit` to install a pre-commit hook that ensures that your changes have correct formatting and license headers when you run `git commit`. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Contributions are welcome and encouraged! Read our [main contributing guide](https://github.com/apple/containerization/blob/main/CONTRIBUTING.md) to get started. ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: MAINTAINERS.txt ================================================ # Maintainers See [MAINTAINERS](https://github.com/apple/containerization/blob/main/MAINTAINERS.txt) for the list of current and former maintainers of this project. Thank you for all your contributions! ================================================ FILE: Makefile ================================================ # Copyright © 2025-2026 Apple Inc. and the container project authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Version and build configuration variables BUILD_CONFIGURATION ?= debug WARNINGS_AS_ERRORS ?= true SWIFT_CONFIGURATION := $(if $(filter-out false,$(WARNINGS_AS_ERRORS)),-Xswiftc -warnings-as-errors) export RELEASE_VERSION ?= $(shell git describe --tags --always) export GIT_COMMIT := $(shell git rev-parse HEAD) # Commonly used locations SWIFT := "/usr/bin/swift" DEST_DIR ?= /usr/local/ ROOT_DIR := $(shell git rev-parse --show-toplevel) BUILD_BIN_DIR = $(shell $(SWIFT) build -c $(BUILD_CONFIGURATION) --show-bin-path) COV_DATA_DIR = $(shell $(SWIFT) test --show-coverage-path | xargs dirname) COV_REPORT_FILE = $(ROOT_DIR)/code-coverage-report STAGING_DIR := bin/$(BUILD_CONFIGURATION)/staging/ PKG_PATH := bin/$(BUILD_CONFIGURATION)/container-installer-unsigned.pkg DSYM_DIR := bin/$(BUILD_CONFIGURATION)/bundle/container-dSYM DSYM_PATH := bin/$(BUILD_CONFIGURATION)/bundle/container-dSYM.zip CODESIGN_OPTS ?= --force --sign - --timestamp=none # Conditionally use a temporary data directory for integration tests SYSTEM_START_OPTS := ifneq ($(strip $(APP_ROOT)),) SYSTEM_START_OPTS += --app-root "$(strip $(APP_ROOT))" endif ifneq ($(strip $(LOG_ROOT)),) SYSTEM_START_OPTS += --log-root "$(strip $(LOG_ROOT))" endif MACOS_VERSION := $(shell sw_vers -productVersion) MACOS_MAJOR := $(shell echo $(MACOS_VERSION) | cut -d. -f1) SUDO ?= sudo .DEFAULT_GOAL := all include Protobuf.Makefile .PHONY: all all: container all: init-block .PHONY: build build: @echo Building container binaries... @$(SWIFT) --version @$(SWIFT) build -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) .PHONY: cli cli: @echo Building container CLI... @$(SWIFT) --version @$(SWIFT) build -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --product container @echo Installing container CLI to bin/... @mkdir -p bin @install "$(BUILD_BIN_DIR)/container" "bin/container" .PHONY: container # Install binaries under project directory container: build @"$(MAKE)" BUILD_CONFIGURATION=$(BUILD_CONFIGURATION) DEST_DIR="$(ROOT_DIR)/" SUDO= install .PHONY: release release: BUILD_CONFIGURATION = release release: all .PHONY: init-block init-block: @echo Building initfs if containerization is in edit mode @scripts/install-init.sh $(SYSTEM_START_OPTS) .PHONY: install install: installer-pkg @echo Installing container installer package @if [ -z "$(SUDO)" ] ; then \ temp_dir=$$(mktemp -d) ; \ xar -xf $(PKG_PATH) -C $${temp_dir} ; \ (cd "$(DEST_DIR)" && pax -rz -f $${temp_dir}/Payload) ; \ rm -rf $${temp_dir} ; \ else \ $(SUDO) installer -pkg $(PKG_PATH) -target / ; \ fi $(STAGING_DIR): @echo Installing container binaries from "$(BUILD_BIN_DIR)" into "$(STAGING_DIR)"... @rm -rf "$(STAGING_DIR)" @mkdir -p "$(join $(STAGING_DIR), bin)" @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/bin)" @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/bin)" @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin)" @install "$(BUILD_BIN_DIR)/container" "$(join $(STAGING_DIR), bin/container)" @install "$(BUILD_BIN_DIR)/container-apiserver" "$(join $(STAGING_DIR), bin/container-apiserver)" @install "$(BUILD_BIN_DIR)/container-runtime-linux" "$(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/bin/container-runtime-linux)" @install config/container-runtime-linux-config.json "$(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/config.json)" @install "$(BUILD_BIN_DIR)/container-network-vmnet" "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/bin/container-network-vmnet)" @install config/container-network-vmnet-config.json "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/config.json)" @install "$(BUILD_BIN_DIR)/container-core-images" "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin/container-core-images)" @install config/container-core-images-config.json "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/config.json)" @echo Install update script @install scripts/update-container.sh "$(join $(STAGING_DIR), bin/update-container.sh)" @echo Install uninstaller script @install scripts/uninstall-container.sh "$(join $(STAGING_DIR), bin/uninstall-container.sh)" .PHONY: installer-pkg installer-pkg: $(STAGING_DIR) @echo Signing container binaries... @codesign $(CODESIGN_OPTS) --identifier com.apple.container.cli "$(join $(STAGING_DIR), bin/container)" @codesign $(CODESIGN_OPTS) --identifier com.apple.container.apiserver "$(join $(STAGING_DIR), bin/container-apiserver)" @codesign $(CODESIGN_OPTS) --prefix=com.apple.container. "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin/container-core-images)" @codesign $(CODESIGN_OPTS) --prefix=com.apple.container. --entitlements=signing/container-runtime-linux.entitlements "$(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/bin/container-runtime-linux)" @codesign $(CODESIGN_OPTS) --prefix=com.apple.container. --entitlements=signing/container-network-vmnet.entitlements "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/bin/container-network-vmnet)" @echo Creating application installer @pkgbuild --root "$(STAGING_DIR)" --identifier com.apple.container-installer --install-location /usr/local --version ${RELEASE_VERSION} $(PKG_PATH) @rm -rf "$(STAGING_DIR)" .PHONY: dsym dsym: @echo Copying debug symbols... @rm -rf "$(DSYM_DIR)" @mkdir -p "$(DSYM_DIR)" @cp -a "$(BUILD_BIN_DIR)/container-runtime-linux.dSYM" "$(DSYM_DIR)" @cp -a "$(BUILD_BIN_DIR)/container-network-vmnet.dSYM" "$(DSYM_DIR)" @cp -a "$(BUILD_BIN_DIR)/container-core-images.dSYM" "$(DSYM_DIR)" @cp -a "$(BUILD_BIN_DIR)/container-apiserver.dSYM" "$(DSYM_DIR)" @cp -a "$(BUILD_BIN_DIR)/container.dSYM" "$(DSYM_DIR)" @echo Packaging the debug symbols... @(cd "$(dir $(DSYM_DIR))" ; zip -r $(notdir $(DSYM_PATH)) $(notdir $(DSYM_DIR))) .PHONY: test test: @$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --skip TestCLI .PHONY: install-kernel install-kernel: @echo Stopping system before installing kernel @bin/container system stop || true @echo Starting system to install kernel @bin/container --debug system start --timeout 60 --enable-kernel-install $(SYSTEM_START_OPTS) .PHONY: coverage coverage: init-block @echo Ensuring apiserver stopped before the coverage analysis @bin/container system stop && sleep 3 && scripts/ensure-container-stopped.sh @bin/container --debug system start $(SYSTEM_START_OPTS) && \ echo "Starting coverage analysis" && \ { \ exit_code=0; \ $(SWIFT) test --no-parallel --enable-code-coverage -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) || exit_code=1 ; \ echo Ensuring apiserver stopped after the CLI integration tests ; \ scripts/ensure-container-stopped.sh ; \ echo Generating code coverage report... ; \ xcrun llvm-profdata merge -sparse $(COV_DATA_DIR)/*.profraw -o $(COV_DATA_DIR)/default.profdata ; \ xcrun llvm-cov show --compilation-dir=`pwd` \ -instr-profile=$(COV_DATA_DIR)/default.profdata \ --ignore-filename-regex=".build/" \ --ignore-filename-regex=".pb.swift" \ --ignore-filename-regex=".proto" \ --ignore-filename-regex=".grpc.swift" \ $(BUILD_BIN_DIR)/containerPackageTests.xctest/Contents/MacOS/containerPackageTests > $(COV_REPORT_FILE) ; \ echo Code coverage report generated: $(COV_REPORT_FILE) ; \ exit $${exit_code} ; \ } .PHONY: integration integration: init-block @echo Ensuring apiserver stopped before the CLI integration tests... @bin/container system stop && sleep 3 && scripts/ensure-container-stopped.sh @echo Running the integration tests... @bin/container --debug system start --timeout 60 $(SYSTEM_START_OPTS) && \ echo "Starting CLI integration tests" && \ { \ exit_code=0; \ CLITEST_LOG_ROOT=$(LOG_ROOT) && export CLITEST_LOG_ROOT ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLINetwork || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunLifecycle || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIExecCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLICreateCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand1 || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand2 || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand3 || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIPruneCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRegistry || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIStatsCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIImagesCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunBase || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunInitImage || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIBuildBase || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIExportCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIVolumes || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIKernelSet || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIAnonymousVolumes || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLINoParallelCases || exit_code=1 ; \ echo Ensuring apiserver stopped after the CLI integration tests ; \ scripts/ensure-container-stopped.sh ; \ exit $${exit_code} ; \ } .PHONY: fmt fmt: swift-fmt update-licenses .PHONY: check check: swift-fmt-check check-licenses .PHONY: swift-fmt SWIFT_SRC = $(shell find . -type f -name '*.swift' -not -path "*/.*" -not -path "*.pb.swift" -not -path "*.grpc.swift" -not -path "*/checkouts/*") swift-fmt: @echo Applying the standard code formatting... @$(SWIFT) format --recursive --configuration .swift-format -i $(SWIFT_SRC) swift-fmt-check: @echo Applying the standard code formatting... @$(SWIFT) format lint --recursive --strict --configuration .swift-format-nolint $(SWIFT_SRC) .PHONY: update-licenses update-licenses: @echo Updating license headers... @./scripts/ensure-hawkeye-exists.sh @.local/bin/hawkeye format --fail-if-unknown --fail-if-updated false .PHONY: check-licenses check-licenses: @echo Checking license headers existence in source files... @./scripts/ensure-hawkeye-exists.sh @.local/bin/hawkeye check --fail-if-unknown .PHONY: pre-commit pre-commit: cp Scripts/pre-commit.fmt .git/hooks touch .git/hooks/pre-commit cat .git/hooks/pre-commit | grep -v 'hooks/pre-commit\.fmt' > /tmp/pre-commit.new || true echo 'PRECOMMIT_NOFMT=$${PRECOMMIT_NOFMT} $$(git rev-parse --show-toplevel)/.git/hooks/pre-commit.fmt' >> /tmp/pre-commit.new mv /tmp/pre-commit.new .git/hooks/pre-commit chmod +x .git/hooks/pre-commit .PHONY: serve-docs serve-docs: @echo 'to browse: open http://127.0.0.1:8000/container/documentation/' @rm -rf _serve @mkdir -p _serve @cp -a _site _serve/container @python3 -m http.server --bind 127.0.0.1 --directory ./_serve .PHONY: docs docs: @echo Updating API documentation... @rm -rf _site @scripts/make-docs.sh _site container .PHONY: cleancontent cleancontent: @bin/container system stop || true @echo Cleaning the content... @rm -rf ~/Library/Application\ Support/com.apple.container .PHONY: clean clean: @echo Cleaning build files... @rm -rf bin/ libexec/ @rm -rf _site _serve @rm -f $(COV_REPORT_FILE) @$(SWIFT) package clean ================================================ FILE: Package.resolved ================================================ { "originHash" : "4ec05f4e83999a89d3397d0657536924d4a425d7f0e3f0fd6a3578e34c924502", "pins" : [ { "identity" : "async-http-client", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { "revision" : "60235983163d040f343a489f7e2e77c1918a8bd9", "version" : "1.26.1" } }, { "identity" : "containerization", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/containerization.git", "state" : { "revision" : "420b915d8b4b0bc5d7edc638b985ee8fd32a3fbe", "version" : "0.29.0" } }, { "identity" : "grpc-swift", "kind" : "remoteSourceControl", "location" : "https://github.com/grpc/grpc-swift.git", "state" : { "revision" : "a56a157218877ef3e9625f7e1f7b2cb7e46ead1b", "version" : "1.26.1" } }, { "identity" : "swift-algorithms", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-algorithms.git", "state" : { "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", "version" : "1.2.1" } }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { "revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3", "version" : "1.6.1" } }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", "version" : "1.4.0" } }, { "identity" : "swift-async-algorithms", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", "version" : "1.1.1" } }, { "identity" : "swift-atomics", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", "version" : "1.3.0" } }, { "identity" : "swift-certificates", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { "revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130", "version" : "1.17.0" } }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", "version" : "1.2.1" } }, { "identity" : "swift-crypto", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { "revision" : "334e682869394ee239a57dbe9262bff3cd9495bd", "version" : "3.14.0" } }, { "identity" : "swift-docc-plugin", "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-docc-plugin.git", "state" : { "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", "version" : "1.4.5" } }, { "identity" : "swift-docc-symbolkit", "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-docc-symbolkit", "state" : { "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "version" : "1.0.0" } }, { "identity" : "swift-http-structured-headers", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", "version" : "1.6.0" } }, { "identity" : "swift-http-types", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types.git", "state" : { "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", "version" : "1.5.1" } }, { "identity" : "swift-log", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", "version" : "1.6.4" } }, { "identity" : "swift-nio", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { "revision" : "1c30f0f2053b654e3d1302492124aa6d242cdba7", "version" : "2.86.0" } }, { "identity" : "swift-nio-extras", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { "revision" : "a55c3dd3a81d035af8a20ce5718889c0dcab073d", "version" : "1.29.0" } }, { "identity" : "swift-nio-http2", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { "revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5", "version" : "1.38.0" } }, { "identity" : "swift-nio-ssl", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { "revision" : "173cc69a058623525a58ae6710e2f5727c663793", "version" : "2.36.0" } }, { "identity" : "swift-nio-transport-services", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { "revision" : "e645014baea2ec1c2db564410c51a656cf47c923", "version" : "1.25.1" } }, { "identity" : "swift-numerics", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-numerics.git", "state" : { "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", "version" : "1.1.1" } }, { "identity" : "swift-protobuf", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { "revision" : "102a647b573f60f73afdce5613a51d71349fe507", "version" : "1.30.0" } }, { "identity" : "swift-service-lifecycle", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { "revision" : "e7187309187695115033536e8fc9b2eb87fd956d", "version" : "2.8.0" } }, { "identity" : "swift-system", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { "revision" : "890830fff1a577dc83134890c7984020c5f6b43b", "version" : "1.6.2" } }, { "identity" : "zstd", "kind" : "remoteSourceControl", "location" : "https://github.com/facebook/zstd.git", "state" : { "revision" : "f8745da6ff1ad1e7bab384bd1f9d742439278e99", "version" : "1.5.7" } } ], "version" : 3 } ================================================ FILE: Package.swift ================================================ // swift-tools-version: 6.2 //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// // The swift-tools-version declares the minimum version of Swift required to build this package. import Foundation import PackageDescription let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0" let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified" let builderShimVersion = "0.10.0" let scVersion = "0.29.0" let package = Package( name: "container", platforms: [.macOS("15")], products: [ .library(name: "ContainerCommands", targets: ["ContainerCommands"]), .library(name: "ContainerBuild", targets: ["ContainerBuild"]), .library(name: "ContainerAPIService", targets: ["ContainerAPIService"]), .library(name: "ContainerAPIClient", targets: ["ContainerAPIClient"]), .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService", "ContainerNetworkServiceClient"]), .library(name: "ContainerSandboxService", targets: ["ContainerSandboxService", "ContainerSandboxServiceClient"]), .library(name: "ContainerResource", targets: ["ContainerResource"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), .library(name: "ContainerPlugin", targets: ["ContainerPlugin"]), .library(name: "ContainerVersion", targets: ["ContainerVersion"]), .library(name: "ContainerXPC", targets: ["ContainerXPC"]), .library(name: "ContainerOS", targets: ["ContainerOS"]), .library(name: "SocketForwarder", targets: ["SocketForwarder"]), .library(name: "TerminalProgress", targets: ["TerminalProgress"]), ], dependencies: [ .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.2.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.80.0"), .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.29.0"), .package(url: "https://github.com/apple/swift-system.git", from: "1.4.0"), .package(url: "https://github.com/grpc/grpc-swift.git", from: "1.26.0"), .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.20.1"), .package(url: "https://github.com/swiftlang/swift-docc-plugin.git", from: "1.1.0"), ], targets: [ .executableTarget( name: "container", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), "ContainerAPIClient", "ContainerCommands", ], path: "Sources/CLI" ), .testTarget( name: "CLITests", dependencies: [ .product(name: "AsyncHTTPClient", package: "async-http-client"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationArchive", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), "ContainerBuild", "ContainerLog", "ContainerResource", ], path: "Tests/CLITests" ), .target( name: "ContainerCommands", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), .product(name: "SwiftProtobuf", package: "swift-protobuf"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationOCI", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), "ContainerBuild", "ContainerAPIClient", "ContainerLog", "ContainerNetworkService", "ContainerPersistence", "ContainerPlugin", "ContainerResource", "ContainerVersion", "ContainerXPC", "TerminalProgress", ], path: "Sources/ContainerCommands" ), .target( name: "ContainerBuild", dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "NIO", package: "swift-nio"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationArchive", package: "containerization"), .product(name: "ContainerizationOCI", package: "containerization"), .product(name: "ArgumentParser", package: "swift-argument-parser"), "ContainerAPIClient", ] ), .testTarget( name: "ContainerBuildTests", dependencies: [ "ContainerBuild" ] ), .executableTarget( name: "container-apiserver", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "AsyncHTTPClient", package: "async-http-client"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), .product(name: "ContainerizationEXT4", package: "containerization"), .product(name: "GRPC", package: "grpc-swift"), .product(name: "Logging", package: "swift-log"), "ContainerAPIService", "ContainerAPIClient", "ContainerLog", "ContainerNetworkService", "ContainerPersistence", "ContainerPlugin", "ContainerResource", "ContainerVersion", "ContainerXPC", "ContainerOS", "DNSServer", ], path: "Sources/Helpers/APIServer" ), .target( name: "ContainerAPIService", dependencies: [ .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationArchive", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), .product(name: "Logging", package: "swift-log"), .product(name: "SystemPackage", package: "swift-system"), "CVersion", "ContainerAPIClient", "ContainerNetworkServiceClient", "ContainerPersistence", "ContainerPlugin", "ContainerResource", "ContainerSandboxServiceClient", "ContainerVersion", "ContainerXPC", "TerminalProgress", ], path: "Sources/Services/ContainerAPIService/Server" ), .target( name: "ContainerAPIClient", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationArchive", package: "containerization"), .product(name: "ContainerizationOCI", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), .product(name: "Logging", package: "swift-log"), .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), .product(name: "SystemPackage", package: "swift-system"), "ContainerImagesServiceClient", "ContainerPersistence", "ContainerPlugin", "ContainerResource", "ContainerXPC", "TerminalProgress", ], path: "Sources/Services/ContainerAPIService/Client" ), .testTarget( name: "ContainerAPIClientTests", dependencies: [ .product(name: "Containerization", package: "containerization"), "ContainerAPIClient", "ContainerPersistence", ] ), .executableTarget( name: "container-core-images", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), .product(name: "SystemPackage", package: "swift-system"), "ContainerImagesService", "ContainerLog", "ContainerPlugin", "ContainerVersion", "ContainerXPC", ], path: "Sources/Helpers/Images" ), .target( name: "ContainerImagesService", dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationArchive", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "ContainerizationOCI", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), "ContainerAPIClient", "ContainerImagesServiceClient", "ContainerLog", "ContainerPersistence", "ContainerResource", "ContainerXPC", "TerminalProgress", ], path: "Sources/Services/ContainerImagesService/Server" ), .target( name: "ContainerImagesServiceClient", dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), "ContainerXPC", "ContainerLog", ], path: "Sources/Services/ContainerImagesService/Client" ), .executableTarget( name: "container-network-vmnet", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "ContainerizationIO", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), "ContainerLog", "ContainerNetworkService", "ContainerNetworkServiceClient", "ContainerPlugin", "ContainerResource", "ContainerVersion", "ContainerXPC", ], path: "Sources/Helpers/NetworkVmnet" ), .target( name: "ContainerNetworkService", dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), "ContainerNetworkServiceClient", "ContainerPersistence", "ContainerResource", "ContainerXPC", ], path: "Sources/Services/ContainerNetworkService/Server" ), .testTarget( name: "ContainerNetworkServiceTests", dependencies: [ .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), "ContainerNetworkService", ] ), .target( name: "ContainerNetworkServiceClient", dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), "ContainerLog", "ContainerResource", "ContainerXPC", ], path: "Sources/Services/ContainerNetworkService/Client" ), .executableTarget( name: "container-runtime-linux", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), .product(name: "GRPC", package: "grpc-swift"), .product(name: "Containerization", package: "containerization"), "ContainerLog", "ContainerPlugin", "ContainerResource", "ContainerSandboxService", "ContainerSandboxServiceClient", "ContainerVersion", "ContainerXPC", ], path: "Sources/Helpers/RuntimeLinux" ), .target( name: "ContainerSandboxService", dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), .product(name: "ArgumentParser", package: "swift-argument-parser"), "ContainerAPIClient", "ContainerOS", "ContainerPersistence", "ContainerResource", "ContainerSandboxServiceClient", "ContainerXPC", "SocketForwarder", ], path: "Sources/Services/ContainerSandboxService/Server" ), .target( name: "ContainerSandboxServiceClient", dependencies: [ "ContainerAPIClient", "ContainerResource", "ContainerXPC", ], path: "Sources/Services/ContainerSandboxService/Client" ), .target( name: "ContainerResource", dependencies: [ .product(name: "Containerization", package: "containerization"), "ContainerXPC", "CAuditToken", "CVersion", ] ), .testTarget( name: "ContainerResourceTests", dependencies: [ .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), "ContainerAPIService", "ContainerResource", ] ), .target( name: "ContainerLog", dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "SystemPackage", package: "swift-system"), ] ), .target( name: "ContainerPersistence", dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), "CVersion", "ContainerVersion", ] ), .target( name: "ContainerPlugin", dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "ContainerizationOS", package: "containerization"), .product(name: "SystemPackage", package: "swift-system"), "ContainerVersion", ] ), .testTarget( name: "ContainerPluginTests", dependencies: [ "ContainerPlugin" ] ), .testTarget( name: "ContainerSandboxServiceTests", dependencies: [ .product(name: "Containerization", package: "containerization"), "ContainerResource", "ContainerSandboxServiceClient", ] ), .target( name: "ContainerXPC", dependencies: [ .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "Logging", package: "swift-log"), "CAuditToken", ] ), .target( name: "ContainerOS", dependencies: [ .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), ], path: "Sources/ContainerOS" ), .target( name: "TerminalProgress", dependencies: [ .product(name: "ContainerizationOS", package: "containerization") ] ), .testTarget( name: "TerminalProgressTests", dependencies: ["TerminalProgress"] ), .target( name: "DNSServer", dependencies: [ .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), .product(name: "Logging", package: "swift-log"), .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), ] ), .testTarget( name: "DNSServerTests", dependencies: [ "DNSServer" ] ), .testTarget( name: "ContainerOSTests", dependencies: [ "ContainerOS" ] ), .target( name: "SocketForwarder", dependencies: [ .product(name: "Collections", package: "swift-collections"), .product(name: "Logging", package: "swift-log"), .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOFoundationCompat", package: "swift-nio"), ] ), .testTarget( name: "SocketForwarderTests", dependencies: ["SocketForwarder"] ), .target( name: "ContainerVersion", dependencies: [ "CVersion" ], ), .target( name: "CVersion", dependencies: [], publicHeadersPath: "include", cSettings: [ .define("CZ_VERSION", to: "\"\(scVersion)\""), .define("GIT_COMMIT", to: "\"\(gitCommit)\""), .define("RELEASE_VERSION", to: "\"\(releaseVersion)\""), .define("BUILDER_SHIM_VERSION", to: "\"\(builderShimVersion)\""), ], ), .target( name: "CAuditToken", dependencies: [], publicHeadersPath: "include", linkerSettings: [ .linkedLibrary("bsm") ] ), ] ) ================================================ FILE: Protobuf.Makefile ================================================ # Copyright © 2025-2026 Apple Inc. and the container project authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ROOT_DIR := $(shell git rev-parse --show-toplevel) LOCAL_DIR := $(ROOT_DIR)/.local LOCAL_BIN_DIR := $(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 PROTOC_ZIP := protoc-$(PROTOC_VERSION)-osx-universal_binary.zip PROTOC := $(LOCAL_BIN_DIR)/protoc@$(PROTOC_VERSION)/protoc $(PROTOC): @echo Downloading protocol buffers... @mkdir -p $(LOCAL_DIR) @curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v$(PROTOC_VERSION)/$(PROTOC_ZIP) @mkdir -p $(dir $@) @unzip -jo $(PROTOC_ZIP) bin/protoc -d $(dir $@) @unzip -o $(PROTOC_ZIP) 'include/*' -d $(dir $@) @rm -f $(PROTOC_ZIP) .PHONY: protoc-gen-swift protoc-gen-swift: @$(SWIFT) build --product protoc-gen-swift @$(SWIFT) build --product protoc-gen-grpc-swift .PHONY: protos protos: $(PROTOC) protoc-gen-swift @echo Generating protocol buffers source code... @mkdir -p $(LOCAL_DIR) @if [ ! -d "$(LOCAL_DIR)/container-builder-shim" ]; then \ cd $(LOCAL_DIR) && git clone --branch $(BUILDER_SHIM_VERSION) --depth 1 $(BUILDER_SHIM_REPO); \ fi @$(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 \ --proto_path=$(LOCAL_DIR)/container-builder-shim/pkg/api \ --grpc-swift_out="Sources/ContainerBuild" \ --grpc-swift_opt=Visibility=Public \ --swift_out="Sources/ContainerBuild" \ --swift_opt=Visibility=Public \ -I. @"$(MAKE)" update-licenses .PHONY: clean-proto-tools clean-proto-tools: @echo Cleaning proto tools... @rm -rf $(LOCAL_DIR)/bin/protoc* @rm -rf $(LOCAL_DIR)/container-builder-shim ================================================ FILE: README.md ================================================ # `container` `container` is a tool that you can use to create and run Linux containers as lightweight virtual machines on your Mac. It's written in Swift, and optimized for Apple silicon. The tool consumes and produces [OCI-compatible container images](https://github.com/opencontainers/image-spec), so you can pull and run images from any standard container registry. You can push images that you build to those registries as well, and run the images in any other OCI-compatible application. `container` uses the [Containerization](https://github.com/apple/containerization) Swift package for low level container, image, and process management. ![introductory movie showing some basic commands](./docs/assets/landing-movie.gif) ## Get started ### Requirements You need a Mac with Apple silicon to run `container`. To build it, see the [BUILDING](./BUILDING.md) document. `container` is supported on macOS 26, since it takes advantage of new features and enhancements to virtualization and networking in this release. We do not support older versions of macOS and the `container` maintainers typically will not address issues that cannot be reproduced on the macOS 26. ### Initial install Download the latest signed installer package for `container` from the [GitHub release page](https://github.com/apple/container/releases). 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: ```bash container system start ``` ### Upgrade or downgrade For both upgrading and downgrading, you can manually download and install the signed installer package by following the steps from [initial install](#initial-install) or use the `update-container.sh` script (installed to `/usr/local/bin`). If you're upgrading and downgrading, you must stop your existing `container`: ```bash container system stop ``` For upgrading to the latest release version, simply run the command below: ```bash /usr/local/bin/update-container.sh ``` If you're downgrading, you must uninstall your existing `container` (the `-k` flag keeps your user data, while `-d` removes it): ```bash /usr/local/bin/uninstall-container.sh -k /usr/local/bin/update-container.sh -v 0.3.0 ``` Start the system service with: ```bash container system start ``` ### Uninstall Use the `uninstall-container.sh` script (installed to `/usr/local/bin`) to remove `container` from your system. To remove your user data along with the tool, run: ```bash /usr/local/bin/uninstall-container.sh -d ``` To retain your user data so that it is available should you reinstall later, run: ```bash /usr/local/bin/uninstall-container.sh -k ``` ## Next steps - Take [a guided tour of `container`](./docs/tutorial.md) by building, running, and publishing a simple web server image. - Learn how to [use various `container` features](./docs/how-to.md). - Read a brief description and [technical overview](./docs/technical-overview.md) of `container`. - Browse the [full command reference](./docs/command-reference.md). - [Build and run](./BUILDING.md) `container` on your own development system. - View the project [API documentation](https://apple.github.io/container/documentation/). ## 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 patch 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. ================================================ FILE: SECURITY.md ================================================ # Security disclosure process If you believe that you have discovered a security or privacy vulnerability in our open source software, please report it to us using the [GitHub private vulnerability feature](https://github.com/apple/container/security/advisories/new). Reports should include specific product and software version(s) that you believe are affected; a technical description of the behavior that you observed and the behavior that you expected; the steps required to reproduce the issue; and a proof of concept or exploit. The project team will do their best to acknowledge receiving all security reports within 7 days of submission. This initial acknowledgment is neither acceptance nor rejection of your report. The project team may come back to you with further questions or invite you to collaborate while working through the details of your report. Keep these additional guidelines in mind when submitting your report: * Reports concerning known, publicly disclosed CVEs can be submitted as normal issues to this project. * Output from automated security scans or fuzzers MUST include additional context demonstrating the vulnerability with a proof of concept or working exploit. * Application crashes due to malformed inputs are typically not treated as security vulnerabilities, unless they are shown to also impact other processes on the system. While we welcome reports for open source software projects, they are not eligible for Apple Security Bounties. ================================================ FILE: Sources/CAuditToken/AuditToken.c ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// // This file is required for Xcode to generate `CAuditToken.o`. ================================================ FILE: Sources/CAuditToken/include/AuditToken.h ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// #include #include void xpc_dictionary_get_audit_token(xpc_object_t xdict, audit_token_t *token); ================================================ FILE: Sources/CLI/ContainerCLI.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerCommands @main public struct ContainerCLI: AsyncParsableCommand { public init() {} @Argument(parsing: .captureForPassthrough) var arguments: [String] = [] public static let configuration = Application.configuration public static func main() async throws { try await Application.main() } public func run() async throws { var application = try Application.parse(arguments) try application.validate() try application.run() } } ================================================ FILE: Sources/CVersion/Version.c ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// #include "Version.h" const char* get_git_commit() { return GIT_COMMIT; } const char* get_release_version() { return RELEASE_VERSION; } const char* get_swift_containerization_version() { return CZ_VERSION; } const char* get_container_builder_shim_version() { return BUILDER_SHIM_VERSION; } ================================================ FILE: Sources/CVersion/include/Version.h ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// #ifndef CZ_VERSION #define CZ_VERSION "latest" #endif #ifndef GIT_COMMIT #define GIT_COMMIT "unspecified" #endif #ifndef RELEASE_VERSION #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(); ================================================ FILE: Sources/ContainerBuild/BuildAPI+Extensions.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Containerization import ContainerizationOCI public typealias IO = Com_Apple_Container_Build_V1_IO public typealias InfoRequest = Com_Apple_Container_Build_V1_InfoRequest public typealias InfoResponse = Com_Apple_Container_Build_V1_InfoResponse public typealias ClientStream = Com_Apple_Container_Build_V1_ClientStream public typealias ServerStream = Com_Apple_Container_Build_V1_ServerStream public typealias ImageTransfer = Com_Apple_Container_Build_V1_ImageTransfer public typealias BuildTransfer = Com_Apple_Container_Build_V1_BuildTransfer public typealias BuilderClient = Com_Apple_Container_Build_V1_BuilderNIOClient public typealias BuilderClientAsync = Com_Apple_Container_Build_V1_BuilderAsyncClient public typealias BuilderClientProtocol = Com_Apple_Container_Build_V1_BuilderClientProtocol public typealias BuilderClientAsyncProtocol = Com_Apple_Container_Build_V1_BuilderAsyncClient extension BuildTransfer { func stage() -> String? { let stage = self.metadata["stage"] return stage == "" ? nil : stage } func method() -> String? { let method = self.metadata["method"] return method == "" ? nil : method } func includePatterns() -> [String]? { guard let includePatternsString = self.metadata["include-patterns"] else { return nil } return includePatternsString == "" ? nil : includePatternsString.components(separatedBy: ",") } func followPaths() -> [String]? { guard let followPathString = self.metadata["followpaths"] else { return nil } return followPathString == "" ? nil : followPathString.components(separatedBy: ",") } func mode() -> String? { self.metadata["mode"] } func size() -> Int? { guard let sizeStr = self.metadata["size"] else { return nil } return sizeStr == "" ? nil : Int(sizeStr) } func offset() -> UInt64? { guard let offsetStr = self.metadata["offset"] else { return nil } return offsetStr == "" ? nil : UInt64(offsetStr) } func len() -> Int? { guard let lenStr = self.metadata["length"] else { return nil } return lenStr == "" ? nil : Int(lenStr) } } extension ImageTransfer { func stage() -> String? { self.metadata["stage"] } func method() -> String? { self.metadata["method"] } func ref() -> String? { self.metadata["ref"] } func platform() throws -> Platform? { let metadata = self.metadata guard let platform = metadata["platform"] else { return nil } return try Platform(from: platform) } func mode() -> String? { self.metadata["mode"] } func size() -> Int? { let metadata = self.metadata guard let sizeStr = metadata["size"] else { return nil } return Int(sizeStr) } func len() -> Int? { let metadata = self.metadata guard let lenStr = metadata["length"] else { return nil } return Int(lenStr) } func offset() -> UInt64? { let metadata = self.metadata guard let offsetStr = metadata["offset"] else { return nil } return UInt64(offsetStr) } } extension ServerStream { func getImageTransfer() -> ImageTransfer? { if case .imageTransfer(let v) = self.packetType { return v } return nil } func getBuildTransfer() -> BuildTransfer? { if case .buildTransfer(let v) = self.packetType { return v } return nil } func getIO() -> IO? { if case .io(let v) = self.packetType { return v } return nil } } ================================================ FILE: Sources/ContainerBuild/BuildFSSync.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Collections import ContainerAPIClient import ContainerizationArchive import ContainerizationOCI import CryptoKit import Foundation import GRPC actor BuildFSSync: BuildPipelineHandler { let contextDir: URL init(_ contextDir: URL) throws { guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else { throw Error.contextNotFound(contextDir.cleanPath) } guard try contextDir.isDir() else { throw Error.contextIsNotDirectory(contextDir.cleanPath) } self.contextDir = contextDir } nonisolated func accept(_ packet: ServerStream) throws -> Bool { guard let buildTransfer = packet.getBuildTransfer() else { return false } guard buildTransfer.stage() == "fssync" else { return false } return true } func handle(_ sender: AsyncStream.Continuation, _ packet: ServerStream) async throws { guard let buildTransfer = packet.getBuildTransfer() else { throw Error.buildTransferMissing } guard let method = buildTransfer.method() else { throw Error.methodMissing } switch try FSSyncMethod(method) { case .read: try await self.read(sender, buildTransfer, packet.buildID) case .info: try await self.info(sender, buildTransfer, packet.buildID) case .walk: try await self.walk(sender, buildTransfer, packet.buildID) } } func read(_ sender: AsyncStream.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws { let offset: UInt64 = packet.offset() ?? 0 let size: Int = packet.len() ?? 0 var path: URL if packet.source.hasPrefix("/") { path = URL(fileURLWithPath: packet.source).standardizedFileURL } else { path = contextDir .appendingPathComponent(packet.source) .standardizedFileURL } if !FileManager.default.fileExists(atPath: path.cleanPath) { path = URL(filePath: self.contextDir.cleanPath) path.append(components: packet.source.cleanPathComponent) } let data = try { if try path.isDir() { return Data() } let file = try LocalContent(path: path.standardizedFileURL) return try file.data(offset: offset, length: size) ?? Data() }() let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true, data: data) var response = ClientStream() response.buildID = buildID response.buildTransfer = transfer response.packetType = .buildTransfer(transfer) sender.yield(response) } func info(_ sender: AsyncStream.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws { let path: URL if packet.source.hasPrefix("/") { path = URL(fileURLWithPath: packet.source).standardizedFileURL } else { path = contextDir .appendingPathComponent(packet.source) .standardizedFileURL } let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true) var response = ClientStream() response.buildID = buildID response.buildTransfer = transfer response.packetType = .buildTransfer(transfer) sender.yield(response) } private struct DirEntry: Hashable { let url: URL let isDirectory: Bool let relativePath: String func hash(into hasher: inout Hasher) { hasher.combine(relativePath) } static func == (lhs: DirEntry, rhs: DirEntry) -> Bool { lhs.relativePath == rhs.relativePath } } func walk( _ sender: AsyncStream.Continuation, _ packet: BuildTransfer, _ buildID: String ) async throws { let wantsTar = packet.mode() == "tar" var entries: [String: Set] = [:] let followPaths: [String] = packet.followPaths() ?? [] let followPathsWalked = try walk(root: self.contextDir, includePatterns: followPaths) for url in followPathsWalked { guard self.contextDir.absoluteURL.cleanPath != url.absoluteURL.cleanPath else { continue } guard self.contextDir.parentOf(url) else { continue } let relPath = try url.relativeChildPath(to: contextDir) let parentPath = try url.deletingLastPathComponent().relativeChildPath(to: contextDir) let entry = DirEntry(url: url, isDirectory: url.hasDirectoryPath, relativePath: relPath) entries[parentPath, default: []].insert(entry) if url.isSymlink { let target: URL = url.resolvingSymlinksInPath() if self.contextDir.parentOf(target) { let relPath = try target.relativeChildPath(to: self.contextDir) let entry = DirEntry(url: target, isDirectory: target.hasDirectoryPath, relativePath: relPath) let parentPath: String = try target.deletingLastPathComponent().relativeChildPath(to: self.contextDir) entries[parentPath, default: []].insert(entry) } } } var fileOrder = [String]() try processDirectory("", inputEntries: entries, processedPaths: &fileOrder) if !wantsTar { let fileInfos = try fileOrder.map { rel -> FileInfo in try FileInfo(path: contextDir.appendingPathComponent(rel), contextDir: contextDir) } let data = try JSONEncoder().encode(fileInfos) let transfer = BuildTransfer( id: packet.id, source: packet.source, complete: true, isDir: false, metadata: [ "os": "linux", "stage": "fssync", "mode": "json", ], data: data ) var resp = ClientStream() resp.buildID = buildID resp.buildTransfer = transfer resp.packetType = .buildTransfer(transfer) sender.yield(resp) return } let tarURL = URL.temporaryDirectory .appendingPathComponent(UUID().uuidString + ".tar") defer { try? FileManager.default.removeItem(at: tarURL) } let writerCfg = ArchiveWriterConfiguration( format: .paxRestricted, filter: .none) let tarHash = try Archiver.compress( source: contextDir, destination: tarURL, writerConfiguration: writerCfg ) { url in guard let rel = try? url.relativeChildPath(to: contextDir) else { return nil } guard let parent = try? url.deletingLastPathComponent().relativeChildPath(to: self.contextDir) else { return nil } guard let items = entries[parent] else { return nil } let include = items.contains { item in item.relativePath == rel } guard include else { return nil } return Archiver.ArchiveEntryInfo( pathOnHost: url, pathInArchive: URL(fileURLWithPath: rel)) } let hash = tarHash.compactMap { String(format: "%02x", $0) }.joined() let header = BuildTransfer( id: packet.id, source: tarURL.path, complete: false, isDir: false, metadata: [ "os": "linux", "stage": "fssync", "mode": "tar", "hash": hash, ] ) var resp = ClientStream() resp.buildID = buildID resp.buildTransfer = header resp.packetType = .buildTransfer(header) sender.yield(resp) for try await chunk in try tarURL.bufferedCopyReader() { let part = BuildTransfer( id: packet.id, source: tarURL.path, complete: false, isDir: false, metadata: [ "os": "linux", "stage": "fssync", "mode": "tar", ], data: chunk ) var resp = ClientStream() resp.buildID = buildID resp.buildTransfer = part resp.packetType = .buildTransfer(part) sender.yield(resp) } let done = BuildTransfer( id: packet.id, source: tarURL.path, complete: true, isDir: false, metadata: [ "os": "linux", "stage": "fssync", "mode": "tar", ], data: Data() ) var finalResp = ClientStream() finalResp.buildID = buildID finalResp.buildTransfer = done finalResp.packetType = .buildTransfer(done) sender.yield(finalResp) } func walk(root: URL, includePatterns: [String]) throws -> [URL] { let globber = Globber(root) for p in includePatterns { try globber.match(p) } return Array(globber.results) } private func processDirectory( _ currentDir: String, inputEntries: [String: Set], processedPaths: inout [String] ) throws { guard let entries = inputEntries[currentDir] else { return } // Sort purely by lexicographical order of relativePath let sortedEntries = entries.sorted { $0.relativePath < $1.relativePath } for entry in sortedEntries { processedPaths.append(entry.relativePath) if entry.isDirectory { try processDirectory( entry.relativePath, inputEntries: inputEntries, processedPaths: &processedPaths ) } } } struct FileInfo: Codable { let name: String let modTime: String let mode: UInt32 let size: UInt64 let isDir: Bool let uid: UInt32 let gid: UInt32 let target: String init(path: URL, contextDir: URL) throws { if path.isSymlink { let target: URL = path.resolvingSymlinksInPath() if contextDir.parentOf(target) { self.target = target.relativePathFrom(from: path) } else { self.target = target.cleanPath } } else { self.target = "" } self.name = try path.relativeChildPath(to: contextDir) self.modTime = try path.modTime() self.mode = try path.mode() self.size = try path.size() self.isDir = path.hasDirectoryPath self.uid = 0 self.gid = 0 } } enum FSSyncMethod: String { case read = "Read" case info = "Info" case walk = "Walk" init(_ method: String) throws { switch method { case "Read": self = .read case "Info": self = .info case "Walk": self = .walk default: throw Error.unknownMethod(method) } } } } extension BuildFSSync { enum Error: Swift.Error, CustomStringConvertible, Equatable { case buildTransferMissing case methodMissing case unknownMethod(String) case contextNotFound(String) case contextIsNotDirectory(String) case couldNotDetermineFileSize(String) case couldNotDetermineModTime(String) case couldNotDetermineFileMode(String) case invalidOffsetSizeForFile(String, UInt64, Int) case couldNotDetermineUID(String) case couldNotDetermineGID(String) case pathIsNotChild(String, String) var description: String { switch self { case .buildTransferMissing: return "buildTransfer field missing in packet" case .methodMissing: return "method is missing in request" case .unknownMethod(let m): return "unknown content-store method \(m)" case .contextNotFound(let path): return "context dir \(path) not found" case .contextIsNotDirectory(let path): return "context \(path) not a directory" case .couldNotDetermineFileSize(let path): return "could not determine size of file \(path)" case .couldNotDetermineModTime(let path): return "could not determine last modified time of \(path)" case .couldNotDetermineFileMode(let path): return "could not determine posix permissions (FileMode) of \(path)" case .invalidOffsetSizeForFile(let digest, let offset, let size): return "invalid request for file: \(digest) with offset: \(offset) size: \(size)" case .couldNotDetermineUID(let path): return "could not determine UID of file at path: \(path)" case .couldNotDetermineGID(let path): return "could not determine GID of file at path: \(path)" case .pathIsNotChild(let path, let parent): return "\(path) is not a child of \(parent)" } } } } extension BuildTransfer { fileprivate init(id: String, source: String, complete: Bool, isDir: Bool, metadata: [String: String], data: Data? = nil) { self.init() self.id = id self.source = source self.direction = .outof self.complete = complete self.metadata = metadata self.isDirectory = isDir if let data { self.data = data } } } extension URL { fileprivate func size() throws -> UInt64 { let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath) if let size = attrs[FileAttributeKey.size] as? UInt64 { return size } throw BuildFSSync.Error.couldNotDetermineFileSize(self.cleanPath) } fileprivate func modTime() throws -> String { let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath) if let date = attrs[FileAttributeKey.modificationDate] as? Date { return date.rfc3339() } throw BuildFSSync.Error.couldNotDetermineModTime(self.cleanPath) } fileprivate func isDir() throws -> Bool { let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath) guard let t = attrs[.type] as? FileAttributeType, t == .typeDirectory else { return false } return true } fileprivate func mode() throws -> UInt32 { let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath) if let mode = attrs[FileAttributeKey.posixPermissions] as? NSNumber { return mode.uint32Value } throw BuildFSSync.Error.couldNotDetermineFileMode(self.cleanPath) } fileprivate func uid() throws -> UInt32 { let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath) if let uid = attrs[.ownerAccountID] as? UInt32 { return uid } throw BuildFSSync.Error.couldNotDetermineUID(self.cleanPath) } fileprivate func gid() throws -> UInt32 { let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath) if let gid = attrs[.groupOwnerAccountID] as? UInt32 { return gid } throw BuildFSSync.Error.couldNotDetermineGID(self.cleanPath) } fileprivate func buildTransfer( id: String, contextDir: URL? = nil, complete: Bool = false, data: Data = Data() ) throws -> BuildTransfer { let p = try { if let contextDir { return try self.relativeChildPath(to: contextDir) } return self.cleanPath }() return BuildTransfer( id: id, source: String(p), complete: complete, isDir: try self.isDir(), metadata: [ "os": "linux", "stage": "fssync", "mode": String(try self.mode()), "size": String(try self.size()), "modified_at": try self.modTime(), "uid": String(try self.uid()), "gid": String(try self.gid()), ], data: data ) } } extension Date { fileprivate func rfc3339() -> String { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) // Adjust if necessary return dateFormatter.string(from: self) } } extension String { var cleanPathComponent: String { let trimmed = self.trimmingCharacters(in: CharacterSet(charactersIn: "/")) if let clean = trimmed.removingPercentEncoding { return clean } return trimmed } } ================================================ FILE: Sources/ContainerBuild/BuildFile.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 Logging public struct BuildFile { /// Tries to resolve either a Dockerfile or Containerfile relative to contextDir. /// Checks for Dockerfile, then falls back to Containerfile. public static func resolvePath(contextDir: String, log: Logger? = nil) throws -> String? { // Check for Dockerfile then Containerfile in context directory let dockerfilePath = URL(filePath: contextDir).appendingPathComponent("Dockerfile").path let containerfilePath = URL(filePath: contextDir).appendingPathComponent("Containerfile").path let dockerfileExists = FileManager.default.fileExists(atPath: dockerfilePath) let containerfileExists = FileManager.default.fileExists(atPath: containerfilePath) if dockerfileExists && containerfileExists { log?.info("Detected both Dockerfile and Containerfile, choosing Dockerfile") return dockerfilePath } if dockerfileExists { return dockerfilePath } if containerfileExists { return containerfilePath } return nil } } ================================================ FILE: Sources/ContainerBuild/BuildImageResolver.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import Containerization import ContainerizationOCI import Foundation import GRPC import Logging import TerminalProgress struct BuildImageResolver: BuildPipelineHandler { let contentStore: ContentStore let quiet: Bool let output: FileHandle let pull: Bool public init(_ contentStore: ContentStore, quiet: Bool = false, output: FileHandle = FileHandle.standardError, pull: Bool = false) throws { self.contentStore = contentStore self.quiet = quiet self.output = output self.pull = pull } func accept(_ packet: ServerStream) throws -> Bool { guard let imageTransfer = packet.getImageTransfer() else { return false } guard imageTransfer.stage() == "resolver" else { return false } guard imageTransfer.method() == "/resolve" else { return false } return true } func handle(_ sender: AsyncStream.Continuation, _ packet: ServerStream) async throws { guard let imageTransfer = packet.getImageTransfer() else { throw Error.imageTransferMissing } guard let ref = imageTransfer.ref() else { throw Error.tagMissing } guard let platform = try imageTransfer.platform() else { throw Error.platformMissing } let img = try await { let progressConfig = try ProgressConfig( terminal: self.output, description: "Pulling \(ref)", showPercent: true, showProgressBar: true, showSize: true, showSpeed: true, disableProgressUpdates: self.quiet ) let progress = ProgressBar(config: progressConfig) defer { progress.finish() } progress.start() if self.pull { return try await ClientImage.pull(reference: ref, platform: platform, progressUpdate: progress.handler) } // Use fetch() which checks cache first, then pulls if needed return try await ClientImage.fetch(reference: ref, platform: platform, progressUpdate: progress.handler) }() let index: Index = try await img.index() let buildID = packet.buildID let platforms = index.manifests.compactMap { $0.platform } for pl in platforms { if pl == platform { let manifest = try await img.manifest(for: pl) guard let ociImage: ContainerizationOCI.Image = try await self.contentStore.get(digest: manifest.config.digest) else { continue } let enc = JSONEncoder() let data = try enc.encode(ociImage) let transfer = try ImageTransfer( id: imageTransfer.id, digest: img.descriptor.digest, ref: ref, platform: platform.description, data: data ) var response = ClientStream() response.buildID = buildID response.imageTransfer = transfer response.packetType = .imageTransfer(transfer) sender.yield(response) return } } throw Error.unknownPlatformForImage(platform.description, ref) } } extension ImageTransfer { fileprivate init(id: String, digest: String, ref: String, platform: String, data: Data) throws { self.init() self.id = id self.tag = digest self.metadata = [ "os": "linux", "stage": "resolver", "method": "/resolve", "ref": ref, "platform": platform, ] self.complete = true self.direction = .into self.data = data } } extension BuildImageResolver { enum Error: Swift.Error, CustomStringConvertible { case imageTransferMissing case tagMissing case platformMissing case imageNameMissing case imageTagMissing case imageNotFound case indexDigestMissing(String) case unknownRegistry(String) case digestIsNotIndex(String) case digestIsNotManifest(String) case unknownPlatformForImage(String, String) var description: String { switch self { case .imageTransferMissing: return "imageTransfer is missing" case .tagMissing: return "tag parameter missing in metadata" case .platformMissing: return "platform parameter missing in metadata" case .imageNameMissing: return "image name missing in $ref parameter" case .imageTagMissing: return "image tag missing in $ref parameter" case .imageNotFound: return "image not found" case .indexDigestMissing(let ref): return "index digest is missing for image: \(ref)" case .unknownRegistry(let registry): return "registry \(registry) is unknown" case .digestIsNotIndex(let digest): return "digest \(digest) is not a descriptor to an index" case .digestIsNotManifest(let digest): return "digest \(digest) is not a descriptor to a manifest" case .unknownPlatformForImage(let platform, let ref): return "platform \(platform) for image \(ref) not found" } } } } ================================================ FILE: Sources/ContainerBuild/BuildPipelineHandler.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 GRPC import NIO protocol BuildPipelineHandler: Sendable { func accept(_ packet: ServerStream) throws -> Bool func handle(_ sender: AsyncStream.Continuation, _ packet: ServerStream) async throws } public actor BuildPipeline { let handlers: [BuildPipelineHandler] public init(_ config: Builder.BuildConfig) async throws { self.handlers = [ try BuildFSSync(URL(filePath: config.contextDir)), try BuildRemoteContentProxy(config.contentStore), try BuildImageResolver(config.contentStore, quiet: config.quiet, output: config.terminal?.handle ?? FileHandle.standardError, pull: config.pull), try BuildStdio(quiet: config.quiet, output: config.terminal?.handle ?? FileHandle.standardError), ] } public func run( sender: AsyncStream.Continuation, receiver: GRPCAsyncResponseStream ) async throws { defer { sender.finish() } try await untilFirstError { group in for try await packet in receiver { try Task.checkCancellation() for handler in self.handlers { try Task.checkCancellation() guard try handler.accept(packet) else { continue } try Task.checkCancellation() try await handler.handle(sender, packet) break } } } } /// untilFirstError() throws when any one of its submitted tasks fail. /// This is useful for asynchronous packet processing scenarios which /// have the following 3 requirements: /// - the packet should be processed without blocking I/O /// - the packet stream is never-ending /// - when the first task fails, the error needs to be propagated to the caller /// /// Usage: /// /// ``` /// try await untilFirstError { group in /// for try await packet in receiver { /// group.addTask { /// try await handler.handle(sender, packet) /// } /// } /// } /// ``` /// /// /// WithThrowingTaskGroup cannot accomplish this because it /// doesn't provide a mechanism to exit when one of the tasks fail /// before all the tasks have been added. i.e. it is more suitable for /// tasks that are limited. Here's a sample code where withThrowingTaskGroup /// doesn't solve the problem: /// /// ``` /// withThrowingTaskGroup { group in /// for try await packet in receiver { /// group.addTask { /// /* process packet */ /// } /// } /* this loop blocks forever waiting for more packets */ /// try await group.next() /* this never gets called */ /// } /// ``` /// The above closure never returns even when a handler encounters an error /// because the blocking operation `try await group.next()` cannot be /// called while iterating over the receiver stream. private func untilFirstError(body: @Sendable @escaping (UntilFirstError) async throws -> Void) async throws { let group = try await UntilFirstError() var taskContinuation: AsyncStream>.Continuation? let tasks = AsyncStream> { continuation in taskContinuation = continuation } guard let taskContinuation else { throw NSError( domain: "untilFirstError", code: 1, userInfo: [NSLocalizedDescriptionKey: "failed to initialize task continuation"]) } defer { taskContinuation.finish() } let stream = AsyncStream { continuation in let processTasks = Task { let taskStream = await group.tasks() defer { continuation.finish() } for await item in taskStream { try Task.checkCancellation() let addedTask = Task { try Task.checkCancellation() do { try await item() } catch { continuation.yield(error) await group.continuation?.finish() throw error } } taskContinuation.yield(addedTask) } } taskContinuation.yield(processTasks) let mainTask = Task { @Sendable in defer { continuation.finish() processTasks.cancel() taskContinuation.finish() } do { try Task.checkCancellation() try await body(group) } catch { continuation.yield(error) await group.continuation?.finish() throw error } } taskContinuation.yield(mainTask) } // when the first handler fails, cancel all tasks and throw error for await item in stream { try Task.checkCancellation() Task { for await task in tasks { task.cancel() } } throw item } // if none of the handlers fail, wait for all subtasks to complete for await task in tasks { try Task.checkCancellation() try await task.value } } private actor UntilFirstError { var stream: AsyncStream<@Sendable () async throws -> Void>? var continuation: AsyncStream<@Sendable () async throws -> Void>.Continuation? init() async throws { self.stream = AsyncStream { cont in self.continuation = cont } guard let _ = continuation else { throw NSError() } } func addTask(body: @Sendable @escaping () async throws -> Void) { if !Task.isCancelled { self.continuation?.yield(body) } } func tasks() -> AsyncStream<@Sendable () async throws -> Void> { self.stream! } } } ================================================ FILE: Sources/ContainerBuild/BuildRemoteContentProxy.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import Containerization import ContainerizationArchive import ContainerizationOCI import Foundation import GRPC struct BuildRemoteContentProxy: BuildPipelineHandler { let local: ContentStore public init(_ contentStore: ContentStore) throws { self.local = contentStore } func accept(_ packet: ServerStream) throws -> Bool { guard let imageTransfer = packet.getImageTransfer() else { return false } guard imageTransfer.stage() == "content-store" else { return false } return true } func handle(_ sender: AsyncStream.Continuation, _ packet: ServerStream) async throws { guard let imageTransfer = packet.getImageTransfer() else { throw Error.imageTransferMissing } guard let method = imageTransfer.method() else { throw Error.methodMissing } switch try ContentStoreMethod(method) { case .info: try await self.info(sender, imageTransfer, packet.buildID) case .readerAt: try await self.readerAt(sender, imageTransfer, packet.buildID) default: throw Error.unknownMethod(method) } } func info(_ sender: AsyncStream.Continuation, _ packet: ImageTransfer, _ buildID: String) async throws { let descriptor = try await local.get(digest: packet.tag) let size = try descriptor?.size() let transfer = try ImageTransfer( id: packet.id, digest: packet.tag, method: ContentStoreMethod.info.rawValue, size: size ) var response = ClientStream() response.buildID = buildID response.imageTransfer = transfer response.packetType = .imageTransfer(transfer) sender.yield(response) } func readerAt(_ sender: AsyncStream.Continuation, _ packet: ImageTransfer, _ buildID: String) async throws { let digest = packet.descriptor.digest let offset: UInt64 = packet.offset() ?? 0 let size: Int = packet.len() ?? 0 guard let descriptor = try await local.get(digest: digest) else { throw Error.contentMissing } if offset == 0 && size == 0 { // Metadata request var transfer = try ImageTransfer( id: packet.id, digest: packet.tag, method: ContentStoreMethod.readerAt.rawValue, size: descriptor.size(), data: Data() ) transfer.complete = true var response = ClientStream() response.buildID = buildID response.imageTransfer = transfer response.packetType = .imageTransfer(transfer) sender.yield(response) return } guard let data = try descriptor.data(offset: offset, length: size) else { throw Error.invalidOffsetSizeForContent(packet.descriptor.digest, offset, size) } let transfer = try ImageTransfer( id: packet.id, digest: packet.tag, method: ContentStoreMethod.readerAt.rawValue, size: UInt64(data.count), data: data ) var response = ClientStream() response.buildID = buildID response.imageTransfer = transfer response.packetType = .imageTransfer(transfer) sender.yield(response) } func delete(_ sender: AsyncStream.Continuation, _ packet: ImageTransfer) async throws { throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.delete)"]) } func update(_ sender: AsyncStream.Continuation, _ packet: ImageTransfer) async throws { throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.update)"]) } func walk(_ sender: AsyncStream.Continuation, _ packet: ImageTransfer) async throws { throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.walk)"]) } enum ContentStoreMethod: String { case info = "/containerd.services.content.v1.Content/Info" case readerAt = "/containerd.services.content.v1.Content/ReaderAt" case delete = "/containerd.services.content.v1.Content/Delete" case update = "/containerd.services.content.v1.Content/Update" case walk = "/containerd.services.content.v1.Content/Walk" init(_ method: String) throws { guard let value = ContentStoreMethod(rawValue: method) else { throw Error.unknownMethod(method) } self = value } } } extension ImageTransfer { fileprivate init(id: String, digest: String, method: String, size: UInt64? = nil, data: Data = Data()) throws { self.init() self.id = id self.tag = digest self.metadata = [ "os": "linux", "stage": "content-store", "method": method, ] if let size { self.metadata["size"] = String(size) } self.complete = true self.direction = .into self.data = data } } extension BuildRemoteContentProxy { enum Error: Swift.Error, CustomStringConvertible { case imageTransferMissing case methodMissing case contentMissing case unknownMethod(String) case invalidOffsetSizeForContent(String, UInt64, Int) var description: String { switch self { case .imageTransferMissing: return "imageTransfer is missing" case .methodMissing: return "method is missing in request" case .contentMissing: return "content cannot be found" case .unknownMethod(let m): return "unknown content-store method \(m)" case .invalidOffsetSizeForContent(let digest, let offset, let size): return "invalid request for content: \(digest) with offset: \(offset) size: \(size)" } } } } ================================================ FILE: Sources/ContainerBuild/BuildStdio.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationOS import Foundation import GRPC import NIO actor BuildStdio: BuildPipelineHandler { public let quiet: Bool public let handle: FileHandle init(quiet: Bool = false, output: FileHandle = FileHandle.standardError) throws { self.quiet = quiet self.handle = output } nonisolated func accept(_ packet: ServerStream) throws -> Bool { guard let _ = packet.getIO() else { return false } return true } func handle(_ sender: AsyncStream.Continuation, _ packet: ServerStream) async throws { guard !quiet else { return } guard let io = packet.getIO() else { throw Error.ioMissing } if let cmdString = try TerminalCommand().json() { var response = ClientStream() response.buildID = packet.buildID response.command = .init() response.command.id = packet.buildID response.command.command = cmdString sender.yield(response) } handle.write(io.data) } } extension BuildStdio { enum Error: Swift.Error, CustomStringConvertible { case ioMissing case invalidContinuation var description: String { switch self { case .ioMissing: return "io field missing in packet" case .invalidContinuation: return "continuation could not created" } } } } ================================================ FILE: Sources/ContainerBuild/Builder.grpc.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// // // DO NOT EDIT. // swift-format-ignore-file // // Generated by the protocol buffer compiler. // Source: Builder.proto // import GRPC import NIO import NIOConcurrencyHelpers import SwiftProtobuf /// Builder service implements APIs for performing an image build with /// Container image builder agent. /// /// To perform a build: /// /// 1. CreateBuild to create a new build /// 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: /// a) signal packet to signal to the build process (e.g. SIGINT) /// /// b) command packet for executing a command in the build file on the /// server /// NOTE: the server will need to switch on the command to determine the /// type of command to execute (e.g. RUN, ENV, etc.) /// /// c) transfer build data either to or from the server /// - INTO direction is for sending build data to the server at specific /// location (e.g. COPY) /// - OUTOF direction is for copying build data from the server to be /// used in subsequent build stages /// /// d) transfer image content data either to or from the server /// - INTO direction is for sending inherited image content data to the /// server's local content store /// - OUTOF direction is for copying successfully built OCI image from /// the server to the client /// /// The server may send: /// a) stdio packet for the build progress /// /// b) build error indicating unsuccessful build /// /// c) command complete packet indicating a command has finished executing /// /// d) handle transfer build data either to or from the client /// /// e) handle transfer image content data either to or from the client /// /// /// NOTE: The build data and image content data transfer is ALWAYS initiated /// by the client. /// /// Sequence for transferring from the client to the server: /// 1. client send a BuildTransfer/ImageTransfer request with ID, direction /// of 'INTO', /// destination path, and first chunk of data /// 2. server starts to receive the data and stream to a temporary file /// 3. client continues to send all chunks of data until last chunk, which /// client will /// send with 'complete' set to true /// 4. server continues to receive until the last chunk with 'complete' set /// to true, /// server will finish writing the last chunk and un-archive the /// temporary file to the destination path /// 5. server completes the transfer by sending a last /// BuildTransfer/ImageTransfer with /// 'complete' set to true /// 6. client waits for the last BuildTransfer/ImageTransfer with 'complete' /// set to true /// before proceeding with the rest of the commands /// /// Sequence for transferring from the server to the client: /// 1. client send a BuildTransfer/ImageTransfer request with ID, direction /// of 'OUTOF', /// source path, and empty data /// 2. server archives the data at source path, and starts to send chunks to /// the client /// 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 /// 5. client continues to receive until the last chunk with 'complete' set /// to true, /// client will finish writing last chunk and un-archive the temporary /// file to the destination path /// 6. client MAY choose to send one last BuildTransfer/ImageTransfer with /// 'complete' /// set to true, but NOT required. /// /// /// NOTE: the client should close the send stream once it has finished /// 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. /// /// Usage: instantiate `Com_Apple_Container_Build_V1_BuilderClient`, then call methods of this protocol to make API calls. public protocol Com_Apple_Container_Build_V1_BuilderClientProtocol: GRPCClient { var serviceName: String { get } var interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? { get } func createBuild( _ request: Com_Apple_Container_Build_V1_CreateBuildRequest, callOptions: CallOptions? ) -> UnaryCall func performBuild( callOptions: CallOptions?, handler: @escaping (Com_Apple_Container_Build_V1_ServerStream) -> Void ) -> BidirectionalStreamingCall func info( _ request: Com_Apple_Container_Build_V1_InfoRequest, callOptions: CallOptions? ) -> UnaryCall } extension Com_Apple_Container_Build_V1_BuilderClientProtocol { public var serviceName: String { return "com.apple.container.build.v1.Builder" } /// Create a build request. /// /// - Parameters: /// - request: Request to send to CreateBuild. /// - callOptions: Call options. /// - Returns: A `UnaryCall` with futures for the metadata, status and response. public func createBuild( _ request: Com_Apple_Container_Build_V1_CreateBuildRequest, callOptions: CallOptions? = nil ) -> UnaryCall { return self.makeUnaryCall( path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.createBuild.path, request: request, callOptions: callOptions ?? self.defaultCallOptions, interceptors: self.interceptors?.makeCreateBuildInterceptors() ?? [] ) } /// Perform the build. /// Executes the entire build sequence with attaching input/output /// to handling data exchange with the server during the build. /// /// Callers should use the `send` method on the returned object to send messages /// to the server. The caller should send an `.end` after the final message has been sent. /// /// - Parameters: /// - callOptions: Call options. /// - handler: A closure called when each response is received from the server. /// - Returns: A `ClientStreamingCall` with futures for the metadata and status. public func performBuild( callOptions: CallOptions? = nil, handler: @escaping (Com_Apple_Container_Build_V1_ServerStream) -> Void ) -> BidirectionalStreamingCall { return self.makeBidirectionalStreamingCall( path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.performBuild.path, callOptions: callOptions ?? self.defaultCallOptions, interceptors: self.interceptors?.makePerformBuildInterceptors() ?? [], handler: handler ) } /// Unary call to Info /// /// - Parameters: /// - request: Request to send to Info. /// - callOptions: Call options. /// - Returns: A `UnaryCall` with futures for the metadata, status and response. public func info( _ request: Com_Apple_Container_Build_V1_InfoRequest, callOptions: CallOptions? = nil ) -> UnaryCall { return self.makeUnaryCall( path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.info.path, request: request, callOptions: callOptions ?? self.defaultCallOptions, interceptors: self.interceptors?.makeInfoInterceptors() ?? [] ) } } @available(*, deprecated) extension Com_Apple_Container_Build_V1_BuilderClient: @unchecked Sendable {} @available(*, deprecated, renamed: "Com_Apple_Container_Build_V1_BuilderNIOClient") public final class Com_Apple_Container_Build_V1_BuilderClient: Com_Apple_Container_Build_V1_BuilderClientProtocol { private let lock = Lock() private var _defaultCallOptions: CallOptions private var _interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? public let channel: GRPCChannel public var defaultCallOptions: CallOptions { get { self.lock.withLock { return self._defaultCallOptions } } set { self.lock.withLockVoid { self._defaultCallOptions = newValue } } } public var interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? { get { self.lock.withLock { return self._interceptors } } set { self.lock.withLockVoid { self._interceptors = newValue } } } /// Creates a client for the com.apple.container.build.v1.Builder service. /// /// - Parameters: /// - channel: `GRPCChannel` to the service host. /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. /// - interceptors: A factory providing interceptors for each RPC. public init( channel: GRPCChannel, defaultCallOptions: CallOptions = CallOptions(), interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? = nil ) { self.channel = channel self._defaultCallOptions = defaultCallOptions self._interceptors = interceptors } } public struct Com_Apple_Container_Build_V1_BuilderNIOClient: Com_Apple_Container_Build_V1_BuilderClientProtocol { public var channel: GRPCChannel public var defaultCallOptions: CallOptions public var interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? /// Creates a client for the com.apple.container.build.v1.Builder service. /// /// - Parameters: /// - channel: `GRPCChannel` to the service host. /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. /// - interceptors: A factory providing interceptors for each RPC. public init( channel: GRPCChannel, defaultCallOptions: CallOptions = CallOptions(), interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? = nil ) { self.channel = channel self.defaultCallOptions = defaultCallOptions self.interceptors = interceptors } } /// Builder service implements APIs for performing an image build with /// Container image builder agent. /// /// To perform a build: /// /// 1. CreateBuild to create a new build /// 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: /// a) signal packet to signal to the build process (e.g. SIGINT) /// /// b) command packet for executing a command in the build file on the /// server /// NOTE: the server will need to switch on the command to determine the /// type of command to execute (e.g. RUN, ENV, etc.) /// /// c) transfer build data either to or from the server /// - INTO direction is for sending build data to the server at specific /// location (e.g. COPY) /// - OUTOF direction is for copying build data from the server to be /// used in subsequent build stages /// /// d) transfer image content data either to or from the server /// - INTO direction is for sending inherited image content data to the /// server's local content store /// - OUTOF direction is for copying successfully built OCI image from /// the server to the client /// /// The server may send: /// a) stdio packet for the build progress /// /// b) build error indicating unsuccessful build /// /// c) command complete packet indicating a command has finished executing /// /// d) handle transfer build data either to or from the client /// /// e) handle transfer image content data either to or from the client /// /// /// NOTE: The build data and image content data transfer is ALWAYS initiated /// by the client. /// /// Sequence for transferring from the client to the server: /// 1. client send a BuildTransfer/ImageTransfer request with ID, direction /// of 'INTO', /// destination path, and first chunk of data /// 2. server starts to receive the data and stream to a temporary file /// 3. client continues to send all chunks of data until last chunk, which /// client will /// send with 'complete' set to true /// 4. server continues to receive until the last chunk with 'complete' set /// to true, /// server will finish writing the last chunk and un-archive the /// temporary file to the destination path /// 5. server completes the transfer by sending a last /// BuildTransfer/ImageTransfer with /// 'complete' set to true /// 6. client waits for the last BuildTransfer/ImageTransfer with 'complete' /// set to true /// before proceeding with the rest of the commands /// /// Sequence for transferring from the server to the client: /// 1. client send a BuildTransfer/ImageTransfer request with ID, direction /// of 'OUTOF', /// source path, and empty data /// 2. server archives the data at source path, and starts to send chunks to /// the client /// 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 /// 5. client continues to receive until the last chunk with 'complete' set /// to true, /// client will finish writing last chunk and un-archive the temporary /// file to the destination path /// 6. client MAY choose to send one last BuildTransfer/ImageTransfer with /// 'complete' /// set to true, but NOT required. /// /// /// NOTE: the client should close the send stream once it has finished /// 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, *) public protocol Com_Apple_Container_Build_V1_BuilderAsyncClientProtocol: GRPCClient { static var serviceDescriptor: GRPCServiceDescriptor { get } var interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? { get } func makeCreateBuildCall( _ request: Com_Apple_Container_Build_V1_CreateBuildRequest, callOptions: CallOptions? ) -> GRPCAsyncUnaryCall func makePerformBuildCall( callOptions: CallOptions? ) -> GRPCAsyncBidirectionalStreamingCall func makeInfoCall( _ request: Com_Apple_Container_Build_V1_InfoRequest, callOptions: CallOptions? ) -> GRPCAsyncUnaryCall } @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) extension Com_Apple_Container_Build_V1_BuilderAsyncClientProtocol { public static var serviceDescriptor: GRPCServiceDescriptor { return Com_Apple_Container_Build_V1_BuilderClientMetadata.serviceDescriptor } public var interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? { return nil } public func makeCreateBuildCall( _ request: Com_Apple_Container_Build_V1_CreateBuildRequest, callOptions: CallOptions? = nil ) -> GRPCAsyncUnaryCall { return self.makeAsyncUnaryCall( path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.createBuild.path, request: request, callOptions: callOptions ?? self.defaultCallOptions, interceptors: self.interceptors?.makeCreateBuildInterceptors() ?? [] ) } public func makePerformBuildCall( callOptions: CallOptions? = nil ) -> GRPCAsyncBidirectionalStreamingCall { return self.makeAsyncBidirectionalStreamingCall( path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.performBuild.path, callOptions: callOptions ?? self.defaultCallOptions, interceptors: self.interceptors?.makePerformBuildInterceptors() ?? [] ) } public func makeInfoCall( _ request: Com_Apple_Container_Build_V1_InfoRequest, callOptions: CallOptions? = nil ) -> GRPCAsyncUnaryCall { return self.makeAsyncUnaryCall( path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.info.path, request: request, callOptions: callOptions ?? self.defaultCallOptions, interceptors: self.interceptors?.makeInfoInterceptors() ?? [] ) } } @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) extension Com_Apple_Container_Build_V1_BuilderAsyncClientProtocol { public func createBuild( _ request: Com_Apple_Container_Build_V1_CreateBuildRequest, callOptions: CallOptions? = nil ) async throws -> Com_Apple_Container_Build_V1_CreateBuildResponse { return try await self.performAsyncUnaryCall( path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.createBuild.path, request: request, callOptions: callOptions ?? self.defaultCallOptions, interceptors: self.interceptors?.makeCreateBuildInterceptors() ?? [] ) } public func performBuild( _ requests: RequestStream, callOptions: CallOptions? = nil ) -> GRPCAsyncResponseStream where RequestStream: Sequence, RequestStream.Element == Com_Apple_Container_Build_V1_ClientStream { return self.performAsyncBidirectionalStreamingCall( path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.performBuild.path, requests: requests, callOptions: callOptions ?? self.defaultCallOptions, interceptors: self.interceptors?.makePerformBuildInterceptors() ?? [] ) } public func performBuild( _ requests: RequestStream, callOptions: CallOptions? = nil ) -> GRPCAsyncResponseStream where RequestStream: AsyncSequence & Sendable, RequestStream.Element == Com_Apple_Container_Build_V1_ClientStream { return self.performAsyncBidirectionalStreamingCall( path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.performBuild.path, requests: requests, callOptions: callOptions ?? self.defaultCallOptions, interceptors: self.interceptors?.makePerformBuildInterceptors() ?? [] ) } public func info( _ request: Com_Apple_Container_Build_V1_InfoRequest, callOptions: CallOptions? = nil ) async throws -> Com_Apple_Container_Build_V1_InfoResponse { return try await self.performAsyncUnaryCall( path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.info.path, request: request, callOptions: callOptions ?? self.defaultCallOptions, interceptors: self.interceptors?.makeInfoInterceptors() ?? [] ) } } @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) public struct Com_Apple_Container_Build_V1_BuilderAsyncClient: Com_Apple_Container_Build_V1_BuilderAsyncClientProtocol { public var channel: GRPCChannel public var defaultCallOptions: CallOptions public var interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? public init( channel: GRPCChannel, defaultCallOptions: CallOptions = CallOptions(), interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? = nil ) { self.channel = channel self.defaultCallOptions = defaultCallOptions self.interceptors = interceptors } } public protocol Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol: Sendable { /// - Returns: Interceptors to use when invoking 'createBuild'. func makeCreateBuildInterceptors() -> [ClientInterceptor] /// - Returns: Interceptors to use when invoking 'performBuild'. func makePerformBuildInterceptors() -> [ClientInterceptor] /// - Returns: Interceptors to use when invoking 'info'. func makeInfoInterceptors() -> [ClientInterceptor] } public enum Com_Apple_Container_Build_V1_BuilderClientMetadata { public static let serviceDescriptor = GRPCServiceDescriptor( name: "Builder", fullName: "com.apple.container.build.v1.Builder", methods: [ Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.createBuild, Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.performBuild, Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.info, ] ) public enum Methods { public static let createBuild = GRPCMethodDescriptor( name: "CreateBuild", path: "/com.apple.container.build.v1.Builder/CreateBuild", type: GRPCCallType.unary ) public static let performBuild = GRPCMethodDescriptor( name: "PerformBuild", path: "/com.apple.container.build.v1.Builder/PerformBuild", type: GRPCCallType.bidirectionalStreaming ) public static let info = GRPCMethodDescriptor( name: "Info", path: "/com.apple.container.build.v1.Builder/Info", type: GRPCCallType.unary ) } } /// Builder service implements APIs for performing an image build with /// Container image builder agent. /// /// To perform a build: /// /// 1. CreateBuild to create a new build /// 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: /// a) signal packet to signal to the build process (e.g. SIGINT) /// /// b) command packet for executing a command in the build file on the /// server /// NOTE: the server will need to switch on the command to determine the /// type of command to execute (e.g. RUN, ENV, etc.) /// /// c) transfer build data either to or from the server /// - INTO direction is for sending build data to the server at specific /// location (e.g. COPY) /// - OUTOF direction is for copying build data from the server to be /// used in subsequent build stages /// /// d) transfer image content data either to or from the server /// - INTO direction is for sending inherited image content data to the /// server's local content store /// - OUTOF direction is for copying successfully built OCI image from /// the server to the client /// /// The server may send: /// a) stdio packet for the build progress /// /// b) build error indicating unsuccessful build /// /// c) command complete packet indicating a command has finished executing /// /// d) handle transfer build data either to or from the client /// /// e) handle transfer image content data either to or from the client /// /// /// NOTE: The build data and image content data transfer is ALWAYS initiated /// by the client. /// /// Sequence for transferring from the client to the server: /// 1. client send a BuildTransfer/ImageTransfer request with ID, direction /// of 'INTO', /// destination path, and first chunk of data /// 2. server starts to receive the data and stream to a temporary file /// 3. client continues to send all chunks of data until last chunk, which /// client will /// send with 'complete' set to true /// 4. server continues to receive until the last chunk with 'complete' set /// to true, /// server will finish writing the last chunk and un-archive the /// temporary file to the destination path /// 5. server completes the transfer by sending a last /// BuildTransfer/ImageTransfer with /// 'complete' set to true /// 6. client waits for the last BuildTransfer/ImageTransfer with 'complete' /// set to true /// before proceeding with the rest of the commands /// /// Sequence for transferring from the server to the client: /// 1. client send a BuildTransfer/ImageTransfer request with ID, direction /// of 'OUTOF', /// source path, and empty data /// 2. server archives the data at source path, and starts to send chunks to /// the client /// 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 /// 5. client continues to receive until the last chunk with 'complete' set /// to true, /// client will finish writing last chunk and un-archive the temporary /// file to the destination path /// 6. client MAY choose to send one last BuildTransfer/ImageTransfer with /// 'complete' /// set to true, but NOT required. /// /// /// NOTE: the client should close the send stream once it has finished /// 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. /// /// To build a server, implement a class that conforms to this protocol. public protocol Com_Apple_Container_Build_V1_BuilderProvider: CallHandlerProvider { var interceptors: Com_Apple_Container_Build_V1_BuilderServerInterceptorFactoryProtocol? { get } /// Create a build request. func createBuild(request: Com_Apple_Container_Build_V1_CreateBuildRequest, context: StatusOnlyCallContext) -> EventLoopFuture /// Perform the build. /// Executes the entire build sequence with attaching input/output /// to handling data exchange with the server during the build. func performBuild(context: StreamingResponseCallContext) -> EventLoopFuture<(StreamEvent) -> Void> func info(request: Com_Apple_Container_Build_V1_InfoRequest, context: StatusOnlyCallContext) -> EventLoopFuture } extension Com_Apple_Container_Build_V1_BuilderProvider { public var serviceName: Substring { return Com_Apple_Container_Build_V1_BuilderServerMetadata.serviceDescriptor.fullName[...] } /// Determines, calls and returns the appropriate request handler, depending on the request's method. /// Returns nil for methods not handled by this service. public func handle( method name: Substring, context: CallHandlerContext ) -> GRPCServerHandlerProtocol? { switch name { case "CreateBuild": return UnaryServerHandler( context: context, requestDeserializer: ProtobufDeserializer(), responseSerializer: ProtobufSerializer(), interceptors: self.interceptors?.makeCreateBuildInterceptors() ?? [], userFunction: self.createBuild(request:context:) ) case "PerformBuild": return BidirectionalStreamingServerHandler( context: context, requestDeserializer: ProtobufDeserializer(), responseSerializer: ProtobufSerializer(), interceptors: self.interceptors?.makePerformBuildInterceptors() ?? [], observerFactory: self.performBuild(context:) ) case "Info": return UnaryServerHandler( context: context, requestDeserializer: ProtobufDeserializer(), responseSerializer: ProtobufSerializer(), interceptors: self.interceptors?.makeInfoInterceptors() ?? [], userFunction: self.info(request:context:) ) default: return nil } } } /// Builder service implements APIs for performing an image build with /// Container image builder agent. /// /// To perform a build: /// /// 1. CreateBuild to create a new build /// 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: /// a) signal packet to signal to the build process (e.g. SIGINT) /// /// b) command packet for executing a command in the build file on the /// server /// NOTE: the server will need to switch on the command to determine the /// type of command to execute (e.g. RUN, ENV, etc.) /// /// c) transfer build data either to or from the server /// - INTO direction is for sending build data to the server at specific /// location (e.g. COPY) /// - OUTOF direction is for copying build data from the server to be /// used in subsequent build stages /// /// d) transfer image content data either to or from the server /// - INTO direction is for sending inherited image content data to the /// server's local content store /// - OUTOF direction is for copying successfully built OCI image from /// the server to the client /// /// The server may send: /// a) stdio packet for the build progress /// /// b) build error indicating unsuccessful build /// /// c) command complete packet indicating a command has finished executing /// /// d) handle transfer build data either to or from the client /// /// e) handle transfer image content data either to or from the client /// /// /// NOTE: The build data and image content data transfer is ALWAYS initiated /// by the client. /// /// Sequence for transferring from the client to the server: /// 1. client send a BuildTransfer/ImageTransfer request with ID, direction /// of 'INTO', /// destination path, and first chunk of data /// 2. server starts to receive the data and stream to a temporary file /// 3. client continues to send all chunks of data until last chunk, which /// client will /// send with 'complete' set to true /// 4. server continues to receive until the last chunk with 'complete' set /// to true, /// server will finish writing the last chunk and un-archive the /// temporary file to the destination path /// 5. server completes the transfer by sending a last /// BuildTransfer/ImageTransfer with /// 'complete' set to true /// 6. client waits for the last BuildTransfer/ImageTransfer with 'complete' /// set to true /// before proceeding with the rest of the commands /// /// Sequence for transferring from the server to the client: /// 1. client send a BuildTransfer/ImageTransfer request with ID, direction /// of 'OUTOF', /// source path, and empty data /// 2. server archives the data at source path, and starts to send chunks to /// the client /// 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 /// 5. client continues to receive until the last chunk with 'complete' set /// to true, /// client will finish writing last chunk and un-archive the temporary /// file to the destination path /// 6. client MAY choose to send one last BuildTransfer/ImageTransfer with /// 'complete' /// set to true, but NOT required. /// /// /// NOTE: the client should close the send stream once it has finished /// 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. /// /// To implement a server, implement an object which conforms to this protocol. @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) public protocol Com_Apple_Container_Build_V1_BuilderAsyncProvider: CallHandlerProvider, Sendable { static var serviceDescriptor: GRPCServiceDescriptor { get } var interceptors: Com_Apple_Container_Build_V1_BuilderServerInterceptorFactoryProtocol? { get } /// Create a build request. func createBuild( request: Com_Apple_Container_Build_V1_CreateBuildRequest, context: GRPCAsyncServerCallContext ) async throws -> Com_Apple_Container_Build_V1_CreateBuildResponse /// Perform the build. /// Executes the entire build sequence with attaching input/output /// to handling data exchange with the server during the build. func performBuild( requestStream: GRPCAsyncRequestStream, responseStream: GRPCAsyncResponseStreamWriter, context: GRPCAsyncServerCallContext ) async throws func info( request: Com_Apple_Container_Build_V1_InfoRequest, context: GRPCAsyncServerCallContext ) async throws -> Com_Apple_Container_Build_V1_InfoResponse } @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) extension Com_Apple_Container_Build_V1_BuilderAsyncProvider { public static var serviceDescriptor: GRPCServiceDescriptor { return Com_Apple_Container_Build_V1_BuilderServerMetadata.serviceDescriptor } public var serviceName: Substring { return Com_Apple_Container_Build_V1_BuilderServerMetadata.serviceDescriptor.fullName[...] } public var interceptors: Com_Apple_Container_Build_V1_BuilderServerInterceptorFactoryProtocol? { return nil } public func handle( method name: Substring, context: CallHandlerContext ) -> GRPCServerHandlerProtocol? { switch name { case "CreateBuild": return GRPCAsyncServerHandler( context: context, requestDeserializer: ProtobufDeserializer(), responseSerializer: ProtobufSerializer(), interceptors: self.interceptors?.makeCreateBuildInterceptors() ?? [], wrapping: { try await self.createBuild(request: $0, context: $1) } ) case "PerformBuild": return GRPCAsyncServerHandler( context: context, requestDeserializer: ProtobufDeserializer(), responseSerializer: ProtobufSerializer(), interceptors: self.interceptors?.makePerformBuildInterceptors() ?? [], wrapping: { try await self.performBuild(requestStream: $0, responseStream: $1, context: $2) } ) case "Info": return GRPCAsyncServerHandler( context: context, requestDeserializer: ProtobufDeserializer(), responseSerializer: ProtobufSerializer(), interceptors: self.interceptors?.makeInfoInterceptors() ?? [], wrapping: { try await self.info(request: $0, context: $1) } ) default: return nil } } } public protocol Com_Apple_Container_Build_V1_BuilderServerInterceptorFactoryProtocol: Sendable { /// - Returns: Interceptors to use when handling 'createBuild'. /// Defaults to calling `self.makeInterceptors()`. func makeCreateBuildInterceptors() -> [ServerInterceptor] /// - Returns: Interceptors to use when handling 'performBuild'. /// Defaults to calling `self.makeInterceptors()`. func makePerformBuildInterceptors() -> [ServerInterceptor] /// - Returns: Interceptors to use when handling 'info'. /// Defaults to calling `self.makeInterceptors()`. func makeInfoInterceptors() -> [ServerInterceptor] } public enum Com_Apple_Container_Build_V1_BuilderServerMetadata { public static let serviceDescriptor = GRPCServiceDescriptor( name: "Builder", fullName: "com.apple.container.build.v1.Builder", methods: [ Com_Apple_Container_Build_V1_BuilderServerMetadata.Methods.createBuild, Com_Apple_Container_Build_V1_BuilderServerMetadata.Methods.performBuild, Com_Apple_Container_Build_V1_BuilderServerMetadata.Methods.info, ] ) public enum Methods { public static let createBuild = GRPCMethodDescriptor( name: "CreateBuild", path: "/com.apple.container.build.v1.Builder/CreateBuild", type: GRPCCallType.unary ) public static let performBuild = GRPCMethodDescriptor( name: "PerformBuild", path: "/com.apple.container.build.v1.Builder/PerformBuild", type: GRPCCallType.bidirectionalStreaming ) public static let info = GRPCMethodDescriptor( name: "Info", path: "/com.apple.container.build.v1.Builder/Info", type: GRPCCallType.unary ) } } ================================================ FILE: Sources/ContainerBuild/Builder.pb.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// // DO NOT EDIT. // swift-format-ignore-file // swiftlint:disable all // // Generated by the Swift generator plugin for the protocol buffer compiler. // Source: Builder.proto // // For information on using the generated types, please see the documentation: // https://github.com/apple/swift-protobuf/ import Foundation import SwiftProtobuf // If the compiler emits an error on this type, it is because this file // was generated by a version of the `protoc` Swift plug-in that is // incompatible with the version of SwiftProtobuf to which you are linking. // Please ensure that you are building against the same version of the API // that was used to generate this file. fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} typealias Version = _2 } public enum Com_Apple_Container_Build_V1_TransferDirection: SwiftProtobuf.Enum, Swift.CaseIterable { public typealias RawValue = Int case into // = 0 case outof // = 1 case UNRECOGNIZED(Int) public init() { self = .into } public init?(rawValue: Int) { switch rawValue { case 0: self = .into case 1: self = .outof default: self = .UNRECOGNIZED(rawValue) } } public var rawValue: Int { switch self { case .into: return 0 case .outof: return 1 case .UNRECOGNIZED(let i): return i } } // The compiler won't synthesize support with the UNRECOGNIZED case. public static let allCases: [Com_Apple_Container_Build_V1_TransferDirection] = [ .into, .outof, ] } /// Standard input/output. public enum Com_Apple_Container_Build_V1_Stdio: SwiftProtobuf.Enum, Swift.CaseIterable { public typealias RawValue = Int case stdin // = 0 case stdout // = 1 case stderr // = 2 case UNRECOGNIZED(Int) public init() { self = .stdin } public init?(rawValue: Int) { switch rawValue { case 0: self = .stdin case 1: self = .stdout case 2: self = .stderr default: self = .UNRECOGNIZED(rawValue) } } public var rawValue: Int { switch self { case .stdin: return 0 case .stdout: return 1 case .stderr: return 2 case .UNRECOGNIZED(let i): return i } } // The compiler won't synthesize support with the UNRECOGNIZED case. public static let allCases: [Com_Apple_Container_Build_V1_Stdio] = [ .stdin, .stdout, .stderr, ] } /// Build error type. public enum Com_Apple_Container_Build_V1_BuildErrorType: SwiftProtobuf.Enum, Swift.CaseIterable { public typealias RawValue = Int case buildFailed // = 0 case `internal` // = 1 case UNRECOGNIZED(Int) public init() { self = .buildFailed } public init?(rawValue: Int) { switch rawValue { case 0: self = .buildFailed case 1: self = .internal default: self = .UNRECOGNIZED(rawValue) } } public var rawValue: Int { switch self { case .buildFailed: return 0 case .internal: return 1 case .UNRECOGNIZED(let i): return i } } // The compiler won't synthesize support with the UNRECOGNIZED case. public static let allCases: [Com_Apple_Container_Build_V1_BuildErrorType] = [ .buildFailed, .internal, ] } public struct Com_Apple_Container_Build_V1_InfoRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} } public struct Com_Apple_Container_Build_V1_InfoResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} } public struct Com_Apple_Container_Build_V1_CreateBuildRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. /// The name of the build stage. public var stageName: String = String() /// The tag of the image to be created. public var tag: String = String() /// Any additional metadata to be associated with the build. public var metadata: Dictionary = [:] /// Additional build arguments. public var buildArgs: [String] = [] /// Enable debug logging. public var debug: Bool = false public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} } public struct Com_Apple_Container_Build_V1_CreateBuildResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. /// A unique ID for the build. public var buildID: String = String() /// Any additional metadata to be associated with the build. public var metadata: Dictionary = [:] public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} } public struct Com_Apple_Container_Build_V1_ClientStream: @unchecked Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. /// A unique ID for the build. public var buildID: String { get {return _storage._buildID} set {_uniqueStorage()._buildID = newValue} } /// The packet type. public var packetType: OneOf_PacketType? { get {return _storage._packetType} set {_uniqueStorage()._packetType = newValue} } public var signal: Com_Apple_Container_Build_V1_Signal { get { if case .signal(let v)? = _storage._packetType {return v} return Com_Apple_Container_Build_V1_Signal() } set {_uniqueStorage()._packetType = .signal(newValue)} } public var command: Com_Apple_Container_Build_V1_Run { get { if case .command(let v)? = _storage._packetType {return v} return Com_Apple_Container_Build_V1_Run() } set {_uniqueStorage()._packetType = .command(newValue)} } public var buildTransfer: Com_Apple_Container_Build_V1_BuildTransfer { get { if case .buildTransfer(let v)? = _storage._packetType {return v} return Com_Apple_Container_Build_V1_BuildTransfer() } set {_uniqueStorage()._packetType = .buildTransfer(newValue)} } public var imageTransfer: Com_Apple_Container_Build_V1_ImageTransfer { get { if case .imageTransfer(let v)? = _storage._packetType {return v} return Com_Apple_Container_Build_V1_ImageTransfer() } set {_uniqueStorage()._packetType = .imageTransfer(newValue)} } public var unknownFields = SwiftProtobuf.UnknownStorage() /// The packet type. public enum OneOf_PacketType: Equatable, Sendable { case signal(Com_Apple_Container_Build_V1_Signal) case command(Com_Apple_Container_Build_V1_Run) case buildTransfer(Com_Apple_Container_Build_V1_BuildTransfer) case imageTransfer(Com_Apple_Container_Build_V1_ImageTransfer) } public init() {} fileprivate var _storage = _StorageClass.defaultInstance } public struct Com_Apple_Container_Build_V1_Signal: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. /// A POSIX signal to send to the build process. /// Can be used for cancelling builds. public var signal: Int32 = 0 public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} } public struct Com_Apple_Container_Build_V1_Run: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. /// A unique ID for the execution. public var id: String = String() /// The type of command to execute. public var command: String = String() public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} } public struct Com_Apple_Container_Build_V1_RunComplete: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. /// A unique ID for the execution. public var id: String = String() public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} } public struct Com_Apple_Container_Build_V1_BuildTransfer: @unchecked Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. /// A unique ID for the transfer. public var id: String = String() /// The direction for transferring data (either to the server or from the /// server). public var direction: Com_Apple_Container_Build_V1_TransferDirection = .into /// The absolute path to the source from the server perspective. public var source: String { get {return _source ?? String()} set {_source = newValue} } /// Returns true if `source` has been explicitly set. public var hasSource: Bool {return self._source != nil} /// Clears the value of `source`. Subsequent reads from it will return its default value. public mutating func clearSource() {self._source = nil} /// The absolute path for the destination from the server perspective. public var destination: String { get {return _destination ?? String()} set {_destination = newValue} } /// Returns true if `destination` has been explicitly set. public var hasDestination: Bool {return self._destination != nil} /// Clears the value of `destination`. Subsequent reads from it will return its default value. public mutating func clearDestination() {self._destination = nil} /// The actual data bytes to be transferred. public var data: Data = Data() /// Signal to indicate that the transfer of data for the request has finished. public var complete: Bool = false /// Boolean to indicate if the content is a directory. public var isDirectory: Bool = false /// Metadata for the transfer. public var metadata: Dictionary = [:] public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} fileprivate var _source: String? = nil fileprivate var _destination: String? = nil } public struct Com_Apple_Container_Build_V1_ImageTransfer: @unchecked Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. /// A unique ID for the transfer. public var id: String = String() /// The direction for transferring data (either to the server or from the /// server). public var direction: Com_Apple_Container_Build_V1_TransferDirection = .into /// The tag for the image. public var tag: String = String() /// The descriptor for the image content. public var descriptor: Com_Apple_Container_Build_V1_Descriptor { get {return _descriptor ?? Com_Apple_Container_Build_V1_Descriptor()} set {_descriptor = newValue} } /// Returns true if `descriptor` has been explicitly set. public var hasDescriptor: Bool {return self._descriptor != nil} /// Clears the value of `descriptor`. Subsequent reads from it will return its default value. public mutating func clearDescriptor() {self._descriptor = nil} /// The actual data bytes to be transferred. public var data: Data = Data() /// Signal to indicate that the transfer of data for the request has finished. public var complete: Bool = false /// Metadata for the image. public var metadata: Dictionary = [:] public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} fileprivate var _descriptor: Com_Apple_Container_Build_V1_Descriptor? = nil } public struct Com_Apple_Container_Build_V1_ServerStream: @unchecked Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. /// A unique ID for the build. public var buildID: String { get {return _storage._buildID} set {_uniqueStorage()._buildID = newValue} } /// The packet type. public var packetType: OneOf_PacketType? { get {return _storage._packetType} set {_uniqueStorage()._packetType = newValue} } public var io: Com_Apple_Container_Build_V1_IO { get { if case .io(let v)? = _storage._packetType {return v} return Com_Apple_Container_Build_V1_IO() } set {_uniqueStorage()._packetType = .io(newValue)} } public var buildError: Com_Apple_Container_Build_V1_BuildError { get { if case .buildError(let v)? = _storage._packetType {return v} return Com_Apple_Container_Build_V1_BuildError() } set {_uniqueStorage()._packetType = .buildError(newValue)} } public var commandComplete: Com_Apple_Container_Build_V1_RunComplete { get { if case .commandComplete(let v)? = _storage._packetType {return v} return Com_Apple_Container_Build_V1_RunComplete() } set {_uniqueStorage()._packetType = .commandComplete(newValue)} } public var buildTransfer: Com_Apple_Container_Build_V1_BuildTransfer { get { if case .buildTransfer(let v)? = _storage._packetType {return v} return Com_Apple_Container_Build_V1_BuildTransfer() } set {_uniqueStorage()._packetType = .buildTransfer(newValue)} } public var imageTransfer: Com_Apple_Container_Build_V1_ImageTransfer { get { if case .imageTransfer(let v)? = _storage._packetType {return v} return Com_Apple_Container_Build_V1_ImageTransfer() } set {_uniqueStorage()._packetType = .imageTransfer(newValue)} } public var unknownFields = SwiftProtobuf.UnknownStorage() /// The packet type. public enum OneOf_PacketType: Equatable, Sendable { case io(Com_Apple_Container_Build_V1_IO) case buildError(Com_Apple_Container_Build_V1_BuildError) case commandComplete(Com_Apple_Container_Build_V1_RunComplete) case buildTransfer(Com_Apple_Container_Build_V1_BuildTransfer) case imageTransfer(Com_Apple_Container_Build_V1_ImageTransfer) } public init() {} fileprivate var _storage = _StorageClass.defaultInstance } public struct Com_Apple_Container_Build_V1_IO: @unchecked Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. /// The type of IO. public var type: Com_Apple_Container_Build_V1_Stdio = .stdin /// The IO data bytes. public var data: Data = Data() public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} } public struct Com_Apple_Container_Build_V1_BuildError: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. /// The type of build error. public var type: Com_Apple_Container_Build_V1_BuildErrorType = .buildFailed /// Additional message for the build failure. public var message: String = String() public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} } /// OCI Platform metadata. public struct Com_Apple_Container_Build_V1_Platform: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. public var architecture: String = String() public var os: String = String() public var osVersion: String = String() public var osFeatures: [String] = [] public var variant: String = String() public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} } /// OCI Descriptor metadata. public struct Com_Apple_Container_Build_V1_Descriptor: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. public var mediaType: String = String() public var digest: String = String() public var size: Int64 = 0 public var urls: [String] = [] public var annotations: Dictionary = [:] public var platform: Com_Apple_Container_Build_V1_Platform { get {return _platform ?? Com_Apple_Container_Build_V1_Platform()} set {_platform = newValue} } /// Returns true if `platform` has been explicitly set. public var hasPlatform: Bool {return self._platform != nil} /// Clears the value of `platform`. Subsequent reads from it will return its default value. public mutating func clearPlatform() {self._platform = nil} public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} fileprivate var _platform: Com_Apple_Container_Build_V1_Platform? = nil } // MARK: - Code below here is support for the SwiftProtobuf runtime. fileprivate let _protobuf_package = "com.apple.container.build.v1" extension Com_Apple_Container_Build_V1_TransferDirection: SwiftProtobuf._ProtoNameProviding { public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 0: .same(proto: "INTO"), 1: .same(proto: "OUTOF"), ] } extension Com_Apple_Container_Build_V1_Stdio: SwiftProtobuf._ProtoNameProviding { public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 0: .same(proto: "STDIN"), 1: .same(proto: "STDOUT"), 2: .same(proto: "STDERR"), ] } extension Com_Apple_Container_Build_V1_BuildErrorType: SwiftProtobuf._ProtoNameProviding { public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 0: .same(proto: "BUILD_FAILED"), 1: .same(proto: "INTERNAL"), ] } extension Com_Apple_Container_Build_V1_InfoRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".InfoRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() public mutating func decodeMessage(decoder: inout D) throws { // Load everything into unknown fields while try decoder.nextFieldNumber() != nil {} } public func traverse(visitor: inout V) throws { try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_InfoRequest, rhs: Com_Apple_Container_Build_V1_InfoRequest) -> Bool { if lhs.unknownFields != rhs.unknownFields {return false} return true } } extension Com_Apple_Container_Build_V1_InfoResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".InfoResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() public mutating func decodeMessage(decoder: inout D) throws { // Load everything into unknown fields while try decoder.nextFieldNumber() != nil {} } public func traverse(visitor: inout V) throws { try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_InfoResponse, rhs: Com_Apple_Container_Build_V1_InfoResponse) -> Bool { if lhs.unknownFields != rhs.unknownFields {return false} return true } } extension Com_Apple_Container_Build_V1_CreateBuildRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".CreateBuildRequest" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .standard(proto: "stage_name"), 2: .same(proto: "tag"), 3: .same(proto: "metadata"), 4: .standard(proto: "build_args"), 5: .same(proto: "debug"), ] public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.stageName) }() case 2: try { try decoder.decodeSingularStringField(value: &self.tag) }() case 3: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &self.metadata) }() case 4: try { try decoder.decodeRepeatedStringField(value: &self.buildArgs) }() case 5: try { try decoder.decodeSingularBoolField(value: &self.debug) }() default: break } } } public func traverse(visitor: inout V) throws { if !self.stageName.isEmpty { try visitor.visitSingularStringField(value: self.stageName, fieldNumber: 1) } if !self.tag.isEmpty { try visitor.visitSingularStringField(value: self.tag, fieldNumber: 2) } if !self.metadata.isEmpty { try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: self.metadata, fieldNumber: 3) } if !self.buildArgs.isEmpty { try visitor.visitRepeatedStringField(value: self.buildArgs, fieldNumber: 4) } if self.debug != false { try visitor.visitSingularBoolField(value: self.debug, fieldNumber: 5) } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_CreateBuildRequest, rhs: Com_Apple_Container_Build_V1_CreateBuildRequest) -> Bool { if lhs.stageName != rhs.stageName {return false} if lhs.tag != rhs.tag {return false} if lhs.metadata != rhs.metadata {return false} if lhs.buildArgs != rhs.buildArgs {return false} if lhs.debug != rhs.debug {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } extension Com_Apple_Container_Build_V1_CreateBuildResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".CreateBuildResponse" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .standard(proto: "build_id"), 2: .same(proto: "metadata"), ] public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.buildID) }() case 2: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &self.metadata) }() default: break } } } public func traverse(visitor: inout V) throws { if !self.buildID.isEmpty { try visitor.visitSingularStringField(value: self.buildID, fieldNumber: 1) } if !self.metadata.isEmpty { try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: self.metadata, fieldNumber: 2) } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_CreateBuildResponse, rhs: Com_Apple_Container_Build_V1_CreateBuildResponse) -> Bool { if lhs.buildID != rhs.buildID {return false} if lhs.metadata != rhs.metadata {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } extension Com_Apple_Container_Build_V1_ClientStream: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ClientStream" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .standard(proto: "build_id"), 2: .same(proto: "signal"), 3: .same(proto: "command"), 4: .standard(proto: "build_transfer"), 5: .standard(proto: "image_transfer"), ] fileprivate class _StorageClass { var _buildID: String = String() var _packetType: Com_Apple_Container_Build_V1_ClientStream.OneOf_PacketType? // This property is used as the initial default value for new instances of the type. // The type itself is protecting the reference to its storage via CoW semantics. // This will force a copy to be made of this reference when the first mutation occurs; // hence, it is safe to mark this as `nonisolated(unsafe)`. static nonisolated(unsafe) let defaultInstance = _StorageClass() private init() {} init(copying source: _StorageClass) { _buildID = source._buildID _packetType = source._packetType } } fileprivate mutating func _uniqueStorage() -> _StorageClass { if !isKnownUniquelyReferenced(&_storage) { _storage = _StorageClass(copying: _storage) } return _storage } public mutating func decodeMessage(decoder: inout D) throws { _ = _uniqueStorage() try withExtendedLifetime(_storage) { (_storage: _StorageClass) in while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &_storage._buildID) }() case 2: try { var v: Com_Apple_Container_Build_V1_Signal? var hadOneofValue = false if let current = _storage._packetType { hadOneofValue = true if case .signal(let m) = current {v = m} } try decoder.decodeSingularMessageField(value: &v) if let v = v { if hadOneofValue {try decoder.handleConflictingOneOf()} _storage._packetType = .signal(v) } }() case 3: try { var v: Com_Apple_Container_Build_V1_Run? var hadOneofValue = false if let current = _storage._packetType { hadOneofValue = true if case .command(let m) = current {v = m} } try decoder.decodeSingularMessageField(value: &v) if let v = v { if hadOneofValue {try decoder.handleConflictingOneOf()} _storage._packetType = .command(v) } }() case 4: try { var v: Com_Apple_Container_Build_V1_BuildTransfer? var hadOneofValue = false if let current = _storage._packetType { hadOneofValue = true if case .buildTransfer(let m) = current {v = m} } try decoder.decodeSingularMessageField(value: &v) if let v = v { if hadOneofValue {try decoder.handleConflictingOneOf()} _storage._packetType = .buildTransfer(v) } }() case 5: try { var v: Com_Apple_Container_Build_V1_ImageTransfer? var hadOneofValue = false if let current = _storage._packetType { hadOneofValue = true if case .imageTransfer(let m) = current {v = m} } try decoder.decodeSingularMessageField(value: &v) if let v = v { if hadOneofValue {try decoder.handleConflictingOneOf()} _storage._packetType = .imageTransfer(v) } }() default: break } } } } public func traverse(visitor: inout V) throws { try withExtendedLifetime(_storage) { (_storage: _StorageClass) in // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every if/case branch local when no optimizations // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // https://github.com/apple/swift-protobuf/issues/1182 if !_storage._buildID.isEmpty { try visitor.visitSingularStringField(value: _storage._buildID, fieldNumber: 1) } switch _storage._packetType { case .signal?: try { guard case .signal(let v)? = _storage._packetType else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 2) }() case .command?: try { guard case .command(let v)? = _storage._packetType else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 3) }() case .buildTransfer?: try { guard case .buildTransfer(let v)? = _storage._packetType else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 4) }() case .imageTransfer?: try { guard case .imageTransfer(let v)? = _storage._packetType else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 5) }() case nil: break } } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_ClientStream, rhs: Com_Apple_Container_Build_V1_ClientStream) -> Bool { if lhs._storage !== rhs._storage { let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in let _storage = _args.0 let rhs_storage = _args.1 if _storage._buildID != rhs_storage._buildID {return false} if _storage._packetType != rhs_storage._packetType {return false} return true } if !storagesAreEqual {return false} } if lhs.unknownFields != rhs.unknownFields {return false} return true } } extension Com_Apple_Container_Build_V1_Signal: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Signal" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "signal"), ] public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularInt32Field(value: &self.signal) }() default: break } } } public func traverse(visitor: inout V) throws { if self.signal != 0 { try visitor.visitSingularInt32Field(value: self.signal, fieldNumber: 1) } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_Signal, rhs: Com_Apple_Container_Build_V1_Signal) -> Bool { if lhs.signal != rhs.signal {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } extension Com_Apple_Container_Build_V1_Run: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Run" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "id"), 2: .same(proto: "command"), ] public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.id) }() case 2: try { try decoder.decodeSingularStringField(value: &self.command) }() default: break } } } public func traverse(visitor: inout V) throws { if !self.id.isEmpty { try visitor.visitSingularStringField(value: self.id, fieldNumber: 1) } if !self.command.isEmpty { try visitor.visitSingularStringField(value: self.command, fieldNumber: 2) } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_Run, rhs: Com_Apple_Container_Build_V1_Run) -> Bool { if lhs.id != rhs.id {return false} if lhs.command != rhs.command {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } extension Com_Apple_Container_Build_V1_RunComplete: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".RunComplete" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "id"), ] public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.id) }() default: break } } } public func traverse(visitor: inout V) throws { if !self.id.isEmpty { try visitor.visitSingularStringField(value: self.id, fieldNumber: 1) } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_RunComplete, rhs: Com_Apple_Container_Build_V1_RunComplete) -> Bool { if lhs.id != rhs.id {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } extension Com_Apple_Container_Build_V1_BuildTransfer: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".BuildTransfer" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "id"), 2: .same(proto: "direction"), 3: .same(proto: "source"), 4: .same(proto: "destination"), 5: .same(proto: "data"), 6: .same(proto: "complete"), 7: .standard(proto: "is_directory"), 8: .same(proto: "metadata"), ] public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.id) }() case 2: try { try decoder.decodeSingularEnumField(value: &self.direction) }() case 3: try { try decoder.decodeSingularStringField(value: &self._source) }() case 4: try { try decoder.decodeSingularStringField(value: &self._destination) }() case 5: try { try decoder.decodeSingularBytesField(value: &self.data) }() case 6: try { try decoder.decodeSingularBoolField(value: &self.complete) }() case 7: try { try decoder.decodeSingularBoolField(value: &self.isDirectory) }() case 8: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &self.metadata) }() default: break } } } public func traverse(visitor: inout V) throws { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every if/case branch local when no optimizations // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // https://github.com/apple/swift-protobuf/issues/1182 if !self.id.isEmpty { try visitor.visitSingularStringField(value: self.id, fieldNumber: 1) } if self.direction != .into { try visitor.visitSingularEnumField(value: self.direction, fieldNumber: 2) } try { if let v = self._source { try visitor.visitSingularStringField(value: v, fieldNumber: 3) } }() try { if let v = self._destination { try visitor.visitSingularStringField(value: v, fieldNumber: 4) } }() if !self.data.isEmpty { try visitor.visitSingularBytesField(value: self.data, fieldNumber: 5) } if self.complete != false { try visitor.visitSingularBoolField(value: self.complete, fieldNumber: 6) } if self.isDirectory != false { try visitor.visitSingularBoolField(value: self.isDirectory, fieldNumber: 7) } if !self.metadata.isEmpty { try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: self.metadata, fieldNumber: 8) } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_BuildTransfer, rhs: Com_Apple_Container_Build_V1_BuildTransfer) -> Bool { if lhs.id != rhs.id {return false} if lhs.direction != rhs.direction {return false} if lhs._source != rhs._source {return false} if lhs._destination != rhs._destination {return false} if lhs.data != rhs.data {return false} if lhs.complete != rhs.complete {return false} if lhs.isDirectory != rhs.isDirectory {return false} if lhs.metadata != rhs.metadata {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } extension Com_Apple_Container_Build_V1_ImageTransfer: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ImageTransfer" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "id"), 2: .same(proto: "direction"), 3: .same(proto: "tag"), 4: .same(proto: "descriptor"), 5: .same(proto: "data"), 6: .same(proto: "complete"), 7: .same(proto: "metadata"), ] public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.id) }() case 2: try { try decoder.decodeSingularEnumField(value: &self.direction) }() case 3: try { try decoder.decodeSingularStringField(value: &self.tag) }() case 4: try { try decoder.decodeSingularMessageField(value: &self._descriptor) }() case 5: try { try decoder.decodeSingularBytesField(value: &self.data) }() case 6: try { try decoder.decodeSingularBoolField(value: &self.complete) }() case 7: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &self.metadata) }() default: break } } } public func traverse(visitor: inout V) throws { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every if/case branch local when no optimizations // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // https://github.com/apple/swift-protobuf/issues/1182 if !self.id.isEmpty { try visitor.visitSingularStringField(value: self.id, fieldNumber: 1) } if self.direction != .into { try visitor.visitSingularEnumField(value: self.direction, fieldNumber: 2) } if !self.tag.isEmpty { try visitor.visitSingularStringField(value: self.tag, fieldNumber: 3) } try { if let v = self._descriptor { try visitor.visitSingularMessageField(value: v, fieldNumber: 4) } }() if !self.data.isEmpty { try visitor.visitSingularBytesField(value: self.data, fieldNumber: 5) } if self.complete != false { try visitor.visitSingularBoolField(value: self.complete, fieldNumber: 6) } if !self.metadata.isEmpty { try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: self.metadata, fieldNumber: 7) } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_ImageTransfer, rhs: Com_Apple_Container_Build_V1_ImageTransfer) -> Bool { if lhs.id != rhs.id {return false} if lhs.direction != rhs.direction {return false} if lhs.tag != rhs.tag {return false} if lhs._descriptor != rhs._descriptor {return false} if lhs.data != rhs.data {return false} if lhs.complete != rhs.complete {return false} if lhs.metadata != rhs.metadata {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } extension Com_Apple_Container_Build_V1_ServerStream: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ServerStream" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .standard(proto: "build_id"), 2: .same(proto: "io"), 3: .standard(proto: "build_error"), 4: .standard(proto: "command_complete"), 5: .standard(proto: "build_transfer"), 6: .standard(proto: "image_transfer"), ] fileprivate class _StorageClass { var _buildID: String = String() var _packetType: Com_Apple_Container_Build_V1_ServerStream.OneOf_PacketType? // This property is used as the initial default value for new instances of the type. // The type itself is protecting the reference to its storage via CoW semantics. // This will force a copy to be made of this reference when the first mutation occurs; // hence, it is safe to mark this as `nonisolated(unsafe)`. static nonisolated(unsafe) let defaultInstance = _StorageClass() private init() {} init(copying source: _StorageClass) { _buildID = source._buildID _packetType = source._packetType } } fileprivate mutating func _uniqueStorage() -> _StorageClass { if !isKnownUniquelyReferenced(&_storage) { _storage = _StorageClass(copying: _storage) } return _storage } public mutating func decodeMessage(decoder: inout D) throws { _ = _uniqueStorage() try withExtendedLifetime(_storage) { (_storage: _StorageClass) in while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &_storage._buildID) }() case 2: try { var v: Com_Apple_Container_Build_V1_IO? var hadOneofValue = false if let current = _storage._packetType { hadOneofValue = true if case .io(let m) = current {v = m} } try decoder.decodeSingularMessageField(value: &v) if let v = v { if hadOneofValue {try decoder.handleConflictingOneOf()} _storage._packetType = .io(v) } }() case 3: try { var v: Com_Apple_Container_Build_V1_BuildError? var hadOneofValue = false if let current = _storage._packetType { hadOneofValue = true if case .buildError(let m) = current {v = m} } try decoder.decodeSingularMessageField(value: &v) if let v = v { if hadOneofValue {try decoder.handleConflictingOneOf()} _storage._packetType = .buildError(v) } }() case 4: try { var v: Com_Apple_Container_Build_V1_RunComplete? var hadOneofValue = false if let current = _storage._packetType { hadOneofValue = true if case .commandComplete(let m) = current {v = m} } try decoder.decodeSingularMessageField(value: &v) if let v = v { if hadOneofValue {try decoder.handleConflictingOneOf()} _storage._packetType = .commandComplete(v) } }() case 5: try { var v: Com_Apple_Container_Build_V1_BuildTransfer? var hadOneofValue = false if let current = _storage._packetType { hadOneofValue = true if case .buildTransfer(let m) = current {v = m} } try decoder.decodeSingularMessageField(value: &v) if let v = v { if hadOneofValue {try decoder.handleConflictingOneOf()} _storage._packetType = .buildTransfer(v) } }() case 6: try { var v: Com_Apple_Container_Build_V1_ImageTransfer? var hadOneofValue = false if let current = _storage._packetType { hadOneofValue = true if case .imageTransfer(let m) = current {v = m} } try decoder.decodeSingularMessageField(value: &v) if let v = v { if hadOneofValue {try decoder.handleConflictingOneOf()} _storage._packetType = .imageTransfer(v) } }() default: break } } } } public func traverse(visitor: inout V) throws { try withExtendedLifetime(_storage) { (_storage: _StorageClass) in // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every if/case branch local when no optimizations // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // https://github.com/apple/swift-protobuf/issues/1182 if !_storage._buildID.isEmpty { try visitor.visitSingularStringField(value: _storage._buildID, fieldNumber: 1) } switch _storage._packetType { case .io?: try { guard case .io(let v)? = _storage._packetType else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 2) }() case .buildError?: try { guard case .buildError(let v)? = _storage._packetType else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 3) }() case .commandComplete?: try { guard case .commandComplete(let v)? = _storage._packetType else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 4) }() case .buildTransfer?: try { guard case .buildTransfer(let v)? = _storage._packetType else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 5) }() case .imageTransfer?: try { guard case .imageTransfer(let v)? = _storage._packetType else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 6) }() case nil: break } } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_ServerStream, rhs: Com_Apple_Container_Build_V1_ServerStream) -> Bool { if lhs._storage !== rhs._storage { let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in let _storage = _args.0 let rhs_storage = _args.1 if _storage._buildID != rhs_storage._buildID {return false} if _storage._packetType != rhs_storage._packetType {return false} return true } if !storagesAreEqual {return false} } if lhs.unknownFields != rhs.unknownFields {return false} return true } } extension Com_Apple_Container_Build_V1_IO: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IO" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "type"), 2: .same(proto: "data"), ] public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularEnumField(value: &self.type) }() case 2: try { try decoder.decodeSingularBytesField(value: &self.data) }() default: break } } } public func traverse(visitor: inout V) throws { if self.type != .stdin { try visitor.visitSingularEnumField(value: self.type, fieldNumber: 1) } if !self.data.isEmpty { try visitor.visitSingularBytesField(value: self.data, fieldNumber: 2) } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_IO, rhs: Com_Apple_Container_Build_V1_IO) -> Bool { if lhs.type != rhs.type {return false} if lhs.data != rhs.data {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } extension Com_Apple_Container_Build_V1_BuildError: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".BuildError" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "type"), 2: .same(proto: "message"), ] public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularEnumField(value: &self.type) }() case 2: try { try decoder.decodeSingularStringField(value: &self.message) }() default: break } } } public func traverse(visitor: inout V) throws { if self.type != .buildFailed { try visitor.visitSingularEnumField(value: self.type, fieldNumber: 1) } if !self.message.isEmpty { try visitor.visitSingularStringField(value: self.message, fieldNumber: 2) } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_BuildError, rhs: Com_Apple_Container_Build_V1_BuildError) -> Bool { if lhs.type != rhs.type {return false} if lhs.message != rhs.message {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } extension Com_Apple_Container_Build_V1_Platform: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Platform" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "architecture"), 2: .same(proto: "os"), 3: .standard(proto: "os_version"), 4: .standard(proto: "os_features"), 5: .same(proto: "variant"), ] public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.architecture) }() case 2: try { try decoder.decodeSingularStringField(value: &self.os) }() case 3: try { try decoder.decodeSingularStringField(value: &self.osVersion) }() case 4: try { try decoder.decodeRepeatedStringField(value: &self.osFeatures) }() case 5: try { try decoder.decodeSingularStringField(value: &self.variant) }() default: break } } } public func traverse(visitor: inout V) throws { if !self.architecture.isEmpty { try visitor.visitSingularStringField(value: self.architecture, fieldNumber: 1) } if !self.os.isEmpty { try visitor.visitSingularStringField(value: self.os, fieldNumber: 2) } if !self.osVersion.isEmpty { try visitor.visitSingularStringField(value: self.osVersion, fieldNumber: 3) } if !self.osFeatures.isEmpty { try visitor.visitRepeatedStringField(value: self.osFeatures, fieldNumber: 4) } if !self.variant.isEmpty { try visitor.visitSingularStringField(value: self.variant, fieldNumber: 5) } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_Platform, rhs: Com_Apple_Container_Build_V1_Platform) -> Bool { if lhs.architecture != rhs.architecture {return false} if lhs.os != rhs.os {return false} if lhs.osVersion != rhs.osVersion {return false} if lhs.osFeatures != rhs.osFeatures {return false} if lhs.variant != rhs.variant {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } extension Com_Apple_Container_Build_V1_Descriptor: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Descriptor" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .standard(proto: "media_type"), 2: .same(proto: "digest"), 3: .same(proto: "size"), 4: .same(proto: "urls"), 5: .same(proto: "annotations"), 6: .same(proto: "platform"), ] public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.mediaType) }() case 2: try { try decoder.decodeSingularStringField(value: &self.digest) }() case 3: try { try decoder.decodeSingularInt64Field(value: &self.size) }() case 4: try { try decoder.decodeRepeatedStringField(value: &self.urls) }() case 5: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &self.annotations) }() case 6: try { try decoder.decodeSingularMessageField(value: &self._platform) }() default: break } } } public func traverse(visitor: inout V) throws { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every if/case branch local when no optimizations // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // https://github.com/apple/swift-protobuf/issues/1182 if !self.mediaType.isEmpty { try visitor.visitSingularStringField(value: self.mediaType, fieldNumber: 1) } if !self.digest.isEmpty { try visitor.visitSingularStringField(value: self.digest, fieldNumber: 2) } if self.size != 0 { try visitor.visitSingularInt64Field(value: self.size, fieldNumber: 3) } if !self.urls.isEmpty { try visitor.visitRepeatedStringField(value: self.urls, fieldNumber: 4) } if !self.annotations.isEmpty { try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: self.annotations, fieldNumber: 5) } try { if let v = self._platform { try visitor.visitSingularMessageField(value: v, fieldNumber: 6) } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Container_Build_V1_Descriptor, rhs: Com_Apple_Container_Build_V1_Descriptor) -> Bool { if lhs.mediaType != rhs.mediaType {return false} if lhs.digest != rhs.digest {return false} if lhs.size != rhs.size {return false} if lhs.urls != rhs.urls {return false} if lhs.annotations != rhs.annotations {return false} if lhs._platform != rhs._platform {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } ================================================ FILE: Sources/ContainerBuild/Builder.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import Containerization import ContainerizationOCI import ContainerizationOS import Foundation import GRPC import NIO import NIOHPACK import NIOHTTP2 public struct Builder: Sendable { public static let builderContainerId = "buildkit" let client: BuilderClientProtocol let clientAsync: BuilderClientAsyncProtocol let group: EventLoopGroup let builderShimSocket: FileHandle let channel: GRPCChannel public init(socket: FileHandle, group: EventLoopGroup) throws { try socket.setSendBufSize(4 << 20) try socket.setRecvBufSize(2 << 20) var config = ClientConnection.Configuration.default( target: .connectedSocket(socket.fileDescriptor), eventLoopGroup: group ) config.connectionIdleTimeout = TimeAmount(.seconds(600)) config.connectionKeepalive = .init( interval: TimeAmount(.seconds(600)), timeout: TimeAmount(.seconds(500)), permitWithoutCalls: true ) config.connectionBackoff = .init( initialBackoff: TimeInterval(1), maximumBackoff: TimeInterval(10) ) config.callStartBehavior = .fastFailure config.httpMaxFrameSize = 8 << 10 config.maximumReceiveMessageLength = 512 << 20 config.httpTargetWindowSize = 16 << 10 let channel = ClientConnection(configuration: config) self.channel = channel self.clientAsync = BuilderClientAsync(channel: channel) self.client = BuilderClient(channel: channel) self.group = group self.builderShimSocket = socket } public func info() throws -> InfoResponse { let resp = self.client.info(InfoRequest(), callOptions: CallOptions()) return try resp.response.wait() } public func info() async throws -> InfoResponse { let opts = CallOptions(timeLimit: .timeout(.seconds(30))) return try await self.clientAsync.info(InfoRequest(), callOptions: opts) } // TODO // - Symlinks in build context dir // - cache-to, cache-from // - output (other than the default OCI image output, e.g., local, tar, Docker) public func build(_ config: BuildConfig) async throws { var continuation: AsyncStream.Continuation? let reqStream = AsyncStream { (cont: AsyncStream.Continuation) in continuation = cont } guard let continuation else { throw Error.invalidContinuation } defer { continuation.finish() } if let terminal = config.terminal { Task { let winchHandler = AsyncSignalHandler.create(notify: [SIGWINCH]) let setWinch = { (rows: UInt16, cols: UInt16) in var winch = ClientStream() winch.command = .init() if let cmdString = try TerminalCommand(rows: rows, cols: cols).json() { winch.command.command = cmdString continuation.yield(winch) } } let size = try terminal.size var width = size.width var height = size.height try setWinch(height, width) for await _ in winchHandler.signals { let size = try terminal.size let cols = size.width let rows = size.height if cols != width || rows != height { width = cols height = rows try setWinch(height, width) } } } } let respStream = self.clientAsync.performBuild(reqStream, callOptions: try CallOptions(config)) let pipeline = try await BuildPipeline(config) do { try await pipeline.run(sender: continuation, receiver: respStream) } catch Error.buildComplete { _ = channel.close() try await group.shutdownGracefully() return } } public struct BuildExport: Sendable { public let type: String public var destination: URL? public let additionalFields: [String: String] public let rawValue: String public init(type: String, destination: URL?, additionalFields: [String: String], rawValue: String) { self.type = type self.destination = destination self.additionalFields = additionalFields self.rawValue = rawValue } public init(from input: String) throws { var typeValue: String? var destinationValue: URL? var additionalFields: [String: String] = [:] let pairs = input.components(separatedBy: ",") for pair in pairs { let parts = pair.components(separatedBy: "=") guard parts.count == 2 else { continue } let key = parts[0].trimmingCharacters(in: .whitespaces) let value = parts[1].trimmingCharacters(in: .whitespaces) switch key { case "type": typeValue = value case "dest": destinationValue = try Self.resolveDestination(dest: value) default: additionalFields[key] = value } } guard let type = typeValue else { throw Builder.Error.invalidExport(input, "type field is required") } switch type { case "oci": break case "tar": if destinationValue == nil { throw Builder.Error.invalidExport(input, "dest field is required") } case "local": if destinationValue == nil { throw Builder.Error.invalidExport(input, "dest field is required") } default: throw Builder.Error.invalidExport(input, "unsupported output type") } self.init(type: type, destination: destinationValue, additionalFields: additionalFields, rawValue: input) } public var stringValue: String { get throws { var components = ["type=\(type)"] switch type { case "oci", "tar", "local": break // ignore destination default: throw Builder.Error.invalidExport(rawValue, "unsupported output type") } for (key, value) in additionalFields { components.append("\(key)=\(value)") } return components.joined(separator: ",") } } static func resolveDestination(dest: String) throws -> URL { let destination = URL(fileURLWithPath: dest) let fileManager = FileManager.default if fileManager.fileExists(atPath: destination.path) { let resourceValues = try destination.resourceValues(forKeys: [.isDirectoryKey]) let isDir = resourceValues.isDirectory if isDir != nil && isDir == false { throw Builder.Error.invalidExport(dest, "dest path already exists") } var finalDestination = destination.appendingPathComponent("out.tar") var index = 1 while fileManager.fileExists(atPath: finalDestination.path) { let path = "out.tar.\(index)" finalDestination = destination.appendingPathComponent(path) index += 1 } return finalDestination } else { let parentDirectory = destination.deletingLastPathComponent() try? fileManager.createDirectory(at: parentDirectory, withIntermediateDirectories: true, attributes: nil) } return destination } } public struct BuildConfig: Sendable { public let buildID: String public let contentStore: ContentStore public let buildArgs: [String] public let secrets: [String: Data] public let contextDir: String public let dockerfile: Data public let hiddenDockerDir: String? public let labels: [String] public let noCache: Bool public let platforms: [Platform] public let terminal: Terminal? public let tags: [String] public let target: String public let quiet: Bool public let exports: [BuildExport] public let cacheIn: [String] public let cacheOut: [String] public let pull: Bool public init( buildID: String, contentStore: ContentStore, buildArgs: [String], secrets: [String: Data], contextDir: String, dockerfile: Data, hiddenDockerDir: String?, labels: [String], noCache: Bool, platforms: [Platform], terminal: Terminal?, tags: [String], target: String, quiet: Bool, exports: [BuildExport], cacheIn: [String], cacheOut: [String], pull: Bool ) { self.buildID = buildID self.contentStore = contentStore self.buildArgs = buildArgs self.secrets = secrets self.contextDir = contextDir self.dockerfile = dockerfile self.hiddenDockerDir = hiddenDockerDir self.labels = labels self.noCache = noCache self.platforms = platforms self.terminal = terminal self.tags = tags self.target = target self.quiet = quiet self.exports = exports self.cacheIn = cacheIn self.cacheOut = cacheOut self.pull = pull } } } extension Builder { enum Error: Swift.Error, CustomStringConvertible { case invalidContinuation case buildComplete case invalidExport(String, String) var description: String { switch self { case .invalidContinuation: return "continuation could not created" case .buildComplete: return "build completed" case .invalidExport(let exp, let reason): return "export entry \(exp) is invalid: \(reason)" } } } } extension CallOptions { public init(_ config: Builder.BuildConfig) throws { var headers: [(String, String)] = [ ("build-id", config.buildID), ("context", URL(filePath: config.contextDir).path(percentEncoded: false)), ("dockerfile", config.dockerfile.base64EncodedString()), ("progress", config.terminal != nil ? "tty" : "plain"), ("target", config.target), ] if let hiddenDockerDir = config.hiddenDockerDir { headers.append(("hidden-docker-dir", hiddenDockerDir)) } for tag in config.tags { headers.append(("tag", tag)) } for platform in config.platforms { headers.append(("platforms", platform.description)) } if config.noCache { headers.append(("no-cache", "")) } for label in config.labels { headers.append(("labels", label)) } for buildArg in config.buildArgs { headers.append(("build-args", buildArg)) } for (id, data) in config.secrets { headers.append(("secrets", id + "=" + data.base64EncodedString())) } for output in config.exports { headers.append(("outputs", try output.stringValue)) } for cacheIn in config.cacheIn { headers.append(("cache-in", cacheIn)) } for cacheOut in config.cacheOut { headers.append(("cache-out", cacheOut)) } self.init( customMetadata: HPACKHeaders(headers) ) } } extension FileHandle { @discardableResult func setSendBufSize(_ bytes: Int) throws -> Int { try setSockOpt( level: SOL_SOCKET, name: SO_SNDBUF, value: bytes) return bytes } @discardableResult func setRecvBufSize(_ bytes: Int) throws -> Int { try setSockOpt( level: SOL_SOCKET, name: SO_RCVBUF, value: bytes) return bytes } private func setSockOpt(level: Int32, name: Int32, value: Int) throws { var v = Int32(value) let res = withUnsafePointer(to: &v) { ptr -> Int32 in ptr.withMemoryRebound( to: UInt8.self, capacity: MemoryLayout.size ) { raw in #if canImport(Darwin) return setsockopt( self.fileDescriptor, level, name, raw, socklen_t(MemoryLayout.size)) #else fatalError("unsupported platform") #endif } } if res == -1 { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM) } } } ================================================ FILE: Sources/ContainerBuild/Globber.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 public class Globber { let input: URL var results: Set = .init() public init(_ input: URL) { self.input = input } public func match(_ pattern: String) throws { let adjustedPattern = pattern .replacingOccurrences(of: #"^\./(?=.)"#, with: "", options: .regularExpression) .replacingOccurrences(of: "^\\.[/]?$", with: "*", options: .regularExpression) .replacingOccurrences(of: "\\*{2,}[/]", with: "*/**/", options: .regularExpression) .replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression) .replacingOccurrences(of: "^\\*{2,}([^/])", with: "**/*$1", options: .regularExpression) for child in input.children { try self.match(input: child, components: adjustedPattern.split(separator: "/").map(String.init)) } } private func match(input: URL, components: [String]) throws { if components.isEmpty { var dir = input.standardizedFileURL while dir != self.input.standardizedFileURL { results.insert(dir) guard dir.pathComponents.count > 1 else { break } dir.deleteLastPathComponent() } return input.childrenRecursive.forEach { results.insert($0) } } let head = components.first ?? "" let tail = components.tail if head == "**" { var tail: [String] = tail while tail.first == "**" { tail = tail.tail } try self.match(input: input, components: tail) for child in input.children { try self.match(input: child, components: components) } return } if try glob(input.lastPathComponent, head) { try self.match(input: input, components: tail) for child in input.children where try glob(child.lastPathComponent, tail.first ?? "") { try self.match(input: child, components: tail) } return } } func glob(_ input: String, _ pattern: String) throws -> Bool { let regexPattern = "^" + NSRegularExpression.escapedPattern(for: pattern) .replacingOccurrences(of: "\\*", with: "[^/]*") .replacingOccurrences(of: "\\?", with: "[^/]") .replacingOccurrences(of: "[\\^", with: "[^") .replacingOccurrences(of: "\\[", with: "[") .replacingOccurrences(of: "\\]", with: "]") + "$" // validate the regex pattern created let _ = try Regex(regexPattern) return input.range(of: regexPattern, options: .regularExpression) != nil } } extension URL { var children: [URL] { (try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil)) ?? [] } var childrenRecursive: [URL] { var results: [URL] = [] if let enumerator = FileManager.default.enumerator( at: self, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey]) { while let child = enumerator.nextObject() as? URL { results.append(child) } } return [self] + results } } extension [String] { var tail: [String] { if self.count <= 1 { return [] } return Array(self.dropFirst()) } } ================================================ FILE: Sources/ContainerBuild/TerminalCommand.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 struct TerminalCommand: Codable { let commandType: String let code: String let rows: UInt16 let cols: UInt16 enum CodingKeys: String, CodingKey { case commandType = "command_type" case code case rows case cols } init(rows: UInt16, cols: UInt16) { self.commandType = "terminal" self.code = "winch" self.rows = rows self.cols = cols } init() { self.commandType = "terminal" self.code = "ack" self.rows = 0 self.cols = 0 } func json() throws -> String? { let encoder = JSONEncoder() let data = try encoder.encode(self) return data.base64EncodedString().trimmingCharacters(in: CharacterSet(charactersIn: "=")) } } ================================================ FILE: Sources/ContainerBuild/URL+Extensions.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Foundation extension String { fileprivate var fs_cleaned: String { var value = self if value.hasPrefix("file://") { value.removeFirst("file://".count) } if value.count > 1 && value.last == "/" { value.removeLast() } return value.removingPercentEncoding ?? value } fileprivate var fs_components: [String] { var parts: [String] = [] for segment in self.split(separator: "/", omittingEmptySubsequences: true) { switch segment { case ".": continue case "..": if !parts.isEmpty { parts.removeLast() } default: parts.append(String(segment)) } } return parts } fileprivate var fs_isAbsolute: Bool { first == "/" } } extension URL { var cleanPath: String { self.path.fs_cleaned } func parentOf(_ url: URL) -> Bool { let parentPath = self.absoluteURL.cleanPath let childPath = url.absoluteURL.cleanPath guard parentPath.fs_isAbsolute else { return true } let parentParts = parentPath.fs_components let childParts = childPath.fs_components guard parentParts.count <= childParts.count else { return false } return zip(parentParts, childParts).allSatisfy { $0 == $1 } } func relativeChildPath(to context: URL) throws -> String { guard context.parentOf(self) else { throw BuildFSSync.Error.pathIsNotChild(cleanPath, context.cleanPath) } let ctxParts = context.cleanPath.fs_components let selfParts = cleanPath.fs_components return selfParts.dropFirst(ctxParts.count).joined(separator: "/") } func relativePathFrom(from base: URL) -> String { let destParts = cleanPath.fs_components let baseParts = base.cleanPath.fs_components let common = zip(destParts, baseParts).prefix { $0 == $1 }.count guard common > 0 else { return cleanPath } let ups = Array(repeating: "..", count: baseParts.count - common) let remainder = destParts.dropFirst(common) return (ups + remainder).joined(separator: "/") } func zeroCopyReader( chunk: Int = 1024 * 1024, buffer: AsyncStream.Continuation.BufferingPolicy = .unbounded ) throws -> AsyncStream { let path = self.cleanPath let fd = open(path, O_RDONLY | O_NONBLOCK) guard fd >= 0 else { throw POSIXError.fromErrno() } let channel = DispatchIO( type: .stream, fileDescriptor: fd, queue: .global(qos: .userInitiated) ) { errno in close(fd) } channel.setLimit(highWater: chunk) return AsyncStream(bufferingPolicy: buffer) { continuation in channel.read( offset: 0, length: Int.max, queue: .global(qos: .userInitiated) ) { done, ddata, err in if err != 0 { continuation.finish() return } if let ddata, ddata.count > -1 { let data = Data(ddata) switch continuation.yield(data) { case .terminated: channel.close(flags: .stop) default: break } } if done { channel.close(flags: .stop) continuation.finish() } } } } 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() } } ================================================ FILE: Sources/ContainerCommands/AggregateError.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// /// An error type that aggregates multiple errors into one. /// /// When displayed, each underlying error is printed on its own line. public struct AggregateError: Swift.Error, Sendable { public let errors: [any Error] public init(_ errors: [any Error]) { self.errors = errors } } extension AggregateError: CustomStringConvertible { public var description: String { errors.map { String(describing: $0) }.joined(separator: "\n") } } ================================================ FILE: Sources/ContainerCommands/Application.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerLog import ContainerPlugin import ContainerVersion import ContainerizationError import ContainerizationOS import Foundation import Logging import TerminalProgress // This logger is only used until `asyncCommand.run()`. // `log` is updated only once in the `validate()` method. private nonisolated(unsafe) var bootstrapLogger = { LoggingSystem.bootstrap({ _ in StderrLogHandler() }) var log = Logger(label: "com.apple.container") log.logLevel = .info return log }() public struct Application: AsyncLoggableCommand { @OptionGroup public var logOptions: Flags.Logging public init() {} public static let configuration = CommandConfiguration( commandName: "container", abstract: "A container platform for macOS", version: ReleaseVersion.singleLine(appName: "container CLI"), subcommands: [ DefaultCommand.self ], groupedSubcommands: [ CommandGroup( name: "Container", subcommands: [ ContainerCreate.self, ContainerDelete.self, ContainerExec.self, ContainerExport.self, ContainerInspect.self, ContainerKill.self, ContainerList.self, ContainerLogs.self, ContainerRun.self, ContainerStart.self, ContainerStats.self, ContainerStop.self, ContainerPrune.self, ] ), CommandGroup( name: "Image", subcommands: [ BuildCommand.self, ImageCommand.self, RegistryCommand.self, ] ), CommandGroup( name: "Volume", subcommands: [ VolumeCommand.self ] ), CommandGroup( name: "Other", subcommands: Self.otherCommands() ), ], // Hidden command to handle plugins on unrecognized input. defaultSubcommand: DefaultCommand.self ) public static func main() async throws { restoreCursorAtExit() #if DEBUG let warning = "Running debug build. Performance may be degraded." let formattedWarning: String if isatty(FileHandle.standardError.fileDescriptor) == 1 { formattedWarning = "\u{001B}[33mWarning!\u{001B}[0m \(warning)\n" } else { formattedWarning = "Warning! \(warning)\n" } let warningData = Data(formattedWarning.utf8) FileHandle.standardError.write(warningData) #endif let fullArgs = CommandLine.arguments let args = Array(fullArgs.dropFirst()) do { // container -> defaultHelpCommand var command = try Application.parseAsRoot(args) if var asyncCommand = command as? AsyncParsableCommand { try await asyncCommand.run() } else { try command.run() } } catch { // Regular ol `command` with no args will get caught by DefaultCommand. --help // on the root command will land here. let containsHelp = fullArgs.contains("-h") || fullArgs.contains("--help") if fullArgs.count <= 2 && containsHelp { let pluginLoader = try? await createPluginLoader() await Self.printModifiedHelpText(pluginLoader: pluginLoader) return } let errorAsString: String = String(describing: error) if errorAsString.contains("XPC connection error") { let modifiedError = ContainerizationError(.interrupted, message: "\(error)\nEnsure container system service has been started with `container system start`.") Application.exit(withError: modifiedError) } else { Application.exit(withError: error) } } } public static func createPluginLoader() async throws -> PluginLoader { let installRoot = CommandLine.executablePathUrl .deletingLastPathComponent() .appendingPathComponent("..") .standardized let pluginsURL = PluginLoader.userPluginsDir(installRoot: installRoot) var directoryExists: ObjCBool = false _ = FileManager.default.fileExists(atPath: pluginsURL.path, isDirectory: &directoryExists) let userPluginsURL = directoryExists.boolValue ? pluginsURL : nil // plugins built into the application installed as a macOS app bundle let appBundlePluginsURL = Bundle.main.resourceURL?.appending(path: "plugins") // plugins built into the application installed as a Unix-like application let installRootPluginsURL = installRoot .appendingPathComponent("libexec") .appendingPathComponent("container") .appendingPathComponent("plugins") .standardized let pluginDirectories = [ userPluginsURL, appBundlePluginsURL, installRootPluginsURL, ].compactMap { $0 } let pluginFactories: [any PluginFactory] = [ DefaultPluginFactory(), AppBundlePluginFactory(), ] guard let systemHealth = try? await ClientHealthCheck.ping(timeout: .seconds(10)) else { throw ContainerizationError(.timeout, message: "unable to retrieve application data root from API server") } return try PluginLoader( appRoot: systemHealth.appRoot, installRoot: systemHealth.installRoot, logRoot: systemHealth.logRoot, pluginDirectories: pluginDirectories, pluginFactories: pluginFactories, log: bootstrapLogger ) } public func validate() throws { // Not really a "validation", but a cheat to run this before // any of the commands do their business. let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"] if self.logOptions.debug || debugEnvVar != nil { bootstrapLogger.logLevel = .debug } // Ensure we're not running under Rosetta. if try isTranslated() { throw ValidationError( """ `container` is currently running under Rosetta Translation, which could be caused by your terminal application. Please ensure this is turned off. """ ) } } private static func otherCommands() -> [any ParsableCommand.Type] { guard #available(macOS 26, *) else { return [ BuilderCommand.self, SystemCommand.self, ] } return [ BuilderCommand.self, NetworkCommand.self, SystemCommand.self, ] } private static func restoreCursorAtExit() { let signalHandler: @convention(c) (Int32) -> Void = { signal in let exitCode = ExitCode(signal + 128) Application.exit(withError: exitCode) } // Termination by Ctrl+C. signal(SIGINT, signalHandler) // Termination using `kill`. signal(SIGTERM, signalHandler) // Normal and explicit exit. atexit { if let progressConfig = try? ProgressConfig() { let progressBar = ProgressBar(config: progressConfig) progressBar.resetCursor() } } } } extension Application { // Because we support plugins, we need to modify the help text to display // any if we found some. static func printModifiedHelpText(pluginLoader: PluginLoader?) async { let original = Application.helpMessage(for: Application.self) guard let pluginLoader else { print(original) print("PLUGINS: not available, run `container system start`") return } let altered = pluginLoader.alterCLIHelpText(original: original) print(altered) } public enum ListFormat: String, CaseIterable, ExpressibleByArgument { case json case table } func isTranslated() throws -> Bool { do { return try Sysctl.byName("sysctl.proc_translated") == 1 } catch let posixErr as POSIXError { if posixErr.code == .ENOENT { return false } throw posixErr } } } ================================================ FILE: Sources/ContainerCommands/AsyncLoggableCommand.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerLog import Logging public protocol AsyncLoggableCommand: AsyncParsableCommand { var logOptions: Flags.Logging { get } } extension AsyncLoggableCommand { /// A shared logger instance configured based on the command's options public var log: Logger { var logger = Logger(label: "container", factory: { _ in StderrLogHandler() }) logger.logLevel = logOptions.debug ? .debug : .info return logger } } ================================================ FILE: Sources/ContainerCommands/BuildCommand.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerBuild import ContainerImagesServiceClient import Containerization import ContainerizationError import ContainerizationOCI import ContainerizationOS import Foundation import NIO import TerminalProgress extension Application { public struct BuildCommand: AsyncLoggableCommand { private static let hiddenDockerDir = ".com.apple.container.dockerfiles" public init() {} public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "build" config.abstract = "Build an image from a Dockerfile or Containerfile" config._superCommandName = "container" config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) return config } enum ProgressType: String, ExpressibleByArgument { case auto case plain case tty } enum SecretType: Decodable { case data(Data) case file(String) } @Option( name: .shortAndLong, help: ArgumentHelp("Add the architecture type to the build", valueName: "value"), transform: { val in val.split(separator: ",").map { String($0) } } ) var arch: [[String]] = { [[Arch.hostArchitecture().rawValue]] }() @Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val")) var buildArg: [String] = [] @Option(name: .long, help: ArgumentHelp("Cache imports for the build", valueName: "value", visibility: .hidden)) var cacheIn: [String] = { [] }() @Option(name: .long, help: ArgumentHelp("Cache exports for the build", valueName: "value", visibility: .hidden)) var cacheOut: [String] = { [] }() @Option(name: .shortAndLong, help: "Number of CPUs to allocate to the builder container") var cpus: Int64? @Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path")) var file: String? var dockerfile: String = "-" @Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val")) var label: [String] = [] @Option( name: .shortAndLong, help: "Amount of builder container memory (1MiByte granularity), with optional K, M, G, T, or P suffix" ) var memory: String? @Flag(name: .long, help: "Do not use cache") var noCache: Bool = false @Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build (format: type=[,dest=])", valueName: "value")) var output: [String] = { ["type=oci"] }() @Option( name: .long, help: ArgumentHelp("Add the OS type to the build", valueName: "value"), transform: { val in val.split(separator: ",").map { String($0) } } ) var os: [[String]] = { [["linux"]] }() @Option( name: .long, help: "Add the platform to the build (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]", transform: { val in val.split(separator: ",").map { String($0) } } ) var platform: [[String]] = [[]] @Option(name: .long, help: ArgumentHelp("Progress type (format: auto|plain|tty)", valueName: "type")) var progress: ProgressType = .auto @Flag(name: .shortAndLong, help: "Suppress build output") var quiet: Bool = false @Option(name: .long, help: ArgumentHelp("Set build-time secrets (format: id=[,env=|,src=])", valueName: "id=key,...")) var secret: [String] = [] var secrets: [String: SecretType] = [:] @Option(name: [.short, .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) var targetImageNames: [String] = { [UUID().uuidString.lowercased()] }() @Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage")) var target: String = "" @Option(name: .long, help: ArgumentHelp("Builder shim vsock port", valueName: "port")) var vsockPort: UInt32 = 8088 @OptionGroup public var logOptions: Flags.Logging @OptionGroup public var dns: Flags.DNS @Argument(help: "Build directory") var contextDir: String = "." @Flag(name: .long, help: "Pull latest image") var pull: Bool = false public func run() async throws { do { let timeout: Duration = .seconds(300) let progressConfig = try ProgressConfig( showTasks: true, showItems: true ) let progress = ProgressBar(config: progressConfig) defer { progress.finish() } progress.start() progress.set(description: "Dialing builder") let dnsNameservers = self.dns.nameservers let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { [vsockPort, cpus, memory, dnsNameservers] group in defer { group.cancelAll() } group.addTask { [vsockPort, cpus, memory, log, dnsNameservers] in let client = ContainerClient() while true { do { let fh = try await client.dial(id: "buildkit", port: vsockPort) let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) let b = try Builder(socket: fh, group: threadGroup) // If this call succeeds, then BuildKit is running. let _ = try await b.info() return b } catch { // If we get here, "Dialing builder" is shown for such a short period // of time that it's invisible to the user. progress.set(tasks: 0) progress.set(totalTasks: 3) try await BuilderStart.start( cpus: cpus, memory: memory, log: log, dnsNameservers: dnsNameservers, progressUpdate: progress.handler ) // wait (seconds) for builder to start listening on vsock try await Task.sleep(for: .seconds(5)) continue } } } group.addTask { try await Task.sleep(for: timeout) throw ValidationError( """ Timeout waiting for connection to builder """ ) } return try await group.next() } guard let builder else { throw ValidationError("builder is not running") } let buildFileData: Data var ignoreFileData: Data? = nil var hiddenDockerDir: String? = nil // Dockerfile should be read from stdin if dockerfile == "-" { let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("Dockerfile-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: tempFile) } guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else { throw ContainerizationError(.internalError, message: "unable to create temporary file") } guard let fileHandle = try? FileHandle(forWritingTo: tempFile) else { throw ContainerizationError(.internalError, message: "unable to open temporary file for writing") } let bufferSize = 4096 while true { let chunk = FileHandle.standardInput.readData(ofLength: bufferSize) if chunk.isEmpty { break } fileHandle.write(chunk) } try fileHandle.close() buildFileData = try Data(contentsOf: URL(filePath: tempFile.path())) } else { let ignoreFileURL = URL(filePath: dockerfile + ".dockerignore") buildFileData = try Data(contentsOf: URL(filePath: dockerfile)) ignoreFileData = try? Data(contentsOf: ignoreFileURL) if var ignoreFileData { hiddenDockerDir = Self.hiddenDockerDir let hiddenDirInContext = URL(fileURLWithPath: contextDir).appendingPathComponent(Self.hiddenDockerDir) try FileManager.default.createDirectory(at: hiddenDirInContext, withIntermediateDirectories: true) try buildFileData.write(to: hiddenDirInContext.appendingPathComponent("Dockerfile")) ignoreFileData.append("\n\(Self.hiddenDockerDir)".data(using: .utf8) ?? Data()) try ignoreFileData.write(to: hiddenDirInContext.appendingPathComponent("Dockerfile.dockerignore")) } } defer { if let hiddenDockerDir { let hiddenDirInContext = URL(fileURLWithPath: contextDir).appendingPathComponent(hiddenDockerDir) try? FileManager.default.removeItem(at: hiddenDirInContext) } } let secretsData: [String: Data] = try self.secrets.mapValues { secret in switch secret { case .data(let data): return data case .file(let path): return try Data(contentsOf: URL(fileURLWithPath: path)) } } let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10)) let exportPath = systemHealth.appRoot .appendingPathComponent(Application.BuilderCommand.builderResourceDir) let buildID = UUID().uuidString let tempURL = exportPath.appendingPathComponent(buildID) try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil) defer { try? FileManager.default.removeItem(at: tempURL) } let imageNames: [String] = try targetImageNames.map { name in let parsedReference = try Reference.parse(name) parsedReference.normalize() return parsedReference.description } var terminal: Terminal? switch self.progress { case .tty: terminal = try Terminal(descriptor: STDERR_FILENO) case .auto: terminal = try? Terminal(descriptor: STDERR_FILENO) case .plain: terminal = nil } defer { terminal?.tryReset() } let exports: [Builder.BuildExport] = try output.map { output in var exp = try Builder.BuildExport(from: output) if exp.destination == nil { exp.destination = tempURL.appendingPathComponent("out.tar") } return exp } try await withThrowingTaskGroup(of: Void.self) { [terminal] group in defer { group.cancelAll() } group.addTask { let handler = AsyncSignalHandler.create(notify: [SIGTERM, SIGINT, SIGUSR1, SIGUSR2]) for await sig in handler.signals { throw ContainerizationError(.interrupted, message: "exiting on signal \(sig)") } } let platforms: Set = try { var results: Set = [] for platform in (self.platform.flatMap { $0 }) { guard let p = try? Platform(from: platform) else { throw ValidationError("invalid platform specified \(platform)") } results.insert(p) } if !results.isEmpty { return results } if let envPlatform = try DefaultPlatform.fromEnvironment(log: log) { return [envPlatform] } for o in (self.os.flatMap { $0 }) { for a in (self.arch.flatMap { $0 }) { guard let platform = try? Platform(from: "\(o)/\(a)") else { throw ValidationError("invalid os/architecture combination \(o)/\(a)") } results.insert(platform) } } return results }() group.addTask { [terminal, buildArg, secretsData, contextDir, hiddenDockerDir, label, noCache, target, quiet, cacheIn, cacheOut, pull] in let config = Builder.BuildConfig( buildID: buildID, contentStore: RemoteContentStoreClient(), buildArgs: buildArg, secrets: secretsData, contextDir: contextDir, dockerfile: buildFileData, hiddenDockerDir: hiddenDockerDir, labels: label, noCache: noCache, platforms: [Platform](platforms), terminal: terminal, tags: imageNames, target: target, quiet: quiet, exports: exports, cacheIn: cacheIn, cacheOut: cacheOut, pull: pull ) progress.finish() try await builder.build(config) } try await group.next() } let unpackProgressConfig = try ProgressConfig( description: "Unpacking built image", itemsName: "entries", showTasks: exports.count > 1, totalTasks: exports.count ) let unpackProgress = ProgressBar(config: unpackProgressConfig) defer { unpackProgress.finish() } unpackProgress.start() var finalMessage = "Successfully built \(imageNames.joined(separator: ", "))" let taskManager = ProgressTaskCoordinator() // Currently, only a single export can be specified. for exp in exports { unpackProgress.add(tasks: 1) let unpackTask = await taskManager.startTask() switch exp.type { case "oci": try Task.checkCancellation() guard let dest = exp.destination else { throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)") } let result = try await ClientImage.load(from: dest.absolutePath(), force: false) guard result.rejectedMembers.isEmpty else { log.error("archive contains invalid members", metadata: ["paths": "\(result.rejectedMembers)"]) throw ContainerizationError(.internalError, message: "failed to load archive") } for image in result.images { try Task.checkCancellation() try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler)) // Tag the unpacked image with all requested tags for tagName in imageNames { try Task.checkCancellation() _ = try await image.tag(new: tagName) } } case "tar": guard let dest = exp.destination else { throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)") } let tarURL = tempURL.appendingPathComponent("out.tar") try FileManager.default.moveItem(at: tarURL, to: dest) finalMessage = "Successfully exported to \(dest.absolutePath())" case "local": guard let dest = exp.destination else { throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)") } let localDir = tempURL.appendingPathComponent("local") guard FileManager.default.fileExists(atPath: localDir.path) else { throw ContainerizationError(.invalidArgument, message: "expected local output not found") } try FileManager.default.copyItem(at: localDir, to: dest) finalMessage = "Successfully exported to \(dest.absolutePath())" default: throw ContainerizationError(.invalidArgument, message: "invalid exporter \(exp.rawValue)") } } await taskManager.finish() unpackProgress.finish() print(finalMessage) } catch { throw NSError(domain: "Build", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(error)"]) } } public mutating func validate() throws { // NOTE: Here we check the Dockerfile exists, and set `dockerfile` to point the valid Dockerfile path or stdin guard FileManager.default.fileExists(atPath: contextDir) else { throw ValidationError("context dir does not exist \(contextDir)") } for name in targetImageNames { guard let _ = try? Reference.parse(name) else { throw ValidationError("invalid reference \(name)") } } switch file { case "-": dockerfile = "-" break case .some(let filepath): let fileURL = URL(fileURLWithPath: filepath, relativeTo: .currentDirectory()) guard FileManager.default.fileExists(atPath: fileURL.path) else { throw ValidationError("dockerfile does not exist \(filepath)") } dockerfile = fileURL.path break case .none: guard let defaultDockerfile = try BuildFile.resolvePath(contextDir: contextDir) else { throw ValidationError("dockerfile not found in context dir") } guard FileManager.default.fileExists(atPath: defaultDockerfile) else { throw ValidationError("dockerfile does not exist \(defaultDockerfile)") } dockerfile = defaultDockerfile break } // Parse --secret args for secret in self.secret { let parts = secret.split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false) guard parts[0].hasPrefix("id=") else { throw ValidationError("secret must start with id= \(secret)") } let key = String(parts[0].dropFirst(3)) guard !key.contains("=") else { throw ValidationError("secret id cannot contain '=' \(key)") } if parts.count == 1 || parts[1].hasPrefix("env=") { let env = parts.count == 1 ? key : String(parts[1].dropFirst(4)) // Using getenv/strlen over processInfo.environment to support // non-UTF-8 env var data. guard let ptr = getenv(env) else { throw ValidationError("secret env var doesn't exist \(env)") } self.secrets[key] = .data(Data(bytes: ptr, count: strlen(ptr))) } else if parts[1].hasPrefix("src=") { let path = String(parts[1].dropFirst(4)) self.secrets[key] = .file(path) } else { throw ValidationError("secret bad value \(parts[1])") } } } } } ================================================ FILE: Sources/ContainerCommands/Builder/Builder.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient extension Application { public struct BuilderCommand: AsyncLoggableCommand { public init() {} public static let builderResourceDir = "builder" public static let configuration = CommandConfiguration( commandName: "builder", abstract: "Manage an image builder instance", subcommands: [ BuilderStart.self, BuilderStatus.self, BuilderStop.self, BuilderDelete.self, ]) @OptionGroup public var logOptions: Flags.Logging } } ================================================ FILE: Sources/ContainerCommands/Builder/BuilderDelete.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerizationError import Foundation extension Application { public struct BuilderDelete: AsyncLoggableCommand { public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "delete" config.aliases = ["rm"] config.abstract = "Delete the builder container" return config } @Flag(name: .shortAndLong, help: "Delete the builder even if it is running") var force = false @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { do { let client = ContainerClient() let container = try await client.get(id: "buildkit") if container.status != .stopped { guard force else { throw ContainerizationError(.invalidState, message: "BuildKit container is not stopped, use --force to override") } try await client.stop(id: container.id) } try await client.delete(id: container.id) } catch { if error is ContainerizationError { if (error as? ContainerizationError)?.code == .notFound { return } } throw error } } } } ================================================ FILE: Sources/ContainerCommands/Builder/BuilderStart.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerBuild import ContainerPersistence import ContainerResource import Containerization import ContainerizationError import ContainerizationExtras import ContainerizationOCI import Foundation import Logging import TerminalProgress extension Application { public struct BuilderStart: AsyncLoggableCommand { static let defaultCPUs = 2 static let defaultMemoryInBytes: UInt64 = 2048.mib() public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "start" config.abstract = "Start the builder container" return config } @Option(name: .shortAndLong, help: "Number of CPUs to allocate to the builder container") var cpus: Int64? @Option( name: .shortAndLong, help: "Amount of builder container memory (1MiByte granularity), with optional K, M, G, T, or P suffix" ) var memory: String? @OptionGroup public var dns: Flags.DNS @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { let progressConfig = try ProgressConfig( showTasks: true, showItems: true, totalTasks: 4 ) let progress = ProgressBar(config: progressConfig) defer { progress.finish() } progress.start() try await Self.start( cpus: self.cpus, memory: self.memory, log: log, dnsNameservers: self.dns.nameservers, dnsDomain: self.dns.domain, dnsSearchDomains: self.dns.searchDomains, dnsOptions: self.dns.options, progressUpdate: progress.handler ) progress.finish() } static func start( cpus: Int64?, memory: String?, log: Logger, dnsNameservers: [String] = [], dnsDomain: String? = nil, dnsSearchDomains: [String] = [], dnsOptions: [String] = [], progressUpdate: @escaping ProgressUpdateHandler ) async throws { await progressUpdate([ .setDescription("Fetching BuildKit image"), .setItemsName("blobs"), ]) let taskManager = ProgressTaskCoordinator() let fetchTask = await taskManager.startTask() let builderImage: String = DefaultsStore.get(key: .defaultBuilderImage) let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10)) let exportsMount: String = systemHealth.appRoot .appendingPathComponent(Application.BuilderCommand.builderResourceDir) .absolutePath() if !FileManager.default.fileExists(atPath: exportsMount) { try FileManager.default.createDirectory( atPath: exportsMount, withIntermediateDirectories: true, attributes: nil ) } let builderPlatform = ContainerizationOCI.Platform(arch: "arm64", os: "linux", variant: "v8") var targetEnvVars: [String] = [] if let buildkitColors = ProcessInfo.processInfo.environment["BUILDKIT_COLORS"] { targetEnvVars.append("BUILDKIT_COLORS=\(buildkitColors)") } if ProcessInfo.processInfo.environment["NO_COLOR"] != nil { targetEnvVars.append("NO_COLOR=true") } targetEnvVars.sort() let client = ContainerClient() let existingContainer = try? await client.get(id: "buildkit") if let existingContainer { let existingImage = existingContainer.configuration.image.reference let existingResources = existingContainer.configuration.resources let existingEnv = existingContainer.configuration.initProcess.environment let existingDNS = existingContainer.configuration.dns let existingManagedEnv = existingEnv.filter { envVar in envVar.hasPrefix("BUILDKIT_COLORS=") || envVar.hasPrefix("NO_COLOR=") }.sorted() let envChanged = existingManagedEnv != targetEnvVars // Check if we need to recreate the builder due to different image let imageChanged = existingImage != builderImage let resolvedResources = try Parser.resources( cpus: cpus, memory: memory, cpuPropertyKey: .defaultBuildCPUs, memoryPropertyKey: .defaultBuildMemory, defaultCPUs: Self.defaultCPUs, defaultMemoryInBytes: Self.defaultMemoryInBytes ) let cpuChanged = existingResources.cpus != resolvedResources.cpus let memChanged = existingResources.memoryInBytes != resolvedResources.memoryInBytes let dnsChanged = { if !dnsNameservers.isEmpty { return existingDNS?.nameservers != dnsNameservers } if dnsDomain != nil { return existingDNS?.domain != dnsDomain } if !dnsSearchDomains.isEmpty { return existingDNS?.searchDomains != dnsSearchDomains } if !dnsOptions.isEmpty { return existingDNS?.options != dnsOptions } return false }() switch existingContainer.status { case .running: guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged else { // If image, mem, cpu, env, and DNS are the same, continue using the existing builder return } // If they changed, stop and delete the existing builder try await client.stop(id: existingContainer.id) try await client.delete(id: existingContainer.id) case .stopped: // If the builder is stopped and matches our requirements, start it // Otherwise, delete it and create a new one guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged else { try await startBuildKit(client: client, id: existingContainer.id, progressUpdate, nil) return } try await client.delete(id: existingContainer.id) case .stopping: throw ContainerizationError( .invalidState, message: "builder is stopping, please wait until it is fully stopped before proceeding" ) case .unknown: break } } let useRosetta = DefaultsStore.getBool(key: .buildRosetta) ?? true let shimArguments = [ "--debug", "--vsock", useRosetta ? nil : "--enable-qemu", ].compactMap { $0 } try ContainerAPIClient.Utility.validEntityName(Builder.builderContainerId) let image = try await ClientImage.fetch( reference: builderImage, platform: builderPlatform, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate) ) // Unpack fetched image before use await progressUpdate([ .setDescription("Unpacking BuildKit image"), .setItemsName("entries"), ]) let unpackTask = await taskManager.startTask() _ = try await image.getCreateSnapshot( platform: builderPlatform, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate) ) let imageDesc = ImageDescription( reference: builderImage, descriptor: image.descriptor ) let imageConfig = try await image.config(for: builderPlatform).config var environment = imageConfig?.env ?? [] environment.append(contentsOf: targetEnvVars) let processConfig = ProcessConfiguration( executable: "/usr/local/bin/container-builder-shim", arguments: shimArguments, environment: environment, workingDirectory: "/", terminal: false, user: .id(uid: 0, gid: 0) ) let resources = try Parser.resources( cpus: cpus, memory: memory, cpuPropertyKey: .defaultBuildCPUs, memoryPropertyKey: .defaultBuildMemory, defaultCPUs: Self.defaultCPUs, defaultMemoryInBytes: Self.defaultMemoryInBytes ) var config = ContainerConfiguration(id: Builder.builderContainerId, image: imageDesc, process: processConfig) config.resources = resources config.labels = [ResourceLabelKeys.role: ResourceRoleValues.builder] config.mounts = [ .init( type: .tmpfs, source: "", destination: "/run", options: [] ), .init( type: .virtiofs, source: exportsMount, destination: "/var/lib/container-builder-shim/exports", options: [] ), ] // Enable Rosetta only if the user didn't ask to disable it config.rosetta = useRosetta guard let defaultNetwork = try await ClientNetwork.builtin else { throw ContainerizationError(.invalidState, message: "default network is not present") } guard case .running(_, let networkStatus) = defaultNetwork else { throw ContainerizationError(.invalidState, message: "default network is not running") } config.networks = [ AttachmentConfiguration(network: defaultNetwork.id, options: AttachmentOptions(hostname: Builder.builderContainerId)) ] let subnet = networkStatus.ipv4Subnet let nameserver = IPv4Address(subnet.lower.value + 1).description let nameservers = dnsNameservers.isEmpty ? [nameserver] : dnsNameservers config.dns = ContainerConfiguration.DNSConfiguration( nameservers: nameservers, domain: dnsDomain, searchDomains: dnsSearchDomains, options: dnsOptions ) let kernel = try await { await progressUpdate([ .setDescription("Fetching kernel"), .setItemsName("binary"), ]) let kernel = try await ClientKernel.getDefaultKernel(for: .current) return kernel }() await progressUpdate([ .setDescription("Starting BuildKit container") ]) try await client.create( configuration: config, options: .default, kernel: kernel ) try await startBuildKit(client: client, id: Builder.builderContainerId, progressUpdate, taskManager) log.debug("starting BuildKit and BuildKit-shim") } } } // MARK: - BuildKit Start Helper /// Starts the BuildKit process within the container /// This function handles bootstrapping the container and starting the BuildKit process private func startBuildKit( client: ContainerClient, id: String, _ progress: @escaping ProgressUpdateHandler, _ taskManager: ProgressTaskCoordinator? = nil ) async throws { do { let io = try ProcessIO.create( tty: false, interactive: false, detach: true ) defer { try? io.close() } let process = try await client.bootstrap(id: id, stdio: io.stdio) try await process.start() await taskManager?.finish() try io.closeAfterStart() } catch { try? await client.stop(id: id) try? await client.delete(id: id) if error is ContainerizationError { throw error } throw ContainerizationError(.internalError, message: "failed to start BuildKit: \(error)") } } ================================================ FILE: Sources/ContainerCommands/Builder/BuilderStatus.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import ContainerizationError import ContainerizationExtras import Foundation extension Application { public struct BuilderStatus: AsyncLoggableCommand { public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "status" config.abstract = "Display the builder container status" return config } @Option(name: .long, help: "Format of the output") var format: ListFormat = .table @Flag(name: .shortAndLong, help: "Only output the container ID") var quiet = false @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { do { let client = ContainerClient() let container = try await client.get(id: "buildkit") try printContainers(containers: [container], format: format) } catch { if error is ContainerizationError { if (error as? ContainerizationError)?.code == .notFound && !quiet { print("builder is not running") return } } throw error } } private func createHeader() -> [[String]] { [["ID", "IMAGE", "STATE", "ADDR", "CPUS", "MEMORY"]] } private func printContainers(containers: [ContainerSnapshot], format: ListFormat) throws { if format == .json { let printables = containers.map { PrintableContainer($0) } let data = try JSONEncoder().encode(printables) print(String(decoding: data, as: UTF8.self)) return } if self.quiet { containers .filter { $0.status == .running } .forEach { print($0.id) } return } var rows = createHeader() for container in containers { rows.append(container.asRow) } let formatter = TableOutput(rows: rows) print(formatter.format()) } } } extension ContainerSnapshot { fileprivate var asRow: [String] { [ self.id, self.configuration.image.reference, self.status.rawValue, self.networks.compactMap { $0.ipv4Address.description }.joined(separator: ","), "\(self.configuration.resources.cpus)", "\(self.configuration.resources.memoryInBytes / (1024 * 1024)) MB", ] } } ================================================ FILE: Sources/ContainerCommands/Builder/BuilderStop.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerizationError import Foundation extension Application { public struct BuilderStop: AsyncLoggableCommand { public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "stop" config.abstract = "Stop the builder container" return config } @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { do { let client = ContainerClient() try await client.stop(id: "buildkit") } catch { if error is ContainerizationError { if (error as? ContainerizationError)?.code == .notFound { print("builder is not running") return } } throw error } } } } ================================================ FILE: Sources/ContainerCommands/Codable+JSON.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Foundation extension [any Codable] { func jsonArray() throws -> String { "[\(try self.map { String(decoding: try JSONEncoder().encode($0), as: UTF8.self) }.joined(separator: ","))]" } } ================================================ FILE: Sources/ContainerCommands/Container/ContainerCreate.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import ContainerizationError import Foundation import TerminalProgress extension Application { public struct ContainerCreate: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a new container") @OptionGroup(title: "Process options") var processFlags: Flags.Process @OptionGroup(title: "Resource options") var resourceFlags: Flags.Resource @OptionGroup(title: "Management options") var managementFlags: Flags.Management @OptionGroup(title: "Registry options") var registryFlags: Flags.Registry @OptionGroup(title: "Image fetch options") var imageFetchFlags: Flags.ImageFetch @OptionGroup public var logOptions: Flags.Logging @Argument(help: "Image name") var image: String @Argument(parsing: .captureForPassthrough, help: "Container init process arguments") var arguments: [String] = [] public func run() async throws { let progressConfig = try ProgressConfig( showTasks: true, showItems: true, ignoreSmallSize: true, totalTasks: 3 ) let progress = ProgressBar(config: progressConfig) defer { progress.finish() } progress.start() let id = Utility.createContainerID(name: self.managementFlags.name) try Utility.validEntityName(id) let ck = try await Utility.containerConfigFromFlags( id: id, image: image, arguments: arguments, process: processFlags, management: managementFlags, resource: resourceFlags, registry: registryFlags, imageFetch: imageFetchFlags, progressUpdate: progress.handler, log: log ) let options = ContainerCreateOptions(autoRemove: managementFlags.remove) let client = ContainerClient() try await client.create(configuration: ck.0, options: options, kernel: ck.1, initImage: ck.2) if !self.managementFlags.cidfile.isEmpty { let path = self.managementFlags.cidfile let data = id.data(using: .utf8) var attributes = [FileAttributeKey: Any]() attributes[.posixPermissions] = 0o644 let success = FileManager.default.createFile( atPath: path, contents: data, attributes: attributes ) guard success else { throw ContainerizationError( .internalError, message: "failed to create cidfile at \(path): \(errno)") } } progress.finish() print(id) } } } ================================================ FILE: Sources/ContainerCommands/Container/ContainerDelete.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import ContainerizationError import Foundation extension Application { public struct ContainerDelete: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "delete", abstract: "Delete one or more containers", aliases: ["rm"]) @Flag(name: .shortAndLong, help: "Delete all containers") var all = false @Flag(name: .shortAndLong, help: "Delete containers even if they are running") var force = false @OptionGroup public var logOptions: Flags.Logging @Argument(help: "Container IDs") var containerIds: [String] = [] public func validate() throws { if containerIds.count == 0 && !all { throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") } if containerIds.count > 0 && all { throw ContainerizationError( .invalidArgument, message: "explicitly supplied container ID(s) conflict with the --all flag" ) } } public mutating func run() async throws { let set = Set(containerIds) let client = ContainerClient() var containers = [ContainerSnapshot]() if all { containers = try await client.list() } else { let ctrs = try await client.list() containers = ctrs.filter { c in set.contains(c.id) } // If one of the containers requested isn't present, let's throw. We don't need to do // this for --all as --all should be perfectly usable with no containers to remove; otherwise, // it'd be quite clunky. if containers.count != set.count { let missing = set.filter { id in !containers.contains { c in c.id == id } } throw ContainerizationError( .notFound, message: "failed to delete one or more containers: \(missing)" ) } } var errors: [any Error] = [] let force = self.force let all = self.all try await withThrowingTaskGroup(of: (any Error)?.self) { group in for container in containers { group.addTask { do { if container.status == .running && !force { guard all else { throw ContainerizationError(.invalidState, message: "container is running") } return nil // Skip running container when using --all } try await client.delete(id: container.id, force: force) print(container.id) return nil } catch { return error } } } for try await error in group { if let error { errors.append(error) } } } if !errors.isEmpty { throw AggregateError(errors) } } } } ================================================ FILE: Sources/ContainerCommands/Container/ContainerExec.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerizationError import ContainerizationOS import Foundation extension Application { public struct ContainerExec: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "exec", abstract: "Run a new command in a running container") @OptionGroup(title: "Process options") var processFlags: Flags.Process @OptionGroup public var logOptions: Flags.Logging @Flag(name: .shortAndLong, help: "Run the process and detach from it") var detach = false @Argument(help: "Container ID") var containerId: String @Argument(parsing: .captureForPassthrough, help: "New process arguments") var arguments: [String] public func run() async throws { var exitCode: Int32 = 127 let client = ContainerClient() let container = try await client.get(id: containerId) try ensureRunning(container: container) let stdin = self.processFlags.interactive let tty = self.processFlags.tty var config = container.configuration.initProcess config.executable = arguments.first! config.arguments = [String](self.arguments.dropFirst()) config.terminal = tty config.environment.append( contentsOf: try Parser.allEnv( imageEnvs: [], envFiles: self.processFlags.envFile, envs: self.processFlags.env )) if let cwd = self.processFlags.cwd { config.workingDirectory = cwd } let defaultUser = config.user let (user, additionalGroups) = Parser.user( user: processFlags.user, uid: processFlags.uid, gid: processFlags.gid, defaultUser: defaultUser) config.user = user config.supplementalGroups.append(contentsOf: additionalGroups) do { let io = try ProcessIO.create(tty: tty, interactive: stdin, detach: self.detach) defer { try? io.close() } let process = try await client.createProcess( containerId: container.id, processId: UUID().uuidString.lowercased(), configuration: config, stdio: io.stdio ) if self.detach { try await process.start() try io.closeAfterStart() print(containerId) return } if !self.processFlags.tty { var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) handler.start { print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") Darwin.exit(1) } } exitCode = try await io.handleProcess(process: process, log: log) } catch { if error is ContainerizationError { throw error } throw ContainerizationError(.internalError, message: "failed to exec process \(error)") } throw ArgumentParser.ExitCode(exitCode) } } } ================================================ FILE: Sources/ContainerCommands/Container/ContainerExport.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerizationError import Foundation import TerminalProgress extension Application { public struct ContainerExport: AsyncLoggableCommand { public init() {} public static var configuration: CommandConfiguration { CommandConfiguration( commandName: "export", abstract: "Export a container's filesystem as a tar archive", ) } @OptionGroup public var logOptions: Flags.Logging @Option( name: .shortAndLong, help: "Pathname for the saved container filesystem (defaults to stdout)", completion: .file(), transform: { str in URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) }) var output: String? @Argument(help: "container ID") var id: String public func run() async throws { let client = ContainerClient() let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let archive = tempDir.appendingPathComponent("archive.tar") try await client.export(id: id, archive: archive) if output == nil { guard let fileHandle = try? FileHandle(forReadingFrom: archive) else { throw ContainerizationError(.internalError, message: "unable to open archive for reading") } let bufferSize = 4096 while true { let chunk = fileHandle.readData(ofLength: bufferSize) if chunk.isEmpty { break } FileHandle.standardOutput.write(chunk) } try fileHandle.close() } else { try FileManager.default.moveItem(at: archive, to: URL(fileURLWithPath: output!)) } } } } ================================================ FILE: Sources/ContainerCommands/Container/ContainerInspect.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import Foundation import SwiftProtobuf extension Application { public struct ContainerInspect: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "inspect", abstract: "Display information about one or more containers") @OptionGroup public var logOptions: Flags.Logging @Argument(help: "Container IDs to inspect") var containerIds: [String] public func run() async throws { let client = ContainerClient() let objects: [any Codable] = try await client.list().filter { containerIds.contains($0.id) }.map { PrintableContainer($0) } print(try objects.jsonArray()) } } } ================================================ FILE: Sources/ContainerCommands/Container/ContainerKill.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import ContainerizationError import ContainerizationOS import Darwin extension Application { public struct ContainerKill: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "kill", abstract: "Kill or signal one or more running containers") @Flag(name: .shortAndLong, help: "Kill or signal all running containers") var all = false @Option(name: .shortAndLong, help: "Signal to send to the container(s)") var signal: String = "KILL" @OptionGroup public var logOptions: Flags.Logging @Argument(help: "Container IDs") var containerIds: [String] = [] public func validate() throws { if containerIds.count == 0 && !all { throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") } if containerIds.count > 0 && all { throw ContainerizationError(.invalidArgument, message: "explicitly supplied container IDs conflict with the --all flag") } } public mutating func run() async throws { let client = ContainerClient() let containers: [String] if self.all { containers = try await client.list(filters: ContainerListFilters(status: .running)).map { $0.id } } else { containers = containerIds } let signalNumber = try Signals.parseSignal(signal) var errors: [any Error] = [] for container in containers { do { try await client.kill(id: container, signal: signalNumber) print(container) } catch { errors.append(error) } } if !errors.isEmpty { throw AggregateError(errors) } } } } ================================================ FILE: Sources/ContainerCommands/Container/ContainerList.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import ContainerizationExtras import Foundation import SwiftProtobuf extension Application { public struct ContainerList: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "list", abstract: "List running containers", aliases: ["ls"]) @Flag(name: .shortAndLong, help: "Include containers that are not running") var all = false @Option(name: .long, help: "Format of the output") var format: ListFormat = .table @Flag(name: .shortAndLong, help: "Only output the container ID") var quiet = false @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { let client = ContainerClient() let filters = self.all ? ContainerListFilters.all : ContainerListFilters(status: .running) let containers = try await client.list(filters: filters) try printContainers(containers: containers, format: format) } private func createHeader() -> [[String]] { [["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR", "CPUS", "MEMORY", "STARTED"]] } private func printContainers(containers: [ContainerSnapshot], format: ListFormat) throws { if format == .json { let printables = containers.map { PrintableContainer($0) } let data = try JSONEncoder().encode(printables) print(String(decoding: data, as: UTF8.self)) return } if self.quiet { containers.forEach { print($0.id) } return } var rows = createHeader() for container in containers { rows.append(container.asRow) } let formatter = TableOutput(rows: rows) print(formatter.format()) } } } extension ContainerSnapshot { fileprivate var asRow: [String] { [ self.id, self.configuration.image.reference, self.platform.os, self.platform.architecture, self.status.rawValue, self.networks.compactMap { $0.ipv4Address.description }.joined(separator: ","), "\(self.configuration.resources.cpus)", "\(self.configuration.resources.memoryInBytes / (1024 * 1024)) MB", self.startedDate.map { ISO8601DateFormatter().string(from: $0) } ?? "", ] } } struct PrintableContainer: Codable { let status: RuntimeStatus let configuration: ContainerConfiguration let networks: [Attachment] let startedDate: Date? init(_ container: ContainerSnapshot) { self.status = container.status self.configuration = container.configuration self.networks = container.networks self.startedDate = container.startedDate } } ================================================ FILE: Sources/ContainerCommands/Container/ContainerLogs.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerizationError import Darwin import Dispatch import Foundation extension Application { public struct ContainerLogs: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "logs", abstract: "Fetch container logs" ) @Flag(name: .long, help: "Display the boot log for the container instead of stdio") var boot: Bool = false @Flag(name: .shortAndLong, help: "Follow log output") var follow: Bool = false @Option(name: .short, help: "Number of lines to show from the end of the logs. If not provided this will print all of the logs") var numLines: Int? @OptionGroup public var logOptions: Flags.Logging @Argument(help: "Container ID") var containerId: String public func run() async throws { let client = ContainerClient() let fhs = try await client.logs(id: containerId) let fileHandle = boot ? fhs[1] : fhs[0] try await Self.tail( fh: fileHandle, n: numLines, follow: follow ) } private static func tail( fh: FileHandle, n: Int?, follow: Bool ) async throws { if let n { var buffer = Data() let size = try fh.seekToEnd() var offset = size var lines: [String] = [] while offset > 0, lines.count < n { let readSize = min(1024, offset) offset -= readSize try fh.seek(toOffset: offset) let data = fh.readData(ofLength: Int(readSize)) buffer.insert(contentsOf: data, at: 0) if let chunk = String(data: buffer, encoding: .utf8) { lines = chunk.components(separatedBy: .newlines) lines = lines.filter { !$0.isEmpty } } } lines = Array(lines.suffix(n)) for line in lines { print(line) } } else { // Fast path if all they want is the full file. guard let data = try fh.readToEnd() else { // Seems you get nil if it's a zero byte read, or you // try and read from dev/null. return } guard let str = String(data: data, encoding: .utf8) else { throw ContainerizationError( .internalError, message: "failed to convert container logs to utf8" ) } print(str.trimmingCharacters(in: .newlines)) } fflush(stdout) if follow { setbuf(stdout, nil) try await Self.followFile(fh: fh) } } private static func followFile(fh: FileHandle) async throws { _ = try fh.seekToEnd() let stream = AsyncStream { cont in fh.readabilityHandler = { handle in let data = handle.availableData if data.isEmpty { // Triggers on container restart - can exit here as well do { _ = try fh.seekToEnd() // To continue streaming existing truncated log files } catch { fh.readabilityHandler = nil cont.finish() return } } if let str = String(data: data, encoding: .utf8), !str.isEmpty { var lines = str.components(separatedBy: .newlines) lines = lines.filter { !$0.isEmpty } for line in lines { cont.yield(line) } } } } for await line in stream { print(line) } } } } ================================================ FILE: Sources/ContainerCommands/Container/ContainerPrune.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerizationError import Foundation extension Application { public struct ContainerPrune: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "prune", abstract: "Remove all stopped containers" ) @OptionGroup public var logOptions: Flags.Logging public func run() async throws { let client = ContainerClient() let containersToPrune = try await client.list().filter { $0.status == .stopped } var prunedContainerIds = [String]() var totalSize: UInt64 = 0 for container in containersToPrune { do { let actualSize = try await client.diskUsage(id: container.id) totalSize += actualSize try await client.delete(id: container.id) prunedContainerIds.append(container.id) } catch { log.error( "failed to prune container", metadata: [ "id": "\(container.id)", "error": "\(error)", ]) } } let formatter = ByteCountFormatter() let freed = formatter.string(fromByteCount: Int64(totalSize)) for name in prunedContainerIds { print(name) } print("Reclaimed \(freed) in disk space") } } } ================================================ FILE: Sources/ContainerCommands/Container/ContainerRun.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import Containerization import ContainerizationError import ContainerizationExtras import ContainerizationOS import Foundation import NIOCore import NIOPosix import TerminalProgress extension Application { public struct ContainerRun: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "run", abstract: "Run a container") @OptionGroup(title: "Process options") var processFlags: Flags.Process @OptionGroup(title: "Resource options") var resourceFlags: Flags.Resource @OptionGroup(title: "Management options") var managementFlags: Flags.Management @OptionGroup(title: "Registry options") var registryFlags: Flags.Registry @OptionGroup(title: "Progress options") var progressFlags: Flags.Progress @OptionGroup(title: "Image fetch options") var imageFetchFlags: Flags.ImageFetch @OptionGroup public var logOptions: Flags.Logging @Argument(help: "Image name") var image: String @Argument(parsing: .captureForPassthrough, help: "Container init process arguments") var arguments: [String] = [] public func run() async throws { var exitCode: Int32 = 127 let id = Utility.createContainerID(name: self.managementFlags.name) var progressConfig: ProgressConfig switch self.progressFlags.progress { case .none: progressConfig = try ProgressConfig(disableProgressUpdates: true) case .ansi: progressConfig = try ProgressConfig( showTasks: true, showItems: true, ignoreSmallSize: true, totalTasks: 6 ) } let progress = ProgressBar(config: progressConfig) defer { progress.finish() } progress.start() try Utility.validEntityName(id) // Check if container with id already exists. let client = ContainerClient() let existing = try? await client.get(id: id) guard existing == nil else { throw ContainerizationError( .exists, message: "container with id \(id) already exists" ) } let ck = try await Utility.containerConfigFromFlags( id: id, image: image, arguments: arguments, process: processFlags, management: managementFlags, resource: resourceFlags, registry: registryFlags, imageFetch: imageFetchFlags, progressUpdate: progress.handler, log: log ) progress.set(description: "Starting container") let options = ContainerCreateOptions(autoRemove: managementFlags.remove) try await client.create( configuration: ck.0, options: options, kernel: ck.1, initImage: ck.2 ) let detach = self.managementFlags.detach do { let io = try ProcessIO.create( tty: self.processFlags.tty, interactive: self.processFlags.interactive, detach: detach ) defer { try? io.close() } let process = try await client.bootstrap(id: id, stdio: io.stdio) progress.finish() if !self.managementFlags.cidfile.isEmpty { let path = self.managementFlags.cidfile let data = id.data(using: .utf8) var attributes = [FileAttributeKey: Any]() attributes[.posixPermissions] = 0o644 let success = FileManager.default.createFile( atPath: path, contents: data, attributes: attributes ) guard success else { throw ContainerizationError( .internalError, message: "failed to create cidfile at \(path): \(errno)") } } if detach { try await process.start() try io.closeAfterStart() print(id) return } if !self.processFlags.tty { var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) handler.start { print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") Darwin.exit(1) } } exitCode = try await io.handleProcess(process: process, log: log) } catch { try? await client.delete(id: id) if error is ContainerizationError { throw error } throw ContainerizationError(.internalError, message: "failed to run container: \(error)") } throw ArgumentParser.ExitCode(exitCode) } } } ================================================ FILE: Sources/ContainerCommands/Container/ContainerStart.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerizationError import ContainerizationOS import Foundation import TerminalProgress extension Application { public struct ContainerStart: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "start", abstract: "Start a container") @Flag(name: .shortAndLong, help: "Attach stdout/stderr") var attach = false @Flag(name: .shortAndLong, help: "Attach stdin") var interactive = false @OptionGroup public var logOptions: Flags.Logging @Argument(help: "Container ID") var containerId: String public func run() async throws { var exitCode: Int32 = 127 let progressConfig = try ProgressConfig( description: "Starting container" ) let progress = ProgressBar(config: progressConfig) defer { progress.finish() } progress.start() let detach = !self.attach && !self.interactive let client = ContainerClient() let container = try await client.get(id: containerId) // Bootstrap and process start are both idempotent and don't fail the second time // around, however not doing an rpc is always faster :). The other bit is we don't // support attach currently, so we can't do `start -a` a second time and have it succeed. if container.status == .running { if !detach { throw ContainerizationError( .invalidArgument, message: "attach is currently unsupported on already running containers" ) } print(containerId) return } for mount in container.configuration.mounts where mount.isVirtiofs { if !FileManager.default.fileExists(atPath: mount.source) { throw ContainerizationError(.invalidState, message: "path '\(mount.source)' is not a directory") } } do { let io = try ProcessIO.create( tty: container.configuration.initProcess.terminal, interactive: self.interactive, detach: detach ) defer { try? io.close() } let process = try await client.bootstrap(id: container.id, stdio: io.stdio) progress.finish() if detach { try await process.start() try io.closeAfterStart() print(self.containerId) return } exitCode = try await io.handleProcess(process: process, log: log) } catch { try? await client.stop(id: container.id) if error is ContainerizationError { throw error } throw ContainerizationError(.internalError, message: "failed to start container: \(error)") } throw ArgumentParser.ExitCode(exitCode) } } } ================================================ FILE: Sources/ContainerCommands/Container/ContainerStats.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import ContainerizationError import ContainerizationExtras import Foundation extension Application { public struct ContainerStats: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "stats", abstract: "Display resource usage statistics for containers") @Argument(help: "Container ID or name (optional, shows all running containers if not specified)") var containers: [String] = [] @Option(name: .long, help: "Format of the output") var format: ListFormat = .table @Flag(name: .long, help: "Disable streaming stats and only pull the first result") var noStream = false @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { if format == .json || noStream { // Static mode - get stats once and exit try await runStatic() } else { // Streaming mode - continuously update like top // Enter alternate screen buffer and hide cursor print("\u{001B}[?1049h\u{001B}[?25l", terminator: "") fflush(stdout) defer { // Exit alternate screen buffer and show cursor again print("\u{001B}[?25h\u{001B}[?1049l", terminator: "") fflush(stdout) } try await runStreaming() } } private func runStatic() async throws { let client = ContainerClient() let containersToShow: [ContainerSnapshot] if containers.isEmpty { // No containers specified - show all running containers containersToShow = try await client.list(filters: ContainerListFilters(status: .running)) } else { // Fetch specified containers by ID containersToShow = try await client.list(filters: ContainerListFilters(ids: containers)) // Validate all specified containers were found for containerId in containers { guard containersToShow.contains(where: { $0.id == containerId }) else { throw ContainerizationError( .notFound, message: "no such container: \(containerId)" ) } } } let statsData = try await collectStats(client: client, for: containersToShow) if format == .json { let jsonStats = statsData.map { $0.stats2 } let data = try JSONEncoder().encode(jsonStats) print(String(decoding: data, as: UTF8.self)) return } printStatsTable(statsData) } private func runStreaming() async throws { let client = ContainerClient() // If containers were specified, validate they all exist upfront if !containers.isEmpty { let specifiedContainers = try await client.list(filters: ContainerListFilters(ids: containers)) for containerId in containers { guard specifiedContainers.contains(where: { $0.id == containerId }) else { throw ContainerizationError( .notFound, message: "no such container: \(containerId)" ) } } } clearScreen() // Show header right away. printStatsTable([]) while true { do { let containersToShow: [ContainerSnapshot] if containers.isEmpty { containersToShow = try await client.list(filters: ContainerListFilters(status: .running)) } else { containersToShow = try await client.list(filters: ContainerListFilters(ids: containers)) } let statsData = try await collectStats(client: client, for: containersToShow) // Clear screen and reprint clearScreen() printStatsTable(statsData) if statsData.isEmpty { try await Task.sleep(for: .seconds(2)) } } catch { clearScreen() print("error collecting stats: \(error)") try await Task.sleep(for: .seconds(2)) } } } private struct StatsSnapshot { let container: ContainerSnapshot let stats1: ContainerResource.ContainerStats let stats2: ContainerResource.ContainerStats } private func collectStats(client: ContainerClient, for containers: [ContainerSnapshot]) async throws -> [StatsSnapshot] { var snapshots: [StatsSnapshot] = [] // First sample for container in containers { guard container.status == .running else { continue } do { let stats1 = try await client.stats(id: container.id) snapshots.append(StatsSnapshot(container: container, stats1: stats1, stats2: stats1)) } catch { // Skip containers that error out continue } } // Wait 2 seconds for CPU delta calculation if !snapshots.isEmpty { try await Task.sleep(for: .seconds(2)) // Second sample for i in 0.. Double { let cpuDelta = cpuUsage2 > cpuUsage1 ? cpuUsage2 - cpuUsage1 : .seconds(0) return (cpuDelta / timeInterval) * 100.0 } static func formatBytes(_ bytes: UInt64) -> String { let kib = 1024.0 let mib = kib * 1024.0 let gib = mib * 1024.0 let value = Double(bytes) if value >= gib { return String(format: "%.2f GiB", value / gib) } else if value >= mib { return String(format: "%.2f MiB", value / mib) } else { return String(format: "%.2f KiB", value / kib) } } private func printStatsTable(_ statsData: [StatsSnapshot]) { let headerRow = ["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"] let notAvailable = "--" var rows = [headerRow] for snapshot in statsData { var row = [snapshot.container.id] let stats1 = snapshot.stats1 let stats2 = snapshot.stats2 if let cpuUsageUsec1 = stats1.cpuUsageUsec, let cpuUsageUsec2 = stats2.cpuUsageUsec { let cpuPercent = Self.calculateCPUPercent( cpuUsage1: .microseconds(cpuUsageUsec1), cpuUsage2: .microseconds(cpuUsageUsec2), timeInterval: .seconds(2) ) let cpuStr = String(format: "%.2f%%", cpuPercent) row.append(cpuStr) } else { row.append(notAvailable) } let memUsageStr = stats2.memoryUsageBytes.map { Self.formatBytes($0) } ?? notAvailable let memLimitStr = stats2.memoryLimitBytes.map { Self.formatBytes($0) } ?? notAvailable row.append("\(memUsageStr) / \(memLimitStr)") let netRxStr = stats2.networkRxBytes.map { Self.formatBytes($0) } ?? notAvailable let netTxStr = stats2.networkTxBytes.map { Self.formatBytes($0) } ?? notAvailable row.append("\(netRxStr) / \(netTxStr)") let blkReadStr = stats2.blockReadBytes.map { Self.formatBytes($0) } ?? notAvailable let blkWriteStr = stats2.blockWriteBytes.map { Self.formatBytes($0) } ?? notAvailable row.append("\(blkReadStr) / \(blkWriteStr)") let pidsStr = stats2.numProcesses.map { "\($0)" } ?? notAvailable row.append(pidsStr) rows.append(row) } // Always print header, even if no containers let formatter = TableOutput(rows: rows) print(formatter.format()) } private func clearScreen() { // Move cursor to home position and clear from cursor to end of screen print("\u{001B}[H\u{001B}[J", terminator: "") fflush(stdout) } } } ================================================ FILE: Sources/ContainerCommands/Container/ContainerStop.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import ContainerizationError import ContainerizationOS import Foundation import Logging extension Application { public struct ContainerStop: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "stop", abstract: "Stop one or more running containers") @Flag(name: .shortAndLong, help: "Stop all running containers") var all = false @Option(name: .shortAndLong, help: "Signal to send to the containers") var signal: String = "SIGTERM" @Option(name: .shortAndLong, help: "Seconds to wait before killing the containers") var time: Int32 = 5 @OptionGroup public var logOptions: Flags.Logging @Argument(help: "Container IDs") var containerIds: [String] = [] public func validate() throws { if containerIds.count == 0 && !all { throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") } if containerIds.count > 0 && all { throw ContainerizationError( .invalidArgument, message: "explicitly supplied container IDs conflict with the --all flag") } } public mutating func run() async throws { let set = Set(containerIds) let client = ContainerClient() var containers = [ContainerSnapshot]() if self.all { containers = try await client.list() } else { containers = try await client.list().filter { c in set.contains(c.id) } } let opts = ContainerStopOptions( timeoutInSeconds: self.time, signal: try Signals.parseSignal(self.signal) ) try await Self.stopContainers( client: client, containers: containers, stopOptions: opts ) } static func stopContainers(client: ContainerClient, containers: [ContainerSnapshot], stopOptions: ContainerStopOptions) async throws { var errors: [any Error] = [] await withTaskGroup(of: (any Error)?.self) { group in for container in containers { group.addTask { do { try await client.stop(id: container.id, opts: stopOptions) print(container.id) return nil } catch { return error } } } for await error in group { if let error { errors.append(error) } } } if !errors.isEmpty { throw AggregateError(errors) } } } } ================================================ FILE: Sources/ContainerCommands/Container/ProcessUtils.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import ContainerResource import Containerization import ContainerizationError import ContainerizationOS import Foundation extension Application { static func ensureRunning(container: ContainerSnapshot) throws { if container.status != .running { throw ContainerizationError(.invalidState, message: "container \(container.id) is not running") } } } ================================================ FILE: Sources/ContainerCommands/DefaultCommand.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerPlugin import Darwin import Foundation struct DefaultCommand: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: nil, shouldDisplay: false ) @OptionGroup(visibility: .hidden) public var logOptions: Flags.Logging @Argument(parsing: .captureForPassthrough) var remaining: [String] = [] func run() async throws { // See if we have a possible plugin command. let pluginLoader = try? await Application.createPluginLoader() guard let command = remaining.first else { await Application.printModifiedHelpText(pluginLoader: pluginLoader) return } // Check for edge cases and unknown options to match the behavior in the absence of plugins. if command.isEmpty { throw ValidationError("unknown argument '\(command)'") } else if command.starts(with: "-") { throw ValidationError("unknown option '\(command)'") } // Compute canonical plugin directories to show in helpful errors (avoid hard-coded paths) let installRoot = CommandLine.executablePathUrl .deletingLastPathComponent() .appendingPathComponent("..") .standardized let userPluginsURL = PluginLoader.userPluginsDir(installRoot: installRoot) let installRootPluginsURL = installRoot .appendingPathComponent("libexec") .appendingPathComponent("container") .appendingPathComponent("plugins") .standardized let hintPaths = [userPluginsURL, installRootPluginsURL] .map { $0.appendingPathComponent(command).path(percentEncoded: false) } .joined(separator: "\n - ") // If plugin loader couldn't be created, the system/APIServer likely isn't running. if pluginLoader == nil { throw ValidationError( """ Plugins are unavailable. Start the container system services and retry: container system start Check to see that the plugin exists under: - \(hintPaths) """ ) } guard let plugin = pluginLoader?.findPlugin(name: command), plugin.config.isCLI else { throw ValidationError( """ Plugin 'container-\(command)' not found. - If system services are not running, start them with: container system start - If the plugin isn't installed, ensure it exists under: Check to see that the plugin exists under: - \(hintPaths) """ ) } // Before execing into the plugin, restore default SIGINT/SIGTERM so the plugin can manage signals. Self.resetSignalsForPluginExec() // Exec performs execvp (with no fork). try plugin.exec(args: remaining) } } extension DefaultCommand { // Exposed for tests to verify signal reset semantics. static func resetSignalsForPluginExec() { signal(SIGINT, SIG_DFL) signal(SIGTERM, SIG_DFL) } } ================================================ FILE: Sources/ContainerCommands/Image/ImageCommand.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient extension Application { public struct ImageCommand: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "image", abstract: "Manage images", subcommands: [ ImageDelete.self, ImageInspect.self, ImageList.self, ImageLoad.self, ImagePrune.self, ImagePull.self, ImagePush.self, ImageSave.self, ImageTag.self, ], aliases: ["i"] ) @OptionGroup public var logOptions: Flags.Logging } } ================================================ FILE: Sources/ContainerCommands/Image/ImageDelete.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import Containerization import ContainerizationError import Foundation import Logging extension Application { public struct RemoveImageOptions: ParsableArguments { public init() {} @Flag(name: .shortAndLong, help: "Delete all images") var all: Bool = false @Flag(name: .shortAndLong, help: "Ignore errors for images that are not found") var force: Bool = false @Argument var images: [String] = [] } struct DeleteImageImplementation { static func validate(options: RemoveImageOptions) throws { if options.images.count == 0 && !options.all { throw ContainerizationError(.invalidArgument, message: "no images specified and --all not supplied") } if options.images.count > 0 && options.all { throw ContainerizationError(.invalidArgument, message: "explicitly supplied images conflict with the --all flag") } } static func removeImage(options: RemoveImageOptions, log: Logger) async throws { let (found, notFound) = try await { if options.all { let found = try await ClientImage.list() let notFound: [String] = [] return (found, notFound) } return try await ClientImage.get(names: options.images) }() var failures: [String] = options.force ? [] : notFound var didDeleteAnyImage = false for image in found { guard !Utility.isInfraImage(name: image.reference) else { continue } do { try await ClientImage.delete(reference: image.reference, garbageCollect: false) print(image.reference) didDeleteAnyImage = true } catch { log.error("failed to delete \(image.reference): \(error)") failures.append(image.reference) } } let (_, size) = try await ClientImage.cleanUpOrphanedBlobs() let formatter = ByteCountFormatter() let freed = formatter.string(fromByteCount: Int64(size)) if didDeleteAnyImage { print("Reclaimed \(freed) in disk space") } if failures.count > 0 { throw ContainerizationError(.internalError, message: "failed to delete one or more images: \(failures)") } } } public struct ImageDelete: AsyncLoggableCommand { @OptionGroup var options: RemoveImageOptions @OptionGroup public var logOptions: Flags.Logging public static let configuration = CommandConfiguration( commandName: "delete", abstract: "Delete one or more images", aliases: ["rm"]) public init() {} public func validate() throws { try DeleteImageImplementation.validate(options: options) } public mutating func run() async throws { try await DeleteImageImplementation.removeImage(options: options, log: log) } } } ================================================ FILE: Sources/ContainerCommands/Image/ImageInspect.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerLog import ContainerizationError import Foundation import Logging import SwiftProtobuf extension Application { public struct ImageInspect: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "inspect", abstract: "Display information about one or more images") @OptionGroup public var logOptions: Flags.Logging @Argument(help: "Images to inspect") var images: [String] public init() {} struct InspectError: Error { let succeeded: [String] let failed: [(String, Error)] } public func run() async throws { var printable = [any Codable]() var succeededImages: [String] = [] var allErrors: [(String, Error)] = [] let result = try await ClientImage.get(names: images) for image in result.images { guard !Utility.isInfraImage(name: image.reference) else { continue } printable.append(try await image.details()) succeededImages.append(image.reference) } for missing in result.error { allErrors.append((missing, ContainerizationError(.notFound, message: "Image not found"))) } if !printable.isEmpty { print(try printable.jsonArray()) } if !allErrors.isEmpty { for (name, error) in allErrors { log.error( "image inspect failed", metadata: [ "name": "\(name)", "error": "\(error.localizedDescription)", ]) } throw InspectError(succeeded: succeededImages, failed: allErrors) } } } } ================================================ FILE: Sources/ContainerCommands/Image/ImageList.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import Containerization import ContainerizationError import ContainerizationOCI import Foundation import SwiftProtobuf extension Application { public struct ListImageOptions: ParsableArguments { @Option(name: .long, help: "Format of the output") var format: ListFormat = .table @Flag(name: .shortAndLong, help: "Only output the image name") var quiet = false @Flag(name: .shortAndLong, help: "Verbose output") var verbose = false public init() {} } struct ListImageImplementation { static func createHeader() -> [[String]] { [["NAME", "TAG", "DIGEST"]] } static func createVerboseHeader() -> [[String]] { [["NAME", "TAG", "INDEX DIGEST", "OS", "ARCH", "VARIANT", "FULL SIZE", "CREATED", "MANIFEST DIGEST"]] } static func printImagesVerbose(images: [ClientImage]) async throws { var rows = createVerboseHeader() for image in images { let formatter = ByteCountFormatter() let imageDigest = try await image.resolved().digest for descriptor in try await image.index().manifests { // Don't list attestation manifests if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], referenceType == "attestation-manifest" { continue } guard let platform = descriptor.platform else { continue } let os = platform.os let arch = platform.architecture let variant = platform.variant ?? "" var config: ContainerizationOCI.Image var manifest: ContainerizationOCI.Manifest do { config = try await image.config(for: platform) manifest = try await image.manifest(for: platform) } catch { continue } let created = config.created ?? "" let size = descriptor.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size }) let formattedSize = formatter.string(fromByteCount: size) let processedReferenceString = try ClientImage.denormalizeReference(image.reference) let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) let row = [ reference.name, reference.tag ?? "", Utility.trimDigest(digest: imageDigest), os, arch, variant, formattedSize, created, Utility.trimDigest(digest: descriptor.digest), ] rows.append(row) } } let formatter = TableOutput(rows: rows) print(formatter.format()) } static func printImages(images: [ClientImage], format: ListFormat, options: ListImageOptions) async throws { var images = images images.sort { $0.reference < $1.reference } if format == .json { var printableImages: [PrintableImage] = [] for image in images { let formatter = ByteCountFormatter() let size = try await ClientImage.getFullImageSize(image: image) let formattedSize = formatter.string(fromByteCount: size) printableImages.append( PrintableImage(reference: image.reference, fullSize: formattedSize, descriptor: image.descriptor) ) } let data = try JSONEncoder().encode(printableImages) print(String(decoding: data, as: UTF8.self)) return } if options.quiet { try images.forEach { image in let processedReferenceString = try ClientImage.denormalizeReference(image.reference) print(processedReferenceString) } return } if options.verbose { try await Self.printImagesVerbose(images: images) return } var rows = createHeader() for image in images { let processedReferenceString = try ClientImage.denormalizeReference(image.reference) let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) let digest = try await image.resolved().digest rows.append([ reference.name, reference.tag ?? "", Utility.trimDigest(digest: digest), ]) } let formatter = TableOutput(rows: rows) print(formatter.format()) } static func validate(options: ListImageOptions) throws { if options.quiet && options.verbose { throw ContainerizationError(.invalidArgument, message: "cannot use flag --quiet and --verbose together") } let modifier = options.quiet || options.verbose if modifier && options.format == .json { throw ContainerizationError(.invalidArgument, message: "cannot use flag --quiet or --verbose along with --format json") } } static func listImages(options: ListImageOptions) async throws { let images = try await ClientImage.list().filter { img in !Utility.isInfraImage(name: img.reference) } try await printImages(images: images, format: options.format, options: options) } struct PrintableImage: Codable { let reference: String let fullSize: String let descriptor: Descriptor init(reference: String, fullSize: String, descriptor: Descriptor) { self.reference = reference self.fullSize = fullSize self.descriptor = descriptor } } } public struct ImageList: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "list", abstract: "List images", aliases: ["ls"]) @OptionGroup var options: ListImageOptions @OptionGroup public var logOptions: Flags.Logging public mutating func run() async throws { try ListImageImplementation.validate(options: options) try await ListImageImplementation.listImages(options: options) } } } ================================================ FILE: Sources/ContainerCommands/Image/ImageLoad.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import Containerization import ContainerizationError import Foundation import TerminalProgress extension Application { public struct ImageLoad: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "load", abstract: "Load images from an OCI compatible tar archive" ) @Option( name: .shortAndLong, help: "Path to the image tar archive", completion: .file(), transform: { str in URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) }) var input: String? @Flag(name: .shortAndLong, help: "Load images even if the archive contains invalid files") public var force = false @OptionGroup public var logOptions: Flags.Logging public func run() async throws { let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar") defer { try? FileManager.default.removeItem(at: tempFile) } // Read from stdin; otherwise read from the input file if input == nil { guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else { throw ContainerizationError(.internalError, message: "unable to create temporary file") } guard let fileHandle = try? FileHandle(forWritingTo: tempFile) else { throw ContainerizationError(.internalError, message: "unable to open temporary file for writing") } let bufferSize = 4096 while true { let chunk = FileHandle.standardInput.readData(ofLength: bufferSize) if chunk.isEmpty { break } fileHandle.write(chunk) } try fileHandle.close() } else { guard FileManager.default.fileExists(atPath: input!) else { print("File does not exist \(input!)") Application.exit(withError: ArgumentParser.ExitCode(1)) } } let progressConfig = try ProgressConfig( showTasks: true, showItems: true, totalTasks: 2 ) let progress = ProgressBar(config: progressConfig) defer { progress.finish() } progress.start() progress.set(description: "Loading tar archive") let result = try await ClientImage.load( from: input ?? tempFile.path(), force: force) if !result.rejectedMembers.isEmpty { log.warning("archive contains invalid members", metadata: ["paths": "\(result.rejectedMembers)"]) } let taskManager = ProgressTaskCoordinator() let unpackTask = await taskManager.startTask() progress.set(description: "Unpacking image") progress.set(itemsName: "entries") for image in result.images { try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) } await taskManager.finish() progress.finish() print("Loaded images:") for image in result.images { print(image.reference) } } } } ================================================ FILE: Sources/ContainerCommands/Image/ImagePrune.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerizationOCI import Foundation extension Application { public struct ImagePrune: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "prune", abstract: "Remove all dangling images. If -a is specified, also remove all images not referenced by any container.") @OptionGroup public var logOptions: Flags.Logging @Flag(name: .shortAndLong, help: "Remove all unused images, not just dangling ones") var all: Bool = false public func run() async throws { let allImages = try await ClientImage.list() let imagesToPrune: [ClientImage] if all { // Find all images not used by any container let client = ContainerClient() let containers = try await client.list() var imagesInUse = Set() for container in containers { imagesInUse.insert(container.configuration.image.reference) } imagesToPrune = allImages.filter { image in !imagesInUse.contains(image.reference) } } else { // Find dangling images (images with no tag) imagesToPrune = allImages.filter { image in !hasTag(image.reference) } } var prunedImages = [String]() for image in imagesToPrune { do { try await ClientImage.delete(reference: image.reference, garbageCollect: false) prunedImages.append(image.reference) } catch { log.error( "failed to prune image", metadata: [ "ref": "\(image.reference)", "error": "\(error)", ]) } } let (deletedDigests, size) = try await ClientImage.cleanUpOrphanedBlobs() for image in imagesToPrune { print("untagged \(image.reference)") } for digest in deletedDigests { print("deleted \(digest)") } let formatter = ByteCountFormatter() formatter.countStyle = .file let freed = formatter.string(fromByteCount: Int64(size)) print("Reclaimed \(freed) in disk space") } private func hasTag(_ reference: String) -> Bool { do { let ref = try ContainerizationOCI.Reference.parse(reference) return ref.tag != nil && !ref.tag!.isEmpty } catch { return false } } } } ================================================ FILE: Sources/ContainerCommands/Image/ImagePull.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import Containerization import ContainerizationOCI import TerminalProgress extension Application { public struct ImagePull: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "pull", abstract: "Pull an image" ) @OptionGroup var registry: Flags.Registry @OptionGroup var progressFlags: Flags.Progress @OptionGroup var imageFetchFlags: Flags.ImageFetch @Option( name: .shortAndLong, help: "Limit the pull to the specified architecture" ) var arch: String? @Option( help: "Limit the pull to the specified OS" ) var os: String? @Option( help: "Limit the pull to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]" ) var platform: String? @OptionGroup public var logOptions: Flags.Logging @Argument var reference: String public init() {} public init(platform: String? = nil, scheme: String = "auto", reference: String) { self.logOptions = Flags.Logging() self.registry = Flags.Registry(scheme: scheme) self.platform = platform self.reference = reference } public func run() async throws { let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log) let scheme = try RequestScheme(registry.scheme) let processedReference = try ClientImage.normalizeReference(reference) var progressConfig: ProgressConfig switch self.progressFlags.progress { case .none: progressConfig = try ProgressConfig(disableProgressUpdates: true) case .ansi: progressConfig = try ProgressConfig( showTasks: true, showItems: true, ignoreSmallSize: true, totalTasks: 2 ) } let progress = ProgressBar(config: progressConfig) defer { progress.finish() } progress.start() progress.set(description: "Fetching image") progress.set(itemsName: "blobs") let taskManager = ProgressTaskCoordinator() let fetchTask = await taskManager.startTask() let image = try await ClientImage.pull( reference: processedReference, platform: p, scheme: scheme, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler), maxConcurrentDownloads: self.imageFetchFlags.maxConcurrentDownloads ) progress.set(description: "Unpacking image") progress.set(itemsName: "entries") let unpackTask = await taskManager.startTask() try await image.unpack(platform: p, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) await taskManager.finish() progress.finish() } } } ================================================ FILE: Sources/ContainerCommands/Image/ImagePush.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import Containerization import ContainerizationOCI import TerminalProgress extension Application { public struct ImagePush: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "push", abstract: "Push an image" ) @OptionGroup var registry: Flags.Registry @OptionGroup var progressFlags: Flags.Progress @Option( name: .shortAndLong, help: "Limit the push to the specified architecture" ) var arch: String? @Option( help: "Limit the push to the specified OS" ) var os: String? @Option(help: "Limit the push to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]") var platform: String? @OptionGroup public var logOptions: Flags.Logging @Argument var reference: String public init() {} public func run() async throws { let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log) let scheme = try RequestScheme(registry.scheme) let image = try await ClientImage.get(reference: reference) var progressConfig: ProgressConfig switch self.progressFlags.progress { case .none: progressConfig = try ProgressConfig(disableProgressUpdates: true) case .ansi: progressConfig = try ProgressConfig( description: "Pushing image \(image.reference)", itemsName: "blobs", showItems: true, showSpeed: false, ignoreSmallSize: true ) } let progress = ProgressBar(config: progressConfig) defer { progress.finish() } progress.start() _ = try await image.push(platform: p, scheme: scheme, progressUpdate: progress.handler) progress.finish() } } } ================================================ FILE: Sources/ContainerCommands/Image/ImageSave.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import Containerization import ContainerizationError import ContainerizationOCI import Foundation import TerminalProgress extension Application { public struct ImageSave: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "save", abstract: "Save one or more images as an OCI compatible tar archive" ) @Option( name: .shortAndLong, help: "Architecture for the saved image" ) var arch: String? @Option( help: "OS for the saved image" ) var os: String? @Option( name: .shortAndLong, help: "Pathname for the saved image", completion: .file(), transform: { str in URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) }) var output: String? @Option( help: "Platform for the saved image (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]" ) var platform: String? @OptionGroup public var logOptions: Flags.Logging @Argument var references: [String] public func run() async throws { let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log) let progressConfig = try ProgressConfig( description: "Saving image(s)" ) let progress = ProgressBar(config: progressConfig) defer { progress.finish() } progress.start() var images: [ImageDescription] = [] for reference in references { do { images.append(try await ClientImage.get(reference: reference).description) } catch { print("failed to get image for reference \(reference): \(error)") } } guard images.count == references.count else { throw ContainerizationError(.invalidArgument, message: "failed to save image(s)") } // Write to stdout; otherwise write to the output file if output == nil { let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar") defer { try? FileManager.default.removeItem(at: tempFile) } guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else { throw ContainerizationError(.internalError, message: "unable to create temporary file") } try await ClientImage.save(references: references, out: tempFile.path(), platform: p) guard let fileHandle = try? FileHandle(forReadingFrom: tempFile) else { throw ContainerizationError(.internalError, message: "unable to open temporary file for reading") } let bufferSize = 4096 while true { let chunk = fileHandle.readData(ofLength: bufferSize) if chunk.isEmpty { break } FileHandle.standardOutput.write(chunk) } try fileHandle.close() } else { try await ClientImage.save(references: references, out: output!, platform: p) } progress.finish() for reference in references { print(reference) } } } } ================================================ FILE: Sources/ContainerCommands/Image/ImageTag.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient extension Application { public struct ImageTag: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "tag", abstract: "Create a new reference for an existing image") @Argument(help: "The existing image reference (format: image-name[:tag])") var source: String @Argument(help: "The new image reference") var target: String @OptionGroup public var logOptions: Flags.Logging public func run() async throws { let existing = try await ClientImage.get(reference: source) let targetReference = try ClientImage.normalizeReference(target) try await existing.tag(new: targetReference) print(target) } } } ================================================ FILE: Sources/ContainerCommands/Network/NetworkCommand.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient extension Application { public struct NetworkCommand: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "network", abstract: "Manage container networks", subcommands: [ NetworkCreate.self, NetworkDelete.self, NetworkList.self, NetworkInspect.self, NetworkPrune.self, ], aliases: ["n"] ) public init() {} @OptionGroup public var logOptions: Flags.Logging } } ================================================ FILE: Sources/ContainerCommands/Network/NetworkCreate.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import ContainerizationError import ContainerizationExtras import Foundation import TerminalProgress extension Application { public struct NetworkCreate: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a new network") @Option(name: .customLong("label"), help: "Set metadata for a network") var labels: [String] = [] @Flag(name: .customLong("internal"), help: "Restrict to host-only network") var hostOnly: Bool = false @Option( name: .customLong("subnet"), help: "Set subnet for a network", transform: { try CIDRv4($0) }) var ipv4Subnet: CIDRv4? = nil @Option( name: .customLong("subnet-v6"), help: "Set the IPv6 prefix for a network", transform: { try CIDRv6($0) }) var ipv6Subnet: CIDRv6? = nil @Option(name: .long, help: "Set the plugin to use to create this network.") var plugin: String = "container-network-vmnet" @Option(name: .long, help: "Set the variant of the network plugin to use.") var pluginVariant: String? @OptionGroup public var logOptions: Flags.Logging @Argument(help: "Network name") var name: String public init() {} public func run() async throws { let parsedLabels = Utility.parseKeyValuePairs(labels) let mode: NetworkMode = hostOnly ? .hostOnly : .nat let config = try NetworkConfiguration( id: self.name, mode: mode, ipv4Subnet: ipv4Subnet, ipv6Subnet: ipv6Subnet, labels: parsedLabels, pluginInfo: NetworkPluginInfo(plugin: self.plugin, variant: self.pluginVariant) ) let state = try await ClientNetwork.create(configuration: config) print(state.id) } } } ================================================ FILE: Sources/ContainerCommands/Network/NetworkDelete.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import ContainerizationError import Foundation extension Application { public struct NetworkDelete: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "delete", abstract: "Delete one or more networks", aliases: ["rm"]) @Flag(name: .shortAndLong, help: "Delete all networks") var all = false @OptionGroup public var logOptions: Flags.Logging @Argument(help: "Network names") var networkNames: [String] = [] public init() {} public func validate() throws { if networkNames.count == 0 && !all { throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied") } if networkNames.count > 0 && all { throw ContainerizationError( .invalidArgument, message: "explicitly supplied network name(s) conflict with the --all flag" ) } } public mutating func run() async throws { let uniqueNetworkNames = Set(networkNames) let networks: [NetworkState] if all { networks = try await ClientNetwork.list() .filter { !$0.isBuiltin } } else { networks = try await ClientNetwork.list() .filter { c in guard uniqueNetworkNames.contains(c.id) else { return false } guard !c.isBuiltin else { throw ContainerizationError( .invalidArgument, message: "cannot delete a builtin network: \(c.id)" ) } return true } // If one of the networks requested isn't present lets throw. We don't need to do // this for --all as --all should be perfectly usable with no networks to remove, // otherwise it'd be quite clunky. if networks.count != uniqueNetworkNames.count { let missing = uniqueNetworkNames.filter { id in !networks.contains { n in n.id == id } } throw ContainerizationError( .notFound, message: "failed to delete one or more networks: \(missing)" ) } } var failed = [String]() let _log = log try await withThrowingTaskGroup(of: NetworkState?.self) { group in for network in networks { group.addTask { do { // Delete atomically disables the IP allocator, then deletes // the allocator. The disable fails if any IPs are still in use. try await ClientNetwork.delete(id: network.id) print(network.id) return nil } catch { _log.error( "failed to delete network", metadata: [ "id": "\(network.id)", "error": "\(error)", ]) return network } } } for try await network in group { guard let network else { continue } failed.append(network.id) } } if failed.count > 0 { throw ContainerizationError(.internalError, message: "delete failed for one or more networks: \(failed)") } } } } ================================================ FILE: Sources/ContainerCommands/Network/NetworkInspect.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import Foundation import SwiftProtobuf extension Application { public struct NetworkInspect: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "inspect", abstract: "Display information about one or more networks") @Argument(help: "Networks to inspect") var networks: [String] @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { let objects: [any Codable] = try await ClientNetwork.list().filter { networks.contains($0.id) }.map { PrintableNetwork($0) } print(try objects.jsonArray()) } } } ================================================ FILE: Sources/ContainerCommands/Network/NetworkList.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import ContainerizationExtras import Foundation import SwiftProtobuf extension Application { public struct NetworkList: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "list", abstract: "List networks", aliases: ["ls"]) @Option(name: .long, help: "Format of the output") var format: ListFormat = .table @Flag(name: .shortAndLong, help: "Only output the network name") var quiet = false @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { let networks = try await ClientNetwork.list() try printNetworks(networks: networks, format: format) } private func createHeader() -> [[String]] { [["NETWORK", "STATE", "SUBNET"]] } func printNetworks(networks: [NetworkState], format: ListFormat) throws { if format == .json { let printables = networks.map { PrintableNetwork($0) } let data = try JSONEncoder().encode(printables) print(String(decoding: data, as: UTF8.self)) return } if self.quiet { networks.forEach { print($0.id) } return } var rows = createHeader() for network in networks { rows.append(network.asRow) } let formatter = TableOutput(rows: rows) print(formatter.format()) } } } extension NetworkState { var asRow: [String] { switch self { case .created(_): return [self.id, self.state, "none"] case .running(_, let status): return [self.id, self.state, status.ipv4Subnet.description] } } } public struct PrintableNetwork: Codable { let id: String let state: String let config: NetworkConfiguration let status: NetworkStatus? public init(_ network: NetworkState) { self.id = network.id self.state = network.state switch network { case .created(let config): self.config = config self.status = nil case .running(let config, let status): self.config = config self.status = status } } } ================================================ FILE: Sources/ContainerCommands/Network/NetworkPrune.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import Foundation extension Application.NetworkCommand { public struct NetworkPrune: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "prune", abstract: "Remove networks with no container connections" ) @OptionGroup public var logOptions: Flags.Logging public func run() async throws { let client = ContainerClient() let allContainers = try await client.list() let allNetworks = try await ClientNetwork.list() var networksInUse = Set() for container in allContainers { for network in container.configuration.networks { networksInUse.insert(network.network) } } let networksToPrune = allNetworks.filter { network in !network.isBuiltin && !networksInUse.contains(network.id) } var prunedNetworks = [String]() for network in networksToPrune { do { try await ClientNetwork.delete(id: network.id) prunedNetworks.append(network.id) } catch { // Note: This failure may occur due to a race condition between the network/ // container collection above and a container run command that attaches to a // network listed in the networksToPrune collection. log.error( "failed to prune network", metadata: [ "id": "\(network.id)", "error": "\(error)", ]) } } for name in prunedNetworks { print(name) } } } } ================================================ FILE: Sources/ContainerCommands/Registry/RegistryCommand.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient extension Application { public struct RegistryCommand: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "registry", abstract: "Manage registry logins", subcommands: [ RegistryLogin.self, RegistryLogout.self, RegistryList.self, ], aliases: ["r"] ) public init() {} @OptionGroup public var logOptions: Flags.Logging } } ================================================ FILE: Sources/ContainerCommands/Registry/RegistryList.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import ContainerizationOCI import ContainerizationOS import Foundation extension Application { public struct RegistryList: AsyncLoggableCommand { @OptionGroup public var logOptions: Flags.Logging @Option(name: .long, help: "Format of the output") var format: ListFormat = .table @Flag(name: .shortAndLong, help: "Only output the registry name") var quiet = false public init() {} public static let configuration = CommandConfiguration( commandName: "list", abstract: "List image registry logins", aliases: ["ls"]) public func run() async throws { let keychain = KeychainHelper(securityDomain: Constants.keychainID) let registryInfos = try keychain.list() let registries = registryInfos.map { RegistryResource(from: $0) } try printRegistries(registries: registries, format: format) } private func createHeader() -> [[String]] { [["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"]] } private func printRegistries(registries: [RegistryResource], format: ListFormat) throws { if format == .json { let data = try JSONEncoder().encode(registries) print(String(decoding: data, as: UTF8.self)) return } if self.quiet { registries.forEach { print($0.name) } return } var rows = createHeader() for registry in registries { rows.append(registry.asRow) } let formatter = TableOutput(rows: rows) print(formatter.format()) } } } extension RegistryResource { fileprivate var asRow: [String] { [ self.name, self.username, self.modificationDate.ISO8601Format(), self.creationDate.ISO8601Format(), ] } } ================================================ FILE: Sources/ContainerCommands/Registry/RegistryLogin.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import Containerization import ContainerizationError import ContainerizationOCI import Foundation extension Application { public struct RegistryLogin: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "login", abstract: "Log in to a registry" ) @OptionGroup var registry: Flags.Registry @Flag(help: "Take the password from stdin") var passwordStdin: Bool = false @Option(name: .shortAndLong, help: "Registry user name") var username: String = "" @OptionGroup public var logOptions: Flags.Logging @Argument(help: "Registry server name") var server: String public func run() async throws { var username = self.username var password = "" if passwordStdin { if username == "" { throw ContainerizationError( .invalidArgument, message: "must provide --username with --password-stdin") } guard let passwordData = try FileHandle.standardInput.readToEnd() else { throw ContainerizationError(.invalidArgument, message: "failed to read password from stdin") } password = String(decoding: passwordData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) } let keychain = KeychainHelper(securityDomain: Constants.keychainID) if username == "" { username = try keychain.userPrompt(hostname: server) } if password == "" { password = try keychain.passwordPrompt() print() } let server = Reference.resolveDomain(domain: server) let scheme = try RequestScheme(registry.scheme).schemeFor(host: server) let _url = "\(scheme)://\(server)" guard let url = URL(string: _url) else { throw ContainerizationError(.invalidArgument, message: "cannot convert \(_url) to URL") } guard let host = url.host else { throw ContainerizationError(.invalidArgument, message: "invalid host \(server)") } let client = RegistryClient( host: host, scheme: scheme.rawValue, port: url.port, authentication: BasicAuthentication(username: username, password: password), retryOptions: .init( maxRetries: 10, retryInterval: 300_000_000, shouldRetry: ({ response in response.status.code >= 500 }) ) ) try await client.ping() try keychain.save(hostname: server, username: username, password: password) print("Login succeeded") } } } ================================================ FILE: Sources/ContainerCommands/Registry/RegistryLogout.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import Containerization import ContainerizationOCI extension Application { public struct RegistryLogout: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "logout", abstract: "Log out from a registry" ) @OptionGroup public var logOptions: Flags.Logging @Argument(help: "Registry server name") var registry: String public func run() async throws { let keychain = KeychainHelper(securityDomain: Constants.keychainID) let r = Reference.resolveDomain(domain: registry) try keychain.delete(hostname: r) } } } ================================================ FILE: Sources/ContainerCommands/System/DNS/DNSCreate.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerPersistence import ContainerizationError import ContainerizationExtras import Foundation extension Application { public struct DNSCreate: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a local DNS domain for containers (must run as an administrator)" ) @OptionGroup public var logOptions: Flags.Logging @Option(name: .long, help: "Set the ip address to be redirected to localhost") var localhost: String? @Argument(help: "The local domain name") var domainName: String public init() {} public func run() async throws { var localhostIP: IPAddress? = nil if let localhost { localhostIP = try? IPAddress(localhost) guard let localhostIP, case .v4(_) = localhostIP else { throw ContainerizationError(.invalidArgument, message: "invalid IPv4 address: \(localhost)") } } let resolver: HostDNSResolver = HostDNSResolver() do { try resolver.createDomain(name: domainName, localhost: localhostIP) } catch let error as ContainerizationError { throw error } catch { throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") } let pf = PacketFilter() if let from = localhostIP { let to = try! IPAddress("127.0.0.1") do { try pf.createRedirectRule(from: from, to: to, domain: domainName) } catch { _ = try resolver.deleteDomain(name: domainName) throw error } } print(domainName) if localhostIP != nil { do { try pf.reinitialize() } catch let error as ContainerizationError { throw error } catch { throw ContainerizationError(.invalidState, message: "failed loading pf rules") } } do { try HostDNSResolver.reinitialize() } catch { throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") } } } } ================================================ FILE: Sources/ContainerCommands/System/DNS/DNSDelete.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerizationError import ContainerizationExtras import Foundation extension Application { public struct DNSDelete: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "delete", abstract: "Delete a local DNS domain (must run as an administrator)", aliases: ["rm"] ) @OptionGroup public var logOptions: Flags.Logging @Argument(help: "The local domain name") var domainName: String public init() {} public func run() async throws { let resolver = HostDNSResolver() var localhostIP: IPAddress? do { localhostIP = try resolver.deleteDomain(name: domainName) } catch { throw ContainerizationError(.invalidState, message: "cannot delete domain (try sudo?)") } do { try HostDNSResolver.reinitialize() } catch { throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") } guard let localhostIP else { print(domainName) return } let pf = PacketFilter() try pf.removeRedirectRule(from: localhostIP, to: try! IPAddress("127.0.0.1"), domain: domainName) do { try pf.reinitialize() } catch let error as ContainerizationError { throw error } catch { throw ContainerizationError(.invalidState, message: "failed loading pf rules") } print(domainName) } } } ================================================ FILE: Sources/ContainerCommands/System/DNS/DNSList.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import Foundation extension Application { public struct DNSList: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "list", abstract: "List local DNS domains", aliases: ["ls"] ) @Option(name: .long, help: "Format of the output") var format: ListFormat = .table @Flag(name: .shortAndLong, help: "Only output the domain") var quiet = false @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { let resolver: HostDNSResolver = HostDNSResolver() let domains = resolver.listDomains() try printDomains(domains: domains, format: format) } private func createHeader() -> [[String]] { [["DOMAIN"]] } func printDomains(domains: [String], format: ListFormat) throws { if format == .json { let data = try JSONEncoder().encode(domains) print(String(decoding: data, as: UTF8.self)) return } if self.quiet { domains.forEach { domain in print(domain) } return } var rows = createHeader() for domain in domains { rows.append([domain]) } let formatter = TableOutput(rows: rows) print(formatter.format()) } } } ================================================ FILE: Sources/ContainerCommands/System/Kernel/KernelSet.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerPersistence import Containerization import ContainerizationError import ContainerizationExtras import ContainerizationOCI import Foundation import TerminalProgress extension Application { public struct KernelSet: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "set", abstract: "Set the default kernel" ) @Option(name: .long, help: "The architecture of the kernel binary (values: amd64, arm64)") var arch: String = ContainerizationOCI.Platform.current.architecture.description @Option(name: .customLong("binary"), help: "Path to the kernel file (or archive member, if used with --tar)") var binaryPath: String? = nil @Flag(name: .long, help: "Overwrites an existing kernel with the same name") var force: Bool = false @Flag(name: .long, help: "Download and install the recommended kernel as the default (takes precedence over all other flags)") var recommended: Bool = false @Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar archive containing a kernel file") var tarPath: String? = nil @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { if recommended { let url = DefaultsStore.get(key: .defaultKernelURL) let path = DefaultsStore.get(key: .defaultKernelBinaryPath) print("Installing the recommended kernel from \(url)...") try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path, force: force) return } guard tarPath != nil else { return try await self.setKernelFromBinary() } try await self.setKernelFromTar() } private func setKernelFromBinary() async throws { guard let binaryPath else { throw ArgumentParser.ValidationError("missing argument '--binary'") } let absolutePath = URL(fileURLWithPath: binaryPath, relativeTo: .currentDirectory()).absoluteURL.absoluteString let platform = try getSystemPlatform() try await ClientKernel.installKernel(kernelFilePath: absolutePath, platform: platform, force: force) } private func setKernelFromTar() async throws { guard let binaryPath else { throw ArgumentParser.ValidationError("missing argument '--binary'") } guard let tarPath else { throw ArgumentParser.ValidationError("missing argument '--tar") } let platform = try getSystemPlatform() let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).path let fm = FileManager.default if fm.fileExists(atPath: localTarPath) { try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform, force: force) return } guard let remoteURL = URL(string: tarPath) else { throw ContainerizationError(.invalidArgument, message: "invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?") } try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL.absoluteString, kernelFilePath: binaryPath, platform: platform, force: force) } private func getSystemPlatform() throws -> SystemPlatform { switch arch { case "arm64": return .linuxArm case "amd64": return .linuxAmd default: throw ContainerizationError(.unsupported, message: "unsupported architecture \(arch)") } } static func downloadAndInstallWithProgressBar(tarRemoteURL: String, kernelFilePath: String, platform: SystemPlatform = .current, force: Bool) async throws { let progressConfig = try ProgressConfig( showTasks: true, totalTasks: 2 ) let progress = ProgressBar(config: progressConfig) defer { progress.finish() } progress.start() try await ClientKernel.installKernelFromTar(tarFile: tarRemoteURL, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler, force: force) progress.finish() } } } ================================================ FILE: Sources/ContainerCommands/System/Property/PropertyClear.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerPersistence import ContainerizationError import Foundation extension Application { public struct PropertyClear: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "clear", abstract: "Clear a property value" ) @OptionGroup public var logOptions: Flags.Logging @Argument(help: "The property ID") var id: String public init() {} public func run() async throws { guard let key = DefaultsStore.Keys(rawValue: id) else { throw ContainerizationError(.invalidArgument, message: "invalid property ID: \(id)") } DefaultsStore.unset(key: key) } } } ================================================ FILE: Sources/ContainerCommands/System/Property/PropertyGet.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerPersistence import ContainerizationError import Foundation extension Application { public struct PropertyGet: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "get", abstract: "Retrieve a property value" ) @OptionGroup public var logOptions: Flags.Logging @Argument(help: "The property ID") var id: String public init() {} public func run() async throws { let value = DefaultsStore.allValues() .filter { id == $0.id } .first guard let value else { throw ContainerizationError(.invalidArgument, message: "property ID \(id) not found") } guard let val = value.value?.description else { return } print(val) } } } ================================================ FILE: Sources/ContainerCommands/System/Property/PropertyList.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerPersistence import Foundation extension Application { public struct PropertyList: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "list", abstract: "List system properties", aliases: ["ls"] ) @Option(name: .long, help: "Format of the output") var format: ListFormat = .table @Flag(name: .shortAndLong, help: "Only output the property ID") var quiet = false @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { let vals = DefaultsStore.allValues() try printValues(vals, format: format) } private func createHeader() -> [[String]] { [["ID", "TYPE", "VALUE", "DESCRIPTION"]] } private func printValues(_ vals: [DefaultsStoreValue], format: ListFormat) throws { if format == .json { let data = try JSONEncoder().encode(vals) print(String(decoding: data, as: UTF8.self)) return } if self.quiet { vals.forEach { print($0.id) } return } var rows = createHeader() for property in vals { rows.append(property.asRow) } let formatter = TableOutput(rows: rows) print(formatter.format()) } } } extension DefaultsStoreValue { var asRow: [String] { [id, String(describing: type), value?.description.elided(to: 40) ?? "*undefined*", description] } } extension String { func elided(to maxCount: Int) -> String { let ellipsis = "..." guard self.count > maxCount else { return self } if maxCount < ellipsis.count { return ellipsis } let prefixCount = maxCount - ellipsis.count return self.prefix(prefixCount) + ellipsis } } ================================================ FILE: Sources/ContainerCommands/System/Property/PropertySet.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerPersistence import ContainerizationError import ContainerizationExtras import ContainerizationOCI import Foundation extension Application { public struct PropertySet: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "set", abstract: "Set a property value" ) @OptionGroup public var logOptions: Flags.Logging @Argument(help: "The property ID") var id: String @Argument(help: "The property value") var value: String public init() {} public func run() async throws { guard let key = DefaultsStore.Keys(rawValue: id) else { throw ContainerizationError(.invalidArgument, message: "invalid property ID: \(id)") } switch key { case .buildRosetta: guard let boolValue = Parser.parseBool(string: value) else { throw ContainerizationError(.invalidArgument, message: "invalid boolean value: \(value)") } DefaultsStore.setBool(value: boolValue, key: key) case .defaultBuildCPUs, .defaultContainerCPUs: guard let cpuCount = Int(value), cpuCount > 0 else { throw ContainerizationError(.invalidArgument, message: "invalid CPU count: \(value)") } DefaultsStore.set(value: value, key: key) case .defaultBuildMemory, .defaultContainerMemory: guard let memoryMiB = try? Parser.memoryStringAsMiB(value), memoryMiB > 0 else { throw ContainerizationError(.invalidArgument, message: "invalid memory value: \(value)") } DefaultsStore.set(value: value, key: key) case .defaultDNSDomain, .defaultRegistryDomain: guard Parser.isValidDomainName(value) else { throw ContainerizationError(.invalidArgument, message: "invalid domain name: \(value)") } DefaultsStore.set(value: value, key: key) case .defaultBuilderImage, .defaultInitImage: guard (try? Reference.parse(value)) != nil else { throw ContainerizationError(.invalidArgument, message: "invalid image reference: \(value)") } DefaultsStore.set(value: value, key: key) case .defaultKernelBinaryPath: DefaultsStore.set(value: value, key: key) case .defaultKernelURL: guard URL(string: value) != nil else { throw ContainerizationError(.invalidArgument, message: "invalid URL: \(value)") } DefaultsStore.set(value: value, key: key) return case .defaultSubnet: guard (try? CIDRv4(value)) != nil else { throw ContainerizationError(.invalidArgument, message: "invalid CIDRv4 address: \(value)") } DefaultsStore.set(value: value, key: key) case .defaultIPv6Subnet: guard (try? CIDRv6(value)) != nil else { throw ContainerizationError(.invalidArgument, message: "invalid CIDRv6 address: \(value)") } DefaultsStore.set(value: value, key: key) } } } } ================================================ FILE: Sources/ContainerCommands/System/SystemCommand.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient extension Application { public struct SystemCommand: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "system", abstract: "Manage system components", subcommands: [ SystemDF.self, SystemDNS.self, SystemKernel.self, SystemLogs.self, SystemProperty.self, SystemStart.self, SystemStatus.self, SystemStop.self, SystemVersion.self, ], aliases: ["s"] ) @OptionGroup public var logOptions: Flags.Logging } } ================================================ FILE: Sources/ContainerCommands/System/SystemDF.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerizationError import Foundation extension Application { public struct SystemDF: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "df", abstract: "Show disk usage for images, containers, and volumes" ) @Option(name: .long, help: "Format of the output") var format: ListFormat = .table @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { let stats = try await ClientDiskUsage.get() if format == .json { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(stats) guard let jsonString = String(data: data, encoding: .utf8) else { throw ContainerizationError( .internalError, message: "failed to encode JSON output" ) } print(jsonString) return } printTable(stats: stats) } private func printTable(stats: DiskUsageStats) { var rows: [[String]] = [] // Header row rows.append(["TYPE", "TOTAL", "ACTIVE", "SIZE", "RECLAIMABLE"]) // Images row rows.append([ "Images", "\(stats.images.total)", "\(stats.images.active)", formatSize(stats.images.sizeInBytes), formatReclaimable(stats.images.reclaimable, total: stats.images.sizeInBytes), ]) // Containers row rows.append([ "Containers", "\(stats.containers.total)", "\(stats.containers.active)", formatSize(stats.containers.sizeInBytes), formatReclaimable(stats.containers.reclaimable, total: stats.containers.sizeInBytes), ]) // Volumes row rows.append([ "Local Volumes", "\(stats.volumes.total)", "\(stats.volumes.active)", formatSize(stats.volumes.sizeInBytes), formatReclaimable(stats.volumes.reclaimable, total: stats.volumes.sizeInBytes), ]) let tableFormatter = TableOutput(rows: rows) print(tableFormatter.format()) } private func formatSize(_ bytes: UInt64) -> String { if bytes == 0 { return "0 B" } let formatter = ByteCountFormatter() formatter.countStyle = .file return formatter.string(fromByteCount: Int64(bytes)) } private func formatReclaimable(_ reclaimable: UInt64, total: UInt64) -> String { let sizeStr = formatSize(reclaimable) if total == 0 { return "\(sizeStr) (0%)" } // Cap at 100% in case reclaimable > total (shouldn't happen but be defensive) let percentage = min(100, Int(round(Double(reclaimable) / Double(total) * 100.0))) return "\(sizeStr) (\(percentage)%)" } } } ================================================ FILE: Sources/ContainerCommands/System/SystemDNS.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerizationError import Foundation extension Application { public struct SystemDNS: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "dns", abstract: "Manage local DNS domains", subcommands: [ DNSCreate.self, DNSDelete.self, DNSList.self, ] ) @OptionGroup public var logOptions: Flags.Logging } } ================================================ FILE: Sources/ContainerCommands/System/SystemKernel.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient extension Application { public struct SystemKernel: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "kernel", abstract: "Manage the default kernel configuration", subcommands: [ KernelSet.self ] ) @OptionGroup public var logOptions: Flags.Logging } } ================================================ FILE: Sources/ContainerCommands/System/SystemLogs.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerizationError import ContainerizationOS import Foundation import OSLog extension Application { public struct SystemLogs: AsyncLoggableCommand { public static let subsystem = "com.apple.container" public static let configuration = CommandConfiguration( commandName: "logs", abstract: "Fetch system logs for `container` services" ) @Flag(name: .shortAndLong, help: "Follow log output") var follow: Bool = false @Option( name: .long, help: "Fetch logs starting from the specified time period (minus the current time); supported formats: m, h, d" ) var last: String = "5m" @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { let process = Process() let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM]) Task { for await _ in sigHandler.signals { process.terminate() Darwin.exit(0) } } do { var args = ["log"] args.append(self.follow ? "stream" : "show") args.append(contentsOf: ["--info", logOptions.debug ? "--debug" : nil].compactMap { $0 }) if !self.follow { args.append(contentsOf: ["--last", last]) } args.append(contentsOf: ["--predicate", "subsystem = 'com.apple.container'"]) process.launchPath = "/usr/bin/env" process.arguments = args process.standardOutput = FileHandle.standardOutput process.standardError = FileHandle.standardError try process.run() process.waitUntilExit() } catch { throw ContainerizationError( .invalidArgument, message: "failed to system logs: \(error)" ) } throw ArgumentParser.ExitCode(process.terminationStatus) } } } ================================================ FILE: Sources/ContainerCommands/System/SystemProperty.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerPersistence import ContainerizationError import Foundation extension Application { public struct SystemProperty: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "property", abstract: "Manage system property values", subcommands: [ PropertyClear.self, PropertyGet.self, PropertyList.self, PropertySet.self, ] ) @OptionGroup public var logOptions: Flags.Logging } } ================================================ FILE: Sources/ContainerCommands/System/SystemStart.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerPersistence import ContainerPlugin import ContainerXPC import ContainerizationError import Foundation import SystemPackage import TerminalProgress extension Application { public struct SystemStart: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "start", abstract: "Start `container` services" ) @Option( name: .shortAndLong, help: "Path to the root directory for application data", transform: { URL(filePath: $0) }) var appRoot = ApplicationRoot.defaultURL @Option( name: .long, help: "Path to the root directory for application executables and plugins", transform: { URL(filePath: $0) }) var installRoot = InstallRoot.defaultURL @Option( name: .long, help: "Path to the root directory for log data, using macOS log facility if not set", transform: { FilePath($0) }) var logRoot: FilePath? = nil @Flag( name: .long, inversion: .prefixedEnableDisable, help: "Specify whether the default kernel should be installed or not (default: prompt user)") var kernelInstall: Bool? @Option( help: "Number of seconds to wait for API service to become responsive", transform: { guard let timeoutSeconds = Double($0) else { throw ValidationError("Invalid timeout value: \($0)") } return .seconds(timeoutSeconds) } ) var timeout: Duration = XPCClient.xpcRegistrationTimeout @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { // Without the true path to the binary in the plist, `container-apiserver` won't launch properly. // TODO: Can we use the plugin loader to bootstrap the API server? let executableUrl = CommandLine.executablePathUrl .deletingLastPathComponent() .appendingPathComponent("container-apiserver") .resolvingSymlinksInPath() var args = [executableUrl.absolutePath()] args.append("start") if logOptions.debug { args.append("--debug") } let apiServerDataUrl = appRoot.appending(path: "apiserver") try! FileManager.default.createDirectory(at: apiServerDataUrl, withIntermediateDirectories: true) var env = PluginLoader.filterEnvironment() env[ApplicationRoot.environmentName] = appRoot.path(percentEncoded: false) env[InstallRoot.environmentName] = installRoot.path(percentEncoded: false) if let logRoot { env[LogRoot.environmentName] = logRoot.isAbsolute ? logRoot.string : FilePath(FileManager.default.currentDirectoryPath).appending(logRoot.components).string } let plist = LaunchPlist( label: "com.apple.container.apiserver", arguments: args, environment: env, limitLoadToSessionType: [.Aqua, .Background, .System], runAtLoad: true, machServices: ["com.apple.container.apiserver"] ) let plistURL = apiServerDataUrl.appending(path: "apiserver.plist") let data = try plist.encode() try data.write(to: plistURL) print("Registering API server with launchd...") try ServiceManager.register(plistPath: plistURL.path) // Now ping our friendly daemon. Fail if we don't get a response. do { print("Verifying apiserver is running...") _ = try await ClientHealthCheck.ping(timeout: timeout) } catch { throw ContainerizationError( .internalError, message: "failed to get a response from apiserver: \(error)" ) } if await !initImageExists() { try? await installInitialFilesystem() } guard await !kernelExists() else { return } try await installDefaultKernel() } private func installInitialFilesystem() async throws { let dep = Dependencies.initFs var pullCommand = try ImagePull.parse() pullCommand.reference = dep.source print("Installing base container filesystem...") do { try await pullCommand.run() } catch { log.error("failed to install base container filesystem", metadata: ["error": "\(error)"]) } } private func installDefaultKernel() async throws { let kernelDependency = Dependencies.kernel let defaultKernelURL = kernelDependency.source let defaultKernelBinaryPath = DefaultsStore.get(key: .defaultKernelBinaryPath) var shouldInstallKernel = false if kernelInstall == nil { print("No default kernel configured.") print("Install the recommended default kernel from [\(kernelDependency.source)]? [Y/n]: ", terminator: "") guard let read = readLine(strippingNewline: true) else { throw ContainerizationError(.internalError, message: "failed to read user input") } guard read.lowercased() == "y" || read.count == 0 else { print("Please use the `container system kernel set --recommended` command to configure the default kernel") return } shouldInstallKernel = true } else { shouldInstallKernel = kernelInstall ?? false } guard shouldInstallKernel else { return } print("Installing kernel...") try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: defaultKernelURL, kernelFilePath: defaultKernelBinaryPath, force: true) } private func initImageExists() async -> Bool { do { let img = try await ClientImage.get(reference: Dependencies.initFs.source) let _ = try await img.getSnapshot(platform: .current) return true } catch { return false } } private func kernelExists() async -> Bool { do { try await ClientKernel.getDefaultKernel(for: .current) return true } catch { return false } } } private enum Dependencies: String { case kernel case initFs var source: String { switch self { case .initFs: return DefaultsStore.get(key: .defaultInitImage) case .kernel: return DefaultsStore.get(key: .defaultKernelURL) } } } } ================================================ FILE: Sources/ContainerCommands/System/SystemStatus.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerPlugin import ContainerizationError import Foundation import Logging extension Application { public struct SystemStatus: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "status", abstract: "Show the status of `container` services" ) @Option(name: .shortAndLong, help: "Launchd prefix for services") var prefix: String = "com.apple.container." @Option(name: .long, help: "Format of the output") var format: ListFormat = .table @OptionGroup public var logOptions: Flags.Logging public init() {} struct PrintableStatus: Codable { let status: String let appRoot: String let installRoot: String let logRoot: String? let apiServerVersion: String let apiServerCommit: String let apiServerBuild: String let apiServerAppName: String } public func run() async throws { let isRegistered = try ServiceManager.isRegistered(fullServiceLabel: "\(prefix)apiserver") if !isRegistered { if format == .json { let status = PrintableStatus( status: "unregistered", appRoot: "", installRoot: "", logRoot: nil, apiServerVersion: "", apiServerCommit: "", apiServerBuild: "", apiServerAppName: "" ) let data = try JSONEncoder().encode(status) print(String(decoding: data, as: UTF8.self)) } else { print("apiserver is not running and not registered with launchd") } Application.exit(withError: ExitCode(1)) } // Now ping our friendly daemon. Fail after 10 seconds with no response. do { let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10)) if format == .json { let status = PrintableStatus( status: "running", appRoot: systemHealth.appRoot.path(percentEncoded: false), installRoot: systemHealth.installRoot.path(percentEncoded: false), logRoot: systemHealth.logRoot?.string, apiServerVersion: systemHealth.apiServerVersion, apiServerCommit: systemHealth.apiServerCommit, apiServerBuild: systemHealth.apiServerBuild, apiServerAppName: systemHealth.apiServerAppName ) let data = try JSONEncoder().encode(status) print(String(decoding: data, as: UTF8.self)) } else { let rows: [[String]] = [ ["FIELD", "VALUE"], ["status", "running"], ["appRoot", systemHealth.appRoot.path(percentEncoded: false)], ["installRoot", systemHealth.installRoot.path(percentEncoded: false)], ["logRoot", systemHealth.logRoot?.string ?? ""], ["apiserver.version", systemHealth.apiServerVersion], ["apiserver.commit", systemHealth.apiServerCommit], ["apiserver.build", systemHealth.apiServerBuild], ["apiserver.appName", systemHealth.apiServerAppName], ] let formatter = TableOutput(rows: rows) print(formatter.format()) } } catch { if format == .json { let status = PrintableStatus( status: "not running", appRoot: "", installRoot: "", logRoot: nil, apiServerVersion: "", apiServerCommit: "", apiServerBuild: "", apiServerAppName: "" ) let data = try JSONEncoder().encode(status) print(String(decoding: data, as: UTF8.self)) } else { print("apiserver is not running") } Application.exit(withError: ExitCode(1)) } } } } ================================================ FILE: Sources/ContainerCommands/System/SystemStop.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerPlugin import ContainerResource import ContainerizationOS import Foundation import Logging extension Application { public struct SystemStop: AsyncLoggableCommand { private static let stopTimeoutSeconds: Int32 = 5 private static let shutdownTimeoutSeconds: Int32 = 20 public static let configuration = CommandConfiguration( commandName: "stop", abstract: "Stop all `container` services" ) @Option(name: .shortAndLong, help: "Launchd prefix for services") var prefix: String = "com.apple.container." @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { let log = Logger( label: "com.apple.container.cli", factory: { label in StreamLogHandler.standardOutput(label: label) } ) let launchdDomainString = try ServiceManager.getDomainString() let fullLabel = "\(launchdDomainString)/\(prefix)apiserver" var running = true do { log.info("checking if APIServer is alive") _ = try await ClientHealthCheck.ping(timeout: .seconds(5)) } catch { log.info("APIServer health check failed, skipping bootout") running = false } if running { let client = ContainerClient() log.info("stopping containers", metadata: ["stopTimeoutSeconds": "\(Self.stopTimeoutSeconds)"]) do { let containers = try await client.list() let signal = try Signals.parseSignal("SIGTERM") let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: signal) try await ContainerStop.stopContainers( client: client, containers: containers, stopOptions: opts, ) } catch { log.warning("failed to stop all containers", metadata: ["error": "\(error)"]) } log.info("waiting for containers to exit") do { for _ in 0..(names) let volumes: [Volume] if all { volumes = try await ClientVolume.list() } else { volumes = try await ClientVolume.list() .filter { v in uniqueVolumeNames.contains(v.id) } // If one of the volumes requested isn't present lets throw. We don't need to do // this for --all as --all should be perfectly usable with no volumes to remove, // otherwise it'd be quite clunky. if volumes.count != uniqueVolumeNames.count { let missing = uniqueVolumeNames.filter { id in !volumes.contains { v in v.id == id } } throw ContainerizationError( .notFound, message: "failed to delete one or more volumes: \(missing)" ) } } var failed = [String]() let _log = log try await withThrowingTaskGroup(of: Volume?.self) { group in for volume in volumes { group.addTask { do { try await ClientVolume.delete(name: volume.id) print(volume.id) return nil } catch { _log.error( "failed to delete volume", metadata: [ "id": "\(volume.id)", "error": "\(error)", ]) return volume } } } for try await volume in group { guard let volume else { continue } failed.append(volume.id) } } if failed.count > 0 { throw ContainerizationError(.internalError, message: "delete failed for one or more volumes: \(failed)") } } } } ================================================ FILE: Sources/ContainerCommands/Volume/VolumeInspect.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import Foundation extension Application.VolumeCommand { public struct VolumeInspect: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "inspect", abstract: "Display information about one or more volumes" ) @OptionGroup public var logOptions: Flags.Logging @Argument(help: "Volumes to inspect") var names: [String] public init() {} public func run() async throws { var volumes: [Volume] = [] for name in names { let volume = try await ClientVolume.inspect(name) volumes.append(volume) } let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(volumes) print(String(decoding: data, as: UTF8.self)) } } } ================================================ FILE: Sources/ContainerCommands/Volume/VolumeList.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import ContainerResource import ContainerizationExtras import Foundation extension Application.VolumeCommand { public struct VolumeList: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "list", abstract: "List volumes", aliases: ["ls"] ) @Option(name: .long, help: "Format of the output") var format: Application.ListFormat = .table @Flag(name: .shortAndLong, help: "Only output the volume name") var quiet: Bool = false @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { let volumes = try await ClientVolume.list() try printVolumes(volumes: volumes, format: format) } private func createHeader() -> [[String]] { [["NAME", "TYPE", "DRIVER", "OPTIONS"]] } func printVolumes(volumes: [Volume], format: Application.ListFormat) throws { if format == .json { let data = try JSONEncoder().encode(volumes) print(String(decoding: data, as: UTF8.self)) return } if quiet { volumes.forEach { print($0.name) } return } // Sort volumes by creation time (newest first) let sortedVolumes = volumes.sorted { v1, v2 in v1.createdAt > v2.createdAt } var rows = createHeader() for volume in sortedVolumes { rows.append(volume.asRow) } let formatter = TableOutput(rows: rows) print(formatter.format()) } } } extension Volume { var asRow: [String] { let volumeType = self.isAnonymous ? "anonymous" : "named" let optionsString = options.isEmpty ? "" : options.map { "\($0.key)=\($0.value)" }.joined(separator: ",") return [ self.name, volumeType, self.driver, optionsString, ] } } ================================================ FILE: Sources/ContainerCommands/Volume/VolumePrune.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerAPIClient import Foundation extension Application.VolumeCommand { public struct VolumePrune: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "prune", abstract: "Remove volumes with no container references") @OptionGroup public var logOptions: Flags.Logging public func run() async throws { let allVolumes = try await ClientVolume.list() // Find all volumes not used by any container let client = ContainerClient() let containers = try await client.list() var volumesInUse = Set() for container in containers { for mount in container.configuration.mounts { if mount.isVolume, let volumeName = mount.volumeName { volumesInUse.insert(volumeName) } } } let volumesToPrune = allVolumes.filter { volume in !volumesInUse.contains(volume.name) } var prunedVolumes = [String]() var totalSize: UInt64 = 0 for volume in volumesToPrune { do { let actualSize = try await ClientVolume.volumeDiskUsage(name: volume.name) totalSize += actualSize try await ClientVolume.delete(name: volume.name) prunedVolumes.append(volume.name) } catch { log.error( "failed to prune volume", metadata: [ "id": "\(volume.name)", "error": "\(error)", ]) } } for name in prunedVolumes { print(name) } let formatter = ByteCountFormatter() let freed = formatter.string(fromByteCount: Int64(totalSize)) print("Reclaimed \(freed) in disk space") } } } ================================================ FILE: Sources/ContainerLog/FileLogHandler.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 Logging import SystemPackage /// Log handler that appends messages to a file, without any /// rotation or truncation strategy. Use for development purposes only. public struct FileLogHandler: LogHandler { public var logLevel: Logger.Level = .info public var metadata: Logger.Metadata = [:] private let label: String private let category: String private let fileHandle: FileHandle public subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { get { self.metadata[metadataKey] } set { self.metadata[metadataKey] = newValue } } /// Create a log handler that appends to the specified file. /// /// - Parameters: /// - label: A unique identifier for the application. /// - category: An identifier for the application subsystem. /// - path: The log file location. The log handler creates the /// file and parent directory if needed. /// - Returns: The log handler. public init(label: String, category: String, path: FilePath) throws { self.label = label self.category = category let parentPath = path.removingLastComponent() try FileManager.default.createDirectory(atPath: parentPath.string, withIntermediateDirectories: true) if !FileManager.default.fileExists(atPath: path.string) { FileManager.default.createFile(atPath: path.string, contents: nil) } guard let handle = FileHandle(forWritingAtPath: path.string) else { throw FileLogFailure.openFailed } self.fileHandle = handle self.fileHandle.seekToEndOfFile() } public func log( level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, source: String, file: String, function: String, line: UInt ) { let timestampFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions.insert(.withFractionalSeconds) return formatter }() let timestamp = timestampFormatter.string(from: Date()) // Merge logger-level metadata with per-message metadata var effectiveMetadata = self.metadata if let metadata { effectiveMetadata.merge(metadata) { _, new in new } } let text: String if !effectiveMetadata.isEmpty { text = "\(timestamp) [\(level)] \(label) \(category) \(effectiveMetadata.description): \(message)\n" } else { text = "\(timestamp) [\(level)] \(label): \(category) \(message)\n" } if let data = text.data(using: .utf8) { fileHandle.write(data) } } /// Failures relating to the log handler. public enum FileLogFailure: Error { /// The log handler could not open the log file. case openFailed } } ================================================ FILE: Sources/ContainerLog/OSLogHandler.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 Logging import os import struct Logging.Logger public struct OSLogHandler: LogHandler { private let logger: os.Logger public var logLevel: Logger.Level = .info private var formattedMetadata: String? public var metadata = Logger.Metadata() { didSet { self.formattedMetadata = self.formatMetadata(self.metadata) } } public subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { get { self.metadata[metadataKey] } set { self.metadata[metadataKey] = newValue } } public init(label: String, category: String) { self.logger = os.Logger(subsystem: label, category: category) } } extension OSLogHandler { public func log( level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, source: String, file: String, function: String, line: UInt ) { var formattedMetadata = self.formattedMetadata if let metadataOverride = metadata, !metadataOverride.isEmpty { formattedMetadata = self.formatMetadata( self.metadata.merging(metadataOverride) { $1 } ) } var finalMessage = message.description if let formattedMetadata { finalMessage += " " + formattedMetadata } self.logger.log( level: level.toOSLogLevel(), "\(finalMessage, privacy: .public)" ) } private func formatMetadata(_ metadata: Logger.Metadata) -> String? { if metadata.isEmpty { return nil } return metadata.map { "[\($0)=\($1)]" }.joined(separator: " ") } } extension Logger.Level { func toOSLogLevel() -> OSLogType { switch self { case .debug, .trace: return .debug case .info: return .info case .notice, .warning: return .default case .error: return .error case .critical: return .fault } } } ================================================ FILE: Sources/ContainerLog/ServiceLogger.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Logging import SystemPackage /// Common logging setup for application services. public struct ServiceLogger { /// Set up the logging system and create a root logger. /// /// - Parameters: /// - label: A unique identifier for the application. /// - category: An identifier for the application subsystem. /// - metadata: Metadata to include for all messsages. A message /// specific value for a duplicate key overrides these values. /// - debug: Enable debug logging. /// - logPath: If supplied, create log files under the named /// directory. Otherwise, log to the OS log facility. /// - Returns: The root logger. public static func bootstrap( label: String = "com.apple.container", category: String, metadata: [String: String] = [:], debug: Bool, logPath: FilePath? ) -> Logger { // Select the log handler and bootstrap logging. LoggingSystem.bootstrap { label in if let logPath { if let handler = try? FileLogHandler( label: label, category: category, path: logPath ) { return handler } } return OSLogHandler(label: label, category: category) } // Configure log level and metadata. var log = Logger(label: label) if debug { log.logLevel = .debug } for (key, value) in metadata { log[metadataKey: key] = "\(value)" } // Log an error if for some reason FileLogHandler init failed. if let logPath, log.handler as? OSLogHandler != nil { log.error( "unable to initialize FileLogHandler, using OSLogHandler", metadata: [ "logPath": "\(logPath)" ]) } return log } } ================================================ FILE: Sources/ContainerLog/StderrLogHandler.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 Logging /// Basic log handler for where simple message output is needed, /// such as CLI commands. public struct StderrLogHandler: LogHandler { public var logLevel: Logger.Level = .info public var metadata: Logger.Metadata = [:] public subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { get { self.metadata[metadataKey] } set { self.metadata[metadataKey] = newValue } } public init() {} public func log( level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, source: String, file: String, function: String, line: UInt ) { let data: Data switch logLevel { case .debug, .trace: let timestamp = ISO8601DateFormatter().string(from: Date()) if let metadata, !metadata.isEmpty { data = "\(timestamp) \(message.description): \(metadata.description)" .data(using: .utf8) ?? Data() } else { data = "\(timestamp) \(message.description)" .data(using: .utf8) ?? Data() } default: if let metadata, !metadata.isEmpty { data = "\(message.description): \(metadata.description)" .data(using: .utf8) ?? Data() } else { data = message.description .data(using: .utf8) ?? Data() } } // Use a single write call for atomicity var output = data output.append("\n".data(using: .utf8)!) FileHandle.standardError.write(output) } } ================================================ FILE: Sources/ContainerOS/DirectoryWatcher.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import ContainerizationOS import Foundation import Logging import Synchronization /// Watches a directory for changes and invokes a handler when the contents change. /// /// `DirectoryWatcher` uses `DispatchSource` file system events to monitor a directory. /// If the target directory does not exist yet, it polls until the directory is created. /// the target is created, then transitions to watching the target directly. /// /// Example usage: /// ```swift /// let watcher = DirectoryWatcher(directoryURL: myURL, log: logger) /// try watcher.startWatching { urls in /// print("Directory contents changed: \(urls)") /// } /// ``` public actor DirectoryWatcher { public static let watchPeriod = Duration.seconds(1) /// The URL of the directory being watched. public let directoryURL: URL private var task: Task? private let monitorQueue: DispatchQueue private let source: Mutex private let log: Logger? /// Creates a new `DirectoryWatcher` for the given directory URL. /// /// - Parameters: /// - directoryURL: The URL of the directory to watch. /// - log: An optional logger for diagnostic messages. public init(directoryURL: URL, log: Logger?) { self.directoryURL = directoryURL self.monitorQueue = DispatchQueue(label: "monitor:\(directoryURL.path)") self.log = log self.source = Mutex(nil) } /// Starts watching the directory for changes. /// /// - Parameters: /// - handler: handler to run on directory state change. public func startWatching(handler: @Sendable @escaping ([URL]) throws -> Void) { self.task = Task { var exists: Bool var isDir: ObjCBool = false while true { do { exists = FileManager.default.fileExists(atPath: self.directoryURL.path, isDirectory: &isDir) if exists && isDir.boolValue && self.source.withLock({ $0 }) == nil { try _startWatching(handler: handler) } } catch { log?.error("failed to start watching", metadata: ["error": "\(error)"]) } try await Task.sleep(for: Self.watchPeriod) } } } private func _startWatching( handler: @escaping ([URL]) throws -> Void ) throws { let descriptor = open(directoryURL.path, O_EVTONLY) guard descriptor > 0 else { throw ContainerizationError(.internalError, message: "cannot open \(directoryURL.path), descriptor=\(descriptor)") } do { let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path) try handler(files.map { directoryURL.appending(path: $0) }) } catch { throw ContainerizationError(.internalError, message: "failed to run handler for \(directoryURL.path)") } log?.info("starting directory watcher", metadata: ["path": "\(directoryURL.path)"]) let dispatchSource = DispatchSource.makeFileSystemObjectSource( fileDescriptor: descriptor, eventMask: [.delete, .write], queue: monitorQueue ) dispatchSource.setCancelHandler { close(descriptor) } dispatchSource.setEventHandler { [weak self] in guard let self else { return } guard !dispatchSource.data.contains(.delete) else { dispatchSource.cancel() self.source.withLock { $0 = nil } return } do { let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path) try handler(files.map { directoryURL.appending(path: $0) }) } catch { self.log?.error( "failed to run watch handler", metadata: ["error": "\(error)", "path": "\(directoryURL.path)"]) } } source.withLock { $0 = dispatchSource } dispatchSource.resume() } deinit { self.task?.cancel() source.withLock { $0?.cancel() } } } ================================================ FILE: Sources/ContainerOS/LocalNetworkPrivacy.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// #if os(macOS) import Darwin /// Utility for triggering local network privacy alert. /// The local networking privacy feature introduced in /// macOS 15 requires users to authorize an app before it can /// access peers on the local network. This security feature /// affects runtime helpers that publish ports on the loopback /// interface. /// /// The approach used here is for the application to trigger /// the alert before clients attemot to communicate with it. /// This is a best effort method; there is no guarantee that /// the alert will display. /// /// See https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy /// for additional details. package struct LocalNetworkPrivacy { /// Attempts to trigger the local network privacy alert. /// /// This builds a list of link-local IPv6 addresses and then creates a connected /// UDP socket to each in turn. Connecting a UDP socket triggers the local /// network alert without actually sending any traffic. package static func triggerLocalNetworkPrivacyAlert() { let addresses = selectedLinkLocalIPv6Addresses() for address in addresses { let sock6 = socket(AF_INET6, SOCK_DGRAM, 0) guard sock6 >= 0 else { return } defer { close(sock6) } withUnsafePointer(to: address) { sa6 in sa6.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in _ = connect(sock6, sa, socklen_t(sa.pointee.sa_len)) >= 0 } } } } private static func selectedLinkLocalIPv6Addresses() -> [sockaddr_in6] { // Find the link-local broadcast-capable IPv6 interfaces, and // for each, create two peer socket addresses for the interface // with the port set to the discard service (port 9). let r1 = (0..<8).map { _ in UInt8.random(in: 0...255) } let r2 = (0..<8).map { _ in UInt8.random(in: 0...255) } return Array( ipv6AddressesOfBroadcastCapableInterfaces() .filter { isIPv6AddressLinkLocal($0) } .map { var addr = $0 addr.sin6_port = UInt16(9).bigEndian return addr } .map { [setIPv6LinkLocalAddressHostPart(of: $0, to: r1), setIPv6LinkLocalAddressHostPart(of: $0, to: r2)] } .joined()) } private static func setIPv6LinkLocalAddressHostPart(of address: sockaddr_in6, to hostPart: [UInt8]) -> sockaddr_in6 { // Set the host part (the bottom 64 bits) of the supplied // IPv6 socket address. precondition(hostPart.count == 8) var result = address withUnsafeMutableBytes(of: &result.sin6_addr) { buf in buf[8...].copyBytes(from: hostPart) } return result } private static func isIPv6AddressLinkLocal(_ address: sockaddr_in6) -> Bool { // Link-local address have the fe:c0/10 prefix. address.sin6_addr.__u6_addr.__u6_addr8.0 == 0xfe && (address.sin6_addr.__u6_addr.__u6_addr8.1 & 0xc0) == 0x80 } private static func ipv6AddressesOfBroadcastCapableInterfaces() -> [sockaddr_in6] { // Iterate all interfaces and return the IPv6 addresses // for those that can broadcast. var addrList: UnsafeMutablePointer? = nil let err = getifaddrs(&addrList) guard err == 0, let start = addrList else { return [] } defer { freeifaddrs(start) } return sequence(first: start, next: { $0.pointee.ifa_next }) .compactMap { i -> sockaddr_in6? in guard (i.pointee.ifa_flags & UInt32(bitPattern: IFF_BROADCAST)) != 0, let sa = i.pointee.ifa_addr, sa.pointee.sa_family == AF_INET6, sa.pointee.sa_len >= MemoryLayout.size else { return nil } return UnsafeRawPointer(sa).load(as: sockaddr_in6.self) } } } #endif ================================================ FILE: Sources/ContainerPersistence/DefaultsStore.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import CVersion import ContainerVersion import ContainerizationError import Foundation public enum DefaultsStore { public static let userDefaultDomain = "com.apple.container.defaults" public enum Keys: String { case buildRosetta = "build.rosetta" case defaultBuildCPUs = "build.cpus" case defaultBuildMemory = "build.memory" case defaultContainerCPUs = "container.cpus" case defaultContainerMemory = "container.memory" case defaultDNSDomain = "dns.domain" case defaultBuilderImage = "image.builder" case defaultInitImage = "image.init" case defaultKernelBinaryPath = "kernel.binaryPath" case defaultKernelURL = "kernel.url" case defaultSubnet = "network.subnet" case defaultIPv6Subnet = "network.subnetv6" case defaultRegistryDomain = "registry.domain" } public static func set(value: String, key: DefaultsStore.Keys) { udSuite.set(value, forKey: key.rawValue) } public static func unset(key: DefaultsStore.Keys) { udSuite.removeObject(forKey: key.rawValue) } public static func get(key: DefaultsStore.Keys) -> String { let appBundle = Bundle.appBundle(executableURL: CommandLine.executablePathUrl) return udSuite.string(forKey: key.rawValue) ?? appBundle?.infoDictionary?["\(Self.userDefaultDomain).\(key.rawValue)"] as? String ?? key.defaultValue } public static func getOptional(key: DefaultsStore.Keys) -> String? { udSuite.string(forKey: key.rawValue) } public static func setBool(value: Bool, key: DefaultsStore.Keys) { udSuite.set(value, forKey: key.rawValue) } public static func getBool(key: DefaultsStore.Keys) -> Bool? { if udSuite.object(forKey: key.rawValue) != nil { return udSuite.bool(forKey: key.rawValue) } let appBundle = Bundle.appBundle(executableURL: CommandLine.executablePathUrl) return appBundle?.infoDictionary?["\(Self.userDefaultDomain).\(key.rawValue)"] as? Bool ?? Bool(key.defaultValue) } public static func allValues() -> [DefaultsStoreValue] { let allKeys: [(Self.Keys, (Self.Keys) -> Any?)] = [ (.buildRosetta, { Self.getBool(key: $0) }), (.defaultBuildCPUs, { Self.getOptional(key: $0) }), (.defaultBuildMemory, { Self.getOptional(key: $0) }), (.defaultContainerCPUs, { Self.getOptional(key: $0) }), (.defaultContainerMemory, { Self.getOptional(key: $0) }), (.defaultBuilderImage, { Self.get(key: $0) }), (.defaultInitImage, { Self.get(key: $0) }), (.defaultKernelBinaryPath, { Self.get(key: $0) }), (.defaultKernelURL, { Self.get(key: $0) }), (.defaultSubnet, { Self.getOptional(key: $0) }), (.defaultIPv6Subnet, { Self.getOptional(key: $0) }), (.defaultDNSDomain, { Self.getOptional(key: $0) }), (.defaultRegistryDomain, { Self.get(key: $0) }), ] return allKeys .map { DefaultsStoreValue(id: $0.rawValue, description: $0.summary, value: $1($0) as? (Encodable & CustomStringConvertible), type: $0.type) } .sorted(by: { $0.id < $1.id }) } private static var udSuite: UserDefaults { guard let ud = UserDefaults.init(suiteName: self.userDefaultDomain) else { fatalError("failed to initialize UserDefaults for domain \(self.userDefaultDomain)") } return ud } } public struct DefaultsStoreValue: Identifiable, CustomStringConvertible, Encodable { public let id: String public let description: String public let value: (Encodable & CustomStringConvertible)? public let type: Any.Type public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(description, forKey: .description) if let value = value { try container.encode(value, forKey: .value) } else { try container.encodeNil(forKey: .value) } try container.encode(String(describing: type), forKey: .type) } enum CodingKeys: String, CodingKey { case id, description, value, type } } extension DefaultsStore.Keys { public var summary: String { switch self { case .buildRosetta: return "Build amd64 images on arm64 using Rosetta, instead of QEMU." case .defaultBuildCPUs: return "If defined, the default number of CPUs to allocate to the builder container." case .defaultBuildMemory: return "If defined, the default amount of memory to allocate to the builder container." case .defaultContainerCPUs: return "If defined, the default number of CPUs to allocate to a container." case .defaultContainerMemory: return "If defined, the default amount of memory to allocate to a container." case .defaultDNSDomain: return "If defined, the local DNS domain to use for containers with unqualified names." case .defaultBuilderImage: return "The image reference for the utility container that `container build` uses." case .defaultInitImage: return "The image reference for the default initial filesystem image." case .defaultKernelBinaryPath: return "If the kernel URL is for an archive, the archive member pathname for the kernel file." case .defaultKernelURL: return "The URL for the kernel file to install, or the URL for an archive containing the kernel file." case .defaultSubnet: return "Default subnet for IPv4 allocation." case .defaultIPv6Subnet: return "Default IPv6 network prefix." case .defaultRegistryDomain: return "The default registry to use for image references that do not specify a registry." } } public var type: Any.Type { switch self { case .buildRosetta: return Bool.self case .defaultBuildCPUs: return String.self case .defaultBuildMemory: return String.self case .defaultContainerCPUs: return String.self case .defaultContainerMemory: return String.self case .defaultDNSDomain: return String.self case .defaultBuilderImage: return String.self case .defaultInitImage: return String.self case .defaultKernelBinaryPath: return String.self case .defaultKernelURL: return String.self case .defaultSubnet: return String.self case .defaultIPv6Subnet: return String.self case .defaultRegistryDomain: return String.self } } fileprivate var defaultValue: String { switch self { case .buildRosetta: // This is a boolean key, not used with the string get() method return "true" case .defaultBuildCPUs: // This key is read with getOptional(), not get(); this value is never used return "2" case .defaultBuildMemory: // This key is read with getOptional(), not get(); this value is never used return "2048MB" case .defaultContainerCPUs: // This key is read with getOptional(), not get(); this value is never used return "4" case .defaultContainerMemory: // This key is read with getOptional(), not get(); this value is never used return "1g" case .defaultDNSDomain: return "test" case .defaultBuilderImage: let tag = String(cString: get_container_builder_shim_version()) return "ghcr.io/apple/container-builder-shim/builder:\(tag)" case .defaultInitImage: let tag = String(cString: get_swift_containerization_version()) guard tag != "latest" else { return "vminit:latest" } return "ghcr.io/apple/containerization/vminit:\(tag)" case .defaultKernelBinaryPath: return "opt/kata/share/kata-containers/vmlinux-6.18.5-177" case .defaultKernelURL: return "https://github.com/kata-containers/kata-containers/releases/download/3.26.0/kata-static-3.26.0-arm64.tar.zst" case .defaultSubnet: return "192.168.64.1/24" case .defaultIPv6Subnet: return "fd00::/64" case .defaultRegistryDomain: return "docker.io" } } } ================================================ FILE: Sources/ContainerPersistence/EntityStore.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import Foundation import Logging let metadataFilename: String = "entity.json" public protocol EntityStore { associatedtype T: Codable & Identifiable & Sendable func list() async throws -> [T] func create(_ entity: T) async throws func retrieve(_ id: String) async throws -> T? func update(_ entity: T) async throws func upsert(_ entity: T) async throws func delete(_ id: String) async throws } public actor FilesystemEntityStore: EntityStore where T: Codable & Identifiable & Sendable { typealias Index = [String: T] private let path: URL private let type: String private var index: Index private let log: Logger private let encoder = JSONEncoder() public init(path: URL, type: String, log: Logger) throws { self.path = path self.type = type self.log = log self.index = try Self.load(path: path, log: log) } public func list() async throws -> [T] { Array(index.values) } public func create(_ entity: T) async throws { let metadataUrl = metadataUrl(entity.id) guard !FileManager.default.fileExists(atPath: metadataUrl.path) else { throw ContainerizationError(.exists, message: "entity \(entity.id) already exist") } try FileManager.default.createDirectory(at: entityUrl(entity.id), withIntermediateDirectories: true) let data = try encoder.encode(entity) try data.write(to: metadataUrl) index[entity.id] = entity } public func retrieve(_ id: String) throws -> T? { index[id] } public func update(_ entity: T) async throws { let metadataUrl: URL = metadataUrl(entity.id) guard FileManager.default.fileExists(atPath: metadataUrl.path) else { throw ContainerizationError(.notFound, message: "entity \(entity.id) not found") } let data = try encoder.encode(entity) try data.write(to: metadataUrl) index[entity.id] = entity } public func upsert(_ entity: T) async throws { let metadataUrl: URL = metadataUrl(entity.id) let data = try encoder.encode(entity) try data.write(to: metadataUrl) index[entity.id] = entity } public func delete(_ id: String) async throws { let metadataUrl = entityUrl(id) guard FileManager.default.fileExists(atPath: metadataUrl.path) else { throw ContainerizationError(.notFound, message: "entity \(id) not found") } try FileManager.default.removeItem(at: metadataUrl) index.removeValue(forKey: id) } public func entityUrl(_ id: String) -> URL { path.appendingPathComponent(id) } 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 decoder.decode(T.self, from: data) index[entity.id] = entity } catch { log.warning( "failed to load entity, ignoring", metadata: [ "path": "\(entityUrl)" ]) } } return index } private func metadataUrl(_ id: String) -> URL { entityUrl(id).appendingPathComponent(metadataFilename) } } ================================================ FILE: Sources/ContainerPlugin/ApplicationRoot.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Provides the application data root path. public struct ApplicationRoot { public static let environmentName = "CONTAINER_APP_ROOT" public static let defaultURL = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask ).first!.appendingPathComponent("com.apple.container") private static let envPath = ProcessInfo.processInfo.environment[Self.environmentName] public static let url = envPath.map { URL(fileURLWithPath: $0) } ?? defaultURL public static let path = url.path(percentEncoded: false) } ================================================ FILE: Sources/ContainerPlugin/InstallRoot.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerVersion import Foundation /// Provides the application installation root path. public struct InstallRoot { public static let environmentName = "CONTAINER_INSTALL_ROOT" public static let defaultURL = CommandLine.executablePathUrl .deletingLastPathComponent() .appendingPathComponent("..") .standardized private static let envPath = ProcessInfo.processInfo.environment[Self.environmentName] public static let url = envPath.map { URL(fileURLWithPath: $0) } ?? defaultURL public static let path = url.path(percentEncoded: false) } ================================================ FILE: Sources/ContainerPlugin/LaunchPlist.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// #if os(macOS) import Foundation public struct LaunchPlist: Encodable { static let debugTarget = "CONTAINER_DEBUG_LAUNCHD_LABEL" public enum Domain: String, Codable { case Aqua case Background case System } public let label: String public let arguments: [String] public let environment: [String: String]? public let cwd: String? public let username: String? public let groupname: String? public let limitLoadToSessionType: [Domain]? public let runAtLoad: Bool? public let stdin: String? public let stdout: String? public let stderr: String? public let disabled: Bool? public let program: String? public let keepAlive: Bool? public let machServices: [String: Bool]? public let waitForDebugger: Bool? enum CodingKeys: String, CodingKey { case label = "Label" case arguments = "ProgramArguments" case environment = "EnvironmentVariables" case cwd = "WorkingDirectory" case username = "UserName" case groupname = "GroupName" case limitLoadToSessionType = "LimitLoadToSessionType" case runAtLoad = "RunAtLoad" case stdin = "StandardInPath" case stdout = "StandardOutPath" case stderr = "StandardErrorPath" case disabled = "Disabled" case program = "Program" case keepAlive = "KeepAlive" case machServices = "MachServices" case waitForDebugger = "WaitForDebugger" } static private func getWaitForDebugger(label: String, fromArg: Bool?) -> Bool? { if let fromArg { return fromArg } let env = ProcessInfo.processInfo.environment if let debugTarget = env[Self.debugTarget], label == debugTarget || label.starts(with: debugTarget + ".") { return true } return nil } public init( label: String, arguments: [String], environment: [String: String]? = nil, cwd: String? = nil, username: String? = nil, groupname: String? = nil, limitLoadToSessionType: [Domain]? = nil, runAtLoad: Bool? = nil, stdin: String? = nil, stdout: String? = nil, stderr: String? = nil, disabled: Bool? = nil, program: String? = nil, keepAlive: Bool? = nil, machServices: [String]? = nil, waitForDebugger: Bool? = nil ) { self.label = label self.arguments = arguments self.environment = environment self.cwd = cwd self.username = username self.groupname = groupname self.limitLoadToSessionType = limitLoadToSessionType self.runAtLoad = runAtLoad self.stdin = stdin self.stdout = stdout self.stderr = stderr self.disabled = disabled self.program = program self.keepAlive = keepAlive self.waitForDebugger = Self.getWaitForDebugger(label: label, fromArg: waitForDebugger) if let services = machServices { var machServices: [String: Bool] = [:] for service in services { machServices[service] = true } self.machServices = machServices } else { self.machServices = nil } } } extension LaunchPlist { public func encode() throws -> Data { let enc = PropertyListEncoder() enc.outputFormat = .xml return try enc.encode(self) } } #endif ================================================ FILE: Sources/ContainerPlugin/LogRoot.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Provides the application data root path. public struct LogRoot { private static let envPath = ProcessInfo.processInfo.environment[Self.environmentName].flatMap { $0.isEmpty ? nil : FilePath($0) } /// The environment variable that if set, determines the root directory for log files. /// Otherwise, the application uses the macOS log facility. public static let environmentName = "CONTAINER_LOG_ROOT" /// The path object for the log file root directory public static let path = envPath.map { guard !$0.isAbsolute else { return $0 } return FilePath(FileManager.default.currentDirectoryPath).appending($0.components) } /// The pathname to the log file root directory public static let pathname = path?.string } ================================================ FILE: Sources/ContainerPlugin/Plugin.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Value type that contains the plugin configuration, the parsed name of the /// plugin and whether a CLI surface for the plugin was found. public struct Plugin: Sendable, Codable { private static let machServicePrefix = "com.apple.container." /// Pathname to installation directory for plugins. public let binaryURL: URL /// Configuration for the plugin. public let config: PluginConfig public init(binaryURL: URL, config: PluginConfig) { self.binaryURL = binaryURL self.config = config } } extension Plugin { public var name: String { binaryURL.lastPathComponent } public var shouldBoot: Bool { guard let config = self.config.servicesConfig else { return false } return config.loadAtBoot } public func getLaunchdLabel(instanceId: String? = nil) -> String { // Use the plugin name for the launchd label. guard let instanceId else { return "\(Self.machServicePrefix)\(self.name)" } return "\(Self.machServicePrefix)\(self.name).\(instanceId)" } public func getMachServices(instanceId: String? = nil) -> [String] { // Use the service type for the mach service. guard let config = self.config.servicesConfig else { return [] } var services = [String]() for service in config.services { let serviceName: String if let instanceId { serviceName = "\(Self.machServicePrefix)\(service.type.rawValue).\(name).\(instanceId)" } else { serviceName = "\(Self.machServicePrefix)\(service.type.rawValue).\(name)" } services.append(serviceName) } return services } public func getMachService(instanceId: String? = nil, type: PluginConfig.DaemonPluginType) -> String? { guard hasType(type) else { return nil } guard let instanceId else { return "\(Self.machServicePrefix)\(type.rawValue).\(name)" } return "\(Self.machServicePrefix)\(type.rawValue).\(name).\(instanceId)" } public func hasType(_ type: PluginConfig.DaemonPluginType) -> Bool { guard let config = self.config.servicesConfig else { return false } guard !(config.services.filter { $0.type == type }.isEmpty) else { return false } return true } } extension Plugin { public func exec(args: [String]) throws { var args = args let executable = self.binaryURL.path args[0] = executable let argv = args.map { strdup($0) } + [nil] guard execvp(executable, argv) != -1 else { throw POSIXError.fromErrno() } fatalError("unreachable") } func helpText(padding: Int) -> String { guard !self.name.isEmpty else { return "" } let namePadded = name.padding(toLength: padding, withPad: " ", startingAt: 0) return " " + namePadded + self.config.abstract } } ================================================ FILE: Sources/ContainerPlugin/PluginConfig.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// PluginConfig details all of the fields to describe and register a plugin. /// A plugin is registered by creating a subdirectory `/user-plugins`, /// where the name of the subdirectory is the name of the plugin, and then placing a /// file named `config.json` inside with the schema below. /// If `services` is filled in then there MUST be a binary named matching the plugin name /// in a `bin` subdirectory inside the same directory as the `config.json`. /// An example of a valid plugin directory structure would be /// $ tree foobar /// foobar /// ├── bin /// │ └── foobar /// └── config.json public struct PluginConfig: Sendable, Codable { /// Categories of services that can be offered through plugins. public enum DaemonPluginType: String, Sendable, Codable { /// A runtime plugin provides an XPC API through which the lifecycle /// of a **single** container can be managed. /// A runtime daemon plugin would typically also have a counterpart /// CLI plugin which knows how to talk to the API exposed by the runtime plugin. /// The API server ensures that a single instance of the plugin is configured /// for a given container such that the client can communicate with it given an instance id. case runtime /// A network plugin provides an XPC API through which IP address allocations on a given /// network can be managed. The API server ensures that a single instance /// of this plugin is configured for a given network. Similar to the runtime plugin, it typically /// would be accompanied by a CLI plugin that knows how to communicate with the XPC API /// given an instance id. case network /// A core plugin provides an XPC API to manage a given type of resource. /// The API server ensures that there exist only a single running instance /// of this plugin type. A core plugin can be thought of a singleton whose lifecycle /// is tied to that of the API server. Core plugins can be used to expand the base functionality /// provided by the API server. As with the other plugin types, it maybe associated with a client /// side plugin that communicates with the XPC service exposed by the daemon plugin. case core /// Reserved for future use. Currently there is no difference between a core and auxiliary daemon plugin. case auxiliary } // An XPC service that the plugin publishes. public struct Service: Sendable, Codable { /// The type of the service the daemon is exposing. /// One plugin can expose multiple services of different types. /// /// The plugin MUST expose a MachService at /// `com.apple.container.{type}.{name}.[{id}]` for /// each service that it exposes. public let type: DaemonPluginType /// Optional description of this service. public let description: String? } /// Descriptor for the services that the plugin offers. public struct ServicesConfig: Sendable, Codable { /// Load the plugin into launchd when the API server starts. public let loadAtBoot: Bool /// Launch the plugin binary as soon as it loads into launchd. public let runAtLoad: Bool /// The service types that the plugin provides. public let services: [Service] /// An optional parameter that include any command line arguments /// that must be passed to the plugin binary when it is loaded. public let defaultArguments: [String] } /// Short description of the plugin surface. This will be displayed as the /// help-text for CLI plugins, and will be returned in API calls to view loaded /// plugins from the daemon. public let abstract: String /// Author of the plugin. This is solely metadata. public let author: String? /// Services configuration. Specify nil for a CLI plugin, and an empty array for /// that does not publish any XPC services. public let servicesConfig: ServicesConfig? } extension PluginConfig { public var isCLI: Bool { self.servicesConfig == nil } } extension PluginConfig { public init?(configURL: URL) throws { let fm = FileManager.default if !fm.fileExists(atPath: configURL.path) { return nil } guard let data = fm.contents(atPath: configURL.path) else { return nil } let decoder: JSONDecoder = JSONDecoder() self = try decoder.decode(PluginConfig.self, from: data) } } ================================================ FILE: Sources/ContainerPlugin/PluginFactory.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 private let configFilename: String = "config.json" /// Describes the configuration and binary file locations for a plugin. public protocol PluginFactory: Sendable { /// Create a plugin from the plugin path, if it conforms to the layout. func create(installURL: URL) throws -> Plugin? /// Create a plugin from the plugin parent path and name, if it conforms to the layout. func create(parentURL: URL, name: String) throws -> Plugin? } /// Default layout which uses a Unix-like structure. public struct DefaultPluginFactory: PluginFactory { public init() {} public func create(installURL: URL) throws -> Plugin? { let fm = FileManager.default let configURL = installURL.appending(path: configFilename) guard fm.fileExists(atPath: configURL.path) else { return nil } guard let config = try PluginConfig(configURL: configURL) else { return nil } let name = installURL.lastPathComponent let binaryURL = installURL.appending(path: "bin").appending(path: name) guard fm.fileExists(atPath: binaryURL.path) else { return nil } return Plugin(binaryURL: binaryURL, config: config) } public func create(parentURL: URL, name: String) throws -> Plugin? { try create(installURL: parentURL.appendingPathComponent(name)) } } /// Layout which uses a macOS application bundle structure. public struct AppBundlePluginFactory: PluginFactory { private static let appSuffix = ".app" public init() {} public func create(installURL: URL) throws -> Plugin? { let fm = FileManager.default let configURL = installURL .appending(path: "Contents") .appending(path: "Resources") .appending(path: configFilename) guard fm.fileExists(atPath: configURL.path) else { return nil } guard let config = try PluginConfig(configURL: configURL) else { return nil } let appName = installURL.lastPathComponent guard appName.hasSuffix(Self.appSuffix) else { return nil } let name = String(appName.dropLast(Self.appSuffix.count)) let binaryURL = installURL .appending(path: "Contents") .appending(path: "MacOS") .appending(path: name) guard fm.fileExists(atPath: binaryURL.path) else { return nil } return Plugin(binaryURL: binaryURL, config: config) } public func create(parentURL: URL, name: String) throws -> Plugin? { try create(installURL: parentURL.appendingPathComponent("\(name)\(Self.appSuffix)")) } } ================================================ FILE: Sources/ContainerPlugin/PluginLoader.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationOS import Foundation import Logging import SystemPackage public struct PluginLoader: Sendable { private let appRoot: URL private let installRoot: URL private let logRoot: FilePath? private let pluginDirectories: [URL] private let pluginFactories: [PluginFactory] private let log: Logger? public typealias PluginQualifier = ((Plugin) -> Bool) // A path on disk managed by the PluginLoader, where it stores // runtime data for loaded plugins. This includes the launchd plists // and logs files. private let pluginResourceRoot: URL public init( appRoot: URL, installRoot: URL, logRoot: FilePath?, pluginDirectories: [URL], pluginFactories: [PluginFactory], log: Logger? = nil ) throws { let pluginResourceRoot = appRoot.appendingPathComponent("plugin-state") try FileManager.default.createDirectory(at: pluginResourceRoot, withIntermediateDirectories: true) self.pluginResourceRoot = pluginResourceRoot self.appRoot = appRoot self.installRoot = installRoot self.logRoot = logRoot self.pluginDirectories = pluginDirectories self.pluginFactories = pluginFactories self.log = log } static public func userPluginsDir(installRoot: URL) -> URL { installRoot .appending(path: "libexec") .appending(path: "container-plugins") .resolvingSymlinksInPath() } } extension PluginLoader { public func alterCLIHelpText(original: String) -> String { var plugins = findPlugins() plugins = plugins.filter { $0.config.isCLI } guard !plugins.isEmpty else { return original } var lines = original.split(separator: "\n").map { String($0) } let sectionHeader = "PLUGINS:" lines.append(sectionHeader) for plugin in plugins { let helpText = plugin.helpText(padding: 24) lines.append(helpText) } return lines.joined(separator: "\n") } /// Scan all plugin directories and detect plugins. public func findPlugins() -> [Plugin] { let fm = FileManager.default // Maintain a set for tracking shadowed plugins var pluginNames = Set() var plugins: [Plugin] = [] for pluginDir in pluginDirectories { // Skip nonexistent plugin parent directories if !fm.fileExists(atPath: pluginDir.path) { continue } // Get all entries under the parent directory let resolvedPluginDir = pluginDir.resolvingSymlinksInPath() guard let urls = try? fm.contentsOfDirectory( at: resolvedPluginDir, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], options: .skipsHiddenFiles ) else { continue } // Filter out all but plugin installation directories let installURLs = urls.filter { url in if url.isDirectory { return true } if url.isSymlink { var isDirectory: ObjCBool = false _ = fm.fileExists(atPath: url.resolvingSymlinksInPath().path(percentEncoded: false), isDirectory: &isDirectory) return isDirectory.boolValue } return false } for installURL in installURLs { do { // Create a plugin with the first factory that can grok the layout under the install URL guard let plugin = try (pluginFactories.compactMap { try $0.create(installURL: installURL) }.first) else { log?.warning( "not installing plugin with missing configuration", metadata: [ "path": "\(installURL.path)" ] ) continue } // Warn and skip if this plugin name has been encountered already guard !pluginNames.contains(plugin.name) else { log?.warning( "not installing shadowed plugin", metadata: [ "path": "\(installURL.path)", "name": "\(plugin.name)", ]) continue } // Add the plugin to the list plugins.append(plugin) pluginNames.insert(plugin.name) } catch { log?.warning( "not installing plugin with invalid configuration", metadata: [ "path": "\(installURL.path)", "error": "\(error)", ] ) } } } return plugins } /// Locate a plugin with a specific name. public func findPlugin(name: String, log: Logger? = nil) -> Plugin? { do { for pluginDirectory in pluginDirectories { for PluginFactory in pluginFactories { // throw means that the factory is correct but the plugin is broken if let plugin = try PluginFactory.create(parentURL: pluginDirectory, name: name) { return plugin } } } } catch { log?.warning( "not installing plugin with invalid configuration", metadata: [ "name": "\(name)", "error": "\(error)", ] ) } return nil } } extension PluginLoader { public static let proxyKeys = Set([ "http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "no_proxy", "NO_PROXY", ]) public func registerWithLaunchd( plugin: Plugin, pluginStateRoot: URL? = nil, args: [String]? = nil, instanceId: String? = nil, debug: Bool = false, ) throws { // We only care about loading plugins that have a service // to expose; otherwise, they may just be CLI commands. guard let serviceConfig = plugin.config.servicesConfig else { return } let id = plugin.getLaunchdLabel(instanceId: instanceId) log?.info("Registering plugin", metadata: ["id": "\(id)"]) let rootURL = pluginStateRoot ?? self.pluginResourceRoot.appending(path: plugin.name) try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) var env = Self.filterEnvironment() env[ApplicationRoot.environmentName] = appRoot.path(percentEncoded: false) env[InstallRoot.environmentName] = installRoot.path(percentEncoded: false) if let logRoot { env[LogRoot.environmentName] = logRoot.isAbsolute ? logRoot.string : FilePath(FileManager.default.currentDirectoryPath).appending(logRoot.components).string } let processedArgs = (args ?? ["start"]) + (debug ? ["--debug"] : []) let plist = LaunchPlist( label: id, arguments: [plugin.binaryURL.path] + processedArgs + serviceConfig.defaultArguments, environment: env, limitLoadToSessionType: [.Aqua, .Background, .System], runAtLoad: serviceConfig.runAtLoad, machServices: plugin.getMachServices(instanceId: instanceId) ) let plistUrl = rootURL.appendingPathComponent("service.plist") let data = try plist.encode() try data.write(to: plistUrl) try ServiceManager.register(plistPath: plistUrl.path) } 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. guard plugin.config.servicesConfig != nil else { return } let domain = try ServiceManager.getDomainString() let label = "\(domain)/\(plugin.getLaunchdLabel(instanceId: instanceId))" log?.info("Deregistering plugin", metadata: ["id": "\(plugin.getLaunchdLabel())"]) try ServiceManager.deregister(fullServiceLabel: label) } public static func filterEnvironment( env: [String: String] = ProcessInfo.processInfo.environment, additionalAllowKeys: Set = Self.proxyKeys ) -> [String: String] { env.filter { key, _ in key.hasPrefix("CONTAINER_") || additionalAllowKeys.contains(key) } } } ================================================ FILE: Sources/ContainerPlugin/ServiceManager.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import Foundation public struct ServiceManager { private static func runLaunchctlCommand(args: [String]) throws -> Int32 { let launchctl = Foundation.Process() launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl") launchctl.arguments = args let null = FileHandle.nullDevice launchctl.standardOutput = null launchctl.standardError = null try launchctl.run() launchctl.waitUntilExit() return launchctl.terminationStatus } /// Register a service by providing the path to a plist. public static func register(plistPath: String) throws { let domain = try Self.getDomainString() _ = try runLaunchctlCommand(args: ["bootstrap", domain, plistPath]) } /// Deregister a service by a launchd label. public static func deregister(fullServiceLabel label: String) throws { _ = try runLaunchctlCommand(args: ["bootout", label]) } /// Deregister a service and pass return status public static func deregister(fullServiceLabel label: String, status: inout Int32) throws { status = try runLaunchctlCommand(args: ["bootout", label]) } /// Restart a service by a launchd label. public static func kickstart(fullServiceLabel label: String) throws { _ = try runLaunchctlCommand(args: ["kickstart", "-k", label]) } /// Send a signal to a service by a launchd label. public static func kill(fullServiceLabel label: String, signal: Int32 = 15) throws { _ = try runLaunchctlCommand(args: ["kill", "\(signal)", label]) } /// Retrieve labels for all loaded launch units. public static func enumerate() throws -> [String] { let launchctl = Foundation.Process() launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl") launchctl.arguments = ["list"] let stdoutPipe = Pipe() let stderrPipe = Pipe() launchctl.standardOutput = stdoutPipe 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 { 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 { 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 return outputText.split { $0.isNewline } .map { String($0).split { $0.isWhitespace } } .filter { $0.count >= 3 } .map { String($0[2]) } } /// Check if a service has been registered or not. public static func isRegistered(fullServiceLabel label: String) throws -> Bool { let exitStatus = try runLaunchctlCommand(args: ["list", label]) return exitStatus == 0 } private static func getLaunchdSessionType() throws -> String { let launchctl = Foundation.Process() launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl") launchctl.arguments = ["managername"] let null = FileHandle.nullDevice let stdoutPipe = Pipe() launchctl.standardOutput = stdoutPipe launchctl.standardError = null try launchctl.run() let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() launchctl.waitUntilExit() let status = launchctl.terminationStatus guard status == 0 else { throw ContainerizationError(.internalError, message: "command `launchctl managername` failed with status \(status)") } guard let outputText = String(data: outputData, encoding: .utf8) else { throw ContainerizationError(.internalError, message: "could not decode output of command `launchctl managername`") } return outputText.trimmingCharacters(in: .whitespacesAndNewlines) } public static func getDomainString() throws -> String { let currentSessionType = try getLaunchdSessionType() switch currentSessionType { case LaunchPlist.Domain.System.rawValue: return LaunchPlist.Domain.System.rawValue.lowercased() case LaunchPlist.Domain.Background.rawValue: return "user/\(getuid())" case LaunchPlist.Domain.Aqua.rawValue: return "gui/\(getuid())" default: throw ContainerizationError(.internalError, message: "unsupported session type \(currentSessionType)") } } } ================================================ FILE: Sources/ContainerResource/Common/ManagedResource.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Common properties for all managed resources. public protocol ManagedResource: Identifiable, Sendable, Codable { /// A 64 byte hexadecimal string, assigned by the system, that uniquely /// identifies the resource. var id: String { get } /// A user assigned name that shall be unique within the namespace of /// the resource category. If the user does not assign a name, this value /// shall be the same as the system-assigned identifier. var name: String { get } /// The time at which the system created the resource. var creationDate: Date { get } /// Key-value properties for the resource. The user and system may both /// make use of labels to read and write annotations or other metadata. /// A good practice is to use var labels: [String: String] { get } /// Generates a unique resource ID value. static func generateId() -> String /// Returns true only if the specified resource name is syntactically valid. static func nameValid(_ name: String) -> Bool } extension ManagedResource { /// Generate a random identifier that has the format of an ASCII SHA-256 hash. public static func generateId() -> String { (0..<2) .map { _ in UInt128.random(in: 0...UInt128.max) } .map { String($0, radix: 16).padding(toLength: 32, withPad: "0", startingAt: 0) } .joined() } } // FIXME: This moves to ManagedResource and/or a ResourceLabels typealias eventually. extension [String: String] { public var isBuiltin: Bool { self.contains { $0 == ResourceLabelKeys.role && $1 == ResourceRoleValues.builtin } } } ================================================ FILE: Sources/ContainerResource/Common/ResourceLabels.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// /// System-defined keys for resource labels. public struct ResourceLabelKeys { /// Indicates a owner of a resource managed by a plugin. public static let plugin = "com.apple.container.plugin" /// Indicates a resource with a reserved or dedicated purpose. public static let role = "com.apple.container.resource.role" } /// System-defined values for resource the resource role label. public struct ResourceRoleValues { /// Indicates a container that can build images. public static let builder = "builder" /// Indicates a system-created resource that cannot be deleted by the user. public static let builtin = "builtin" } ================================================ FILE: Sources/ContainerResource/Container/Bundle.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Containerization import ContainerizationError import Foundation public struct Bundle: Sendable { private static let initfsFilename = "initfs.ext4" private static let kernelFilename = "kernel.json" private static let kernelBinaryFilename = "kernel.bin" private static let containerRootFsBlockFilename = "rootfs.ext4" private static let containerRootFsFilename = "rootfs.json" static let containerConfigFilename = "config.json" /// The path to the bundle. public let path: URL public init(path: URL) { self.path = path } public var bootlog: URL { self.path.appendingPathComponent("vminitd.log") } public var containerRootfsBlock: URL { self.path.appendingPathComponent(Self.containerRootFsBlockFilename) } private var containerRootfsConfig: URL { self.path.appendingPathComponent(Self.containerRootFsFilename) } public var containerRootfs: Filesystem { get throws { let data = try Data(contentsOf: containerRootfsConfig) let fs = try JSONDecoder().decode(Filesystem.self, from: data) return fs } } /// Return the initial filesystem for a sandbox. public var initialFilesystem: Filesystem { .block( format: "ext4", source: self.path.appendingPathComponent(Self.initfsFilename).path, destination: "/", options: ["ro"] ) } public var kernel: Kernel { get throws { try load(path: self.path.appendingPathComponent(Self.kernelFilename)) } } public var configuration: ContainerConfiguration { get throws { try load(path: self.path.appendingPathComponent(Self.containerConfigFilename)) } } } extension Bundle { public static func create( path: URL, initialFilesystem: Filesystem, kernel: Kernel, containerConfiguration: ContainerConfiguration? = nil, containerRootFilesystem: Filesystem? = nil, options: ContainerCreateOptions? = nil ) throws -> Bundle { try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) let kbin = path.appendingPathComponent(Self.kernelBinaryFilename) try FileManager.default.copyItem(at: kernel.path, to: kbin) var k = kernel k.path = kbin try write(path.appendingPathComponent(Self.kernelFilename), value: k) switch initialFilesystem.type { case .block(let fmt, _, _): guard fmt == "ext4" else { fatalError("ext4 is the only supported format for initial filesystem") } // when saving the Initial Filesystem to the bundle // discard any filesystem information and just persist // the block into the Bundle. _ = try initialFilesystem.clone(to: path.appendingPathComponent(Self.initfsFilename).path) default: fatalError("invalid filesystem type for initial filesystem") } let bundle = Bundle(path: path) if let containerConfiguration { try bundle.write(filename: Self.containerConfigFilename, value: containerConfiguration) } if let rootFsOverride = options?.rootFsOverride { try bundle.setContainerRootFs(fs: rootFsOverride) } else if let containerRootFilesystem { let readonly = containerConfiguration?.readOnly ?? false try bundle.cloneContainerRootFs(cloning: containerRootFilesystem, readonly: readonly) } if let options { try bundle.write(filename: "options.json", value: options) } return bundle } } extension Bundle { /// Set the value of the configuration for the Bundle. public func set(configuration: ContainerConfiguration) throws { try write(filename: Self.containerConfigFilename, value: configuration) } /// Return the full filepath for a named resource in the Bundle. public func filePath(for name: String) -> URL { path.appendingPathComponent(name) } public func setContainerRootFs(fs: Filesystem) throws { let fsData = try JSONEncoder().encode(fs) try fsData.write(to: self.containerRootfsConfig) } public func cloneContainerRootFs(cloning fs: Filesystem, readonly: Bool = false) throws { var mutableFs = fs if readonly && !mutableFs.options.contains("ro") { mutableFs.options.append("ro") } let cloned = try mutableFs.clone(to: self.containerRootfsBlock.absolutePath()) try setContainerRootFs(fs: cloned) } /// Delete the bundle and all of the resources contained inside. public func delete() throws { try FileManager.default.removeItem(at: self.path) } public func write(filename: String, value: Encodable) throws { try Self.write(self.path.appendingPathComponent(filename), value: value) } private static func write(_ path: URL, value: Encodable) throws { let data = try JSONEncoder().encode(value) try data.write(to: path) } public func load(filename: String) throws -> T where T: Decodable { try load(path: self.path.appendingPathComponent(filename)) } private func load(path: URL) throws -> T where T: Decodable { let data = try Data(contentsOf: path) return try JSONDecoder().decode(T.self, from: data) } } ================================================ FILE: Sources/ContainerResource/Container/ContainerConfiguration.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationOCI public struct ContainerConfiguration: Sendable, Codable { /// Identifier for the container. public var id: String /// Image used to create the container. public var image: ImageDescription /// External mounts to add to the container. public var mounts: [Filesystem] = [] /// Ports to publish from container to host. public var publishedPorts: [PublishPort] = [] /// 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. public var sysctls: [String: String] = [:] /// The networks the container will be added to. public var networks: [AttachmentConfiguration] = [] /// The DNS configuration for the container. public var dns: DNSConfiguration? = nil /// Whether to enable rosetta x86-64 translation for the container. public var rosetta: Bool = false /// Initial or main process of the container. public var initProcess: ProcessConfiguration /// Platform for the container. public var platform: ContainerizationOCI.Platform = .current /// Resource values for the container. public var resources: Resources = .init() /// Name of the runtime that supports the container. public var runtimeHandler: String = "container-runtime-linux" /// Configure exposing virtualization support in the container. public var virtualization: Bool = false /// Enable SSH agent socket forwarding from host to container. public var ssh: Bool = false /// Whether to mount the rootfs as read-only. public var readOnly: Bool = false /// Whether to use a minimal init process inside the container. public var useInit: Bool = false enum CodingKeys: String, CodingKey { case id case image case mounts case publishedPorts case publishedSockets case labels case sysctls case networks case dns case rosetta case initProcess case platform case resources case runtimeHandler case virtualization case ssh case readOnly case useInit } /// 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) ?? [] publishedPorts = try container.decodeIfPresent([PublishPort].self, forKey: .publishedPorts) ?? [] 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) ?? [:] if container.contains(.networks) { networks = try container.decode([AttachmentConfiguration].self, forKey: .networks) } else { networks = [] } dns = try container.decodeIfPresent(DNSConfiguration.self, forKey: .dns) rosetta = try container.decodeIfPresent(Bool.self, forKey: .rosetta) ?? false 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" virtualization = try container.decodeIfPresent(Bool.self, forKey: .virtualization) ?? false ssh = try container.decodeIfPresent(Bool.self, forKey: .ssh) ?? false readOnly = try container.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false useInit = try container.decodeIfPresent(Bool.self, forKey: .useInit) ?? false } public struct DNSConfiguration: Sendable, Codable { public static let defaultNameservers = ["1.1.1.1"] public let nameservers: [String] public let domain: String? public let searchDomains: [String] public let options: [String] public init( nameservers: [String] = defaultNameservers, domain: String? = nil, searchDomains: [String] = [], options: [String] = [] ) { self.nameservers = nameservers self.domain = domain self.searchDomains = searchDomains self.options = options } } /// Resources like cpu, memory, and storage quota. public struct Resources: Sendable, Codable { /// Number of CPU cores allocated. public var cpus: Int = 4 /// Memory in bytes allocated. public var memoryInBytes: UInt64 = 1024.mib() /// Storage quota/size in bytes. public var storage: UInt64? public init() {} } public init( id: String, image: ImageDescription, process: ProcessConfiguration ) { self.id = id self.image = image self.initProcess = process } } ================================================ FILE: Sources/ContainerResource/Container/ContainerCreateOptions.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// public struct ContainerCreateOptions: Codable, Sendable { /// Remove the container and wipe out its data on container stop public let autoRemove: Bool /// Override the rootFs with this one other than the image-cloned version public let rootFsOverride: Filesystem? public init(autoRemove: Bool, rootFsOverride: Filesystem? = nil) { self.autoRemove = autoRemove self.rootFsOverride = rootFsOverride } public static let `default` = ContainerCreateOptions(autoRemove: false) } ================================================ FILE: Sources/ContainerResource/Container/ContainerListFilters.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Filters for listing containers. public struct ContainerListFilters: Sendable, Codable { /// Filter by container IDs. If non-empty, only containers with matching IDs are returned. public var ids: [String] /// Filter by container status. public var status: RuntimeStatus? /// Filter by labels. All specified labels must match. public var labels: [String: String] /// No filters applied. Will return all containers. public static let all = ContainerListFilters() public init( ids: [String] = [], status: RuntimeStatus? = nil, labels: [String: String] = [:] ) { self.ids = ids self.status = status self.labels = labels } } ================================================ FILE: Sources/ContainerResource/Container/ContainerSnapshot.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationOCI import Foundation /// A snapshot of a container along with its configuration /// and any runtime state information. public struct ContainerSnapshot: Codable, Sendable { /// The configuration of the container. public var configuration: ContainerConfiguration /// Identifier of the container. public var id: String { configuration.id } /// Configured platform for the container. public var platform: ContainerizationOCI.Platform { configuration.platform } /// The runtime status of the container. public var status: RuntimeStatus /// Network interfaces attached to the sandbox that are provided to the container. public var networks: [Attachment] /// When the container was started. public var startedDate: Date? public init( configuration: ContainerConfiguration, status: RuntimeStatus, networks: [Attachment], startedDate: Date? = nil ) { self.configuration = configuration self.status = status self.networks = networks self.startedDate = startedDate } } ================================================ FILE: Sources/ContainerResource/Container/ContainerStats.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Statistics for a container suitable for CLI display. public struct ContainerStats: Sendable, Codable { /// Container ID public var id: String /// Physical memory usage in bytes public var memoryUsageBytes: UInt64? /// Memory limit in bytes public var memoryLimitBytes: UInt64? /// CPU usage in microseconds public var cpuUsageUsec: UInt64? /// Network received bytes (sum of all interfaces) public var networkRxBytes: UInt64? /// Network transmitted bytes (sum of all interfaces) public var networkTxBytes: UInt64? /// Block I/O read bytes (sum of all devices) public var blockReadBytes: UInt64? /// Block I/O write bytes (sum of all devices) public var blockWriteBytes: UInt64? /// Number of processes in the container public var numProcesses: UInt64? public init( id: String, memoryUsageBytes: UInt64?, memoryLimitBytes: UInt64?, cpuUsageUsec: UInt64?, networkRxBytes: UInt64?, networkTxBytes: UInt64?, blockReadBytes: UInt64?, blockWriteBytes: UInt64?, numProcesses: UInt64? ) { self.id = id self.memoryUsageBytes = memoryUsageBytes self.memoryLimitBytes = memoryLimitBytes self.cpuUsageUsec = cpuUsageUsec self.networkRxBytes = networkRxBytes self.networkTxBytes = networkTxBytes self.blockReadBytes = blockReadBytes self.blockWriteBytes = blockWriteBytes self.numProcesses = numProcesses } } ================================================ FILE: Sources/ContainerResource/Container/ContainerStopOptions.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 public struct ContainerStopOptions: Sendable, Codable { public let timeoutInSeconds: Int32 public let signal: Int32 public static let `default` = ContainerStopOptions( timeoutInSeconds: 5, signal: SIGTERM ) public init(timeoutInSeconds: Int32, signal: Int32) { self.timeoutInSeconds = timeoutInSeconds self.signal = signal } } ================================================ FILE: Sources/ContainerResource/Container/Filesystem.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Options to pass to a mount call. public typealias MountOptions = [String] extension MountOptions { /// Returns true if the Filesystem should be consumed as read-only. public var readonly: Bool { self.contains("ro") } } /// A host filesystem that will be attached to the sandbox for use. /// /// A filesystem will be mounted automatically when starting the sandbox /// or container. public struct Filesystem: Sendable, Codable { /// Type of caching to perform at the host level. public enum CacheMode: Sendable, Codable { case on case off case auto } /// Sync mode to perform at the host level. public enum SyncMode: Sendable, Codable { case full case fsync case nosync } /// The type of filesystem attachment for the sandbox. public enum FSType: Sendable, Codable, Equatable { package enum VirtiofsType: String, Sendable, Codable, Equatable { // This is a virtiofs share for the rootfs of a sandbox. case rootfs // Data share. This is what all virtiofs shares for anything besides // the rootfs for a sandbox will be. case data } case block(format: String, cache: CacheMode, sync: SyncMode) case volume(name: String, format: String, cache: CacheMode, sync: SyncMode) case virtiofs case tmpfs } /// Type of the filesystem. public var type: FSType /// Source of the filesystem. public var source: String /// Destination where the filesystem should be mounted. public var destination: String /// Mount options applied when mounting the filesystem. public var options: MountOptions public init() { self.type = .tmpfs self.source = "" self.destination = "" self.options = [] } public init(type: FSType, source: String, destination: String, options: MountOptions) { self.type = type self.source = source self.destination = destination self.options = options } // Defaulting to CachedMode = .on (i.e., cached mode) to fix Linux FS issue when using Virtualization // * https://github.com/apple/container/issues/614 // * https://github.com/utmapp/UTM/pull/5919 /// A block based filesystem. public static func block( format: String, source: String, destination: String, options: MountOptions, cache: CacheMode = .on, sync: SyncMode = .fsync ) -> Filesystem { .init( type: .block(format: format, cache: cache, sync: sync), source: URL(fileURLWithPath: source).absolutePath(), destination: destination, options: options ) } /// A named volume filesystem. public static func volume( name: String, format: String, source: String, destination: String, options: MountOptions, cache: CacheMode = .on, sync: SyncMode = .fsync ) -> Filesystem { .init( type: .volume(name: name, format: format, cache: cache, sync: sync), source: URL(fileURLWithPath: source).absolutePath(), destination: destination, options: options ) } /// A vritiofs backed filesystem providing a directory. public static func virtiofs(source: String, destination: String, options: MountOptions) -> Filesystem { .init( type: .virtiofs, source: URL(fileURLWithPath: source).absolutePath(), destination: destination, options: options ) } public static func tmpfs(destination: String, options: MountOptions) -> Filesystem { .init( type: .tmpfs, source: "tmpfs", destination: destination, options: options ) } /// Returns true if the Filesystem is backed by a block device. public var isBlock: Bool { switch type { case .block(_, _, _): true case .volume(_, _, _, _): true default: false } } /// Returns true if the Filesystem is a named volume. public var isVolume: Bool { switch type { case .volume(_, _, _, _): true default: false } } /// Returns the volume name if this is a volume filesystem, nil otherwise. public var volumeName: String? { switch type { case .volume(let name, _, _, _): name default: nil } } /// Returns true if the Filesystem is backed by a in-memory mount type. public var isTmpfs: Bool { switch type { case .tmpfs: true default: false } } /// Returns true if the Filesystem is backed by virtioFS. public var isVirtiofs: Bool { switch type { case .virtiofs: true default: false } } /// Clone the Filesystem to the provided path. /// /// This uses `clonefile` to provide a copy-on-write copy of the Filesystem. public func clone(to: String) throws -> Self { let fm = FileManager.default let src = self.source try fm.copyItem(atPath: src, toPath: to) return .init(type: self.type, source: to, destination: self.destination, options: self.options) } } ================================================ FILE: Sources/ContainerResource/Container/ProcessConfiguration.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// /// Configuration data for an executable Process. public struct ProcessConfiguration: Sendable, Codable { /// The on disk path to the executable binary. public var executable: String /// Arguments passed to the Process. public var arguments: [String] /// Environment variables for the Process. public var environment: [String] /// The current working directory (cwd) for the Process. public var workingDirectory: String /// A boolean value indicating if a Terminal or PTY device should /// be attached to the Process's Standard I/O. public var terminal: Bool /// The User a Process should execute under. public var user: User /// Supplemental groups for the Process. public var supplementalGroups: [UInt32] /// Rlimits for the Process. public var rlimits: [Rlimit] /// Rlimits for Processes. public struct Rlimit: Sendable, Codable { /// The Rlimit type of the Process. /// /// Values include standard Rlimit resource types, i.e. RLIMIT_NPROC, RLIMIT_NOFILE, ... public let limit: String /// The soft limit of the Process public let soft: UInt64 /// The hard or max limit of the Process. public let hard: UInt64 public init(limit: String, soft: UInt64, hard: UInt64) { self.limit = limit self.soft = soft self.hard = hard } } /// The User information for a Process. public enum User: Sendable, Codable, CustomStringConvertible { /// Given the raw user string of the form or or lookup the uid/gid within /// the container before setting it for the Process. case raw(userString: String) /// Set the provided uid/gid for the Process. case id(uid: UInt32, gid: UInt32) public var description: String { switch self { case .id(let uid, let gid): return "\(uid):\(gid)" case .raw(let name): return name } } } public init( executable: String, arguments: [String], environment: [String], workingDirectory: String = "/", terminal: Bool = false, user: User = .id(uid: 0, gid: 0), supplementalGroups: [UInt32] = [], rlimits: [Rlimit] = [] ) { self.executable = executable self.arguments = arguments self.environment = environment self.workingDirectory = workingDirectory self.terminal = terminal self.user = user self.supplementalGroups = supplementalGroups self.rlimits = rlimits } } ================================================ FILE: Sources/ContainerResource/Container/PublishPort.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationExtras /// The network protocols available for port forwarding. public enum PublishProtocol: String, Sendable, Codable { case tcp = "tcp" case udp = "udp" /// Initialize a protocol with to default value, `.tcp`. public init() { self = .tcp } /// Initialize a protocol value from the provided string. public init?(_ value: String) { switch value.lowercased() { case "tcp": self = .tcp case "udp": self = .udp default: return nil } } } /// Specifies internet port forwarding from host to container. public struct PublishPort: Sendable, Codable { /// The IP address of the proxy listener on the host public let hostAddress: IPAddress /// The port number of the proxy listener on the host public let hostPort: UInt16 /// The port number of the container listener public let containerPort: UInt16 /// The network protocol for the proxy public let proto: PublishProtocol /// The number of ports to publish public let count: UInt16 /// Creates a new port forwarding specification. public init(hostAddress: IPAddress, hostPort: UInt16, containerPort: UInt16, proto: PublishProtocol, count: UInt16) { self.hostAddress = hostAddress self.hostPort = hostPort self.containerPort = containerPort self.proto = proto self.count = count } /// 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) hostAddress = try container.decode(IPAddress.self, forKey: .hostAddress) hostPort = try container.decode(UInt16.self, forKey: .hostPort) containerPort = try container.decode(UInt16.self, forKey: .containerPort) proto = try container.decode(PublishProtocol.self, forKey: .proto) count = try container.decodeIfPresent(UInt16.self, forKey: .count) ?? 1 } } extension [PublishPort] { public func hasOverlaps() -> Bool { var hostPorts = Set() for publishPort in self { for index in publishPort.hostPort..<(publishPort.hostPort + publishPort.count) { let hostPortKey = "\(index)/\(publishPort.proto.rawValue)" guard !hostPorts.contains(hostPortKey) else { return true } hostPorts.insert(hostPortKey) } } return false } } ================================================ FILE: Sources/ContainerResource/Container/PublishSocket.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 } } ================================================ FILE: Sources/ContainerResource/Container/RuntimeStatus.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Runtime status for a sandbox or container. public enum RuntimeStatus: String, CaseIterable, Sendable, Codable { /// The object is in an unknown status. case unknown /// The object is currently stopped. case stopped /// The object is currently running. case running /// The object is currently stopping. case stopping } ================================================ FILE: Sources/ContainerResource/Image/ImageDescription.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import ContainerizationOCI /// A type that represents an OCI image that can be used with sandboxes or containers. public struct ImageDescription: Sendable, Codable { /// The public reference/name of the image. public let reference: String /// The descriptor of the image. public let descriptor: Descriptor public var digest: String { descriptor.digest } public var mediaType: String { descriptor.mediaType } public init(reference: String, descriptor: Descriptor) { self.reference = reference self.descriptor = descriptor } } ================================================ FILE: Sources/ContainerResource/Image/ImageDetail.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Containerization import ContainerizationOCI public struct ImageDetail: Codable { public let name: String public let index: Descriptor public let variants: [Variants] public struct Variants: Codable { public let platform: Platform public let config: ContainerizationOCI.Image public let size: Int64 public init(platform: Platform, size: Int64, config: ContainerizationOCI.Image) { self.platform = platform self.config = config self.size = size } } public init(name: String, index: Descriptor, variants: [Variants]) { self.name = name self.index = index self.variants = variants } } ================================================ FILE: Sources/ContainerResource/Network/AllocatedAttachment.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerXPC /// AllocatedAttachment represents a network attachment that has been allocated for use /// by a container and any additional relevant data needed for a sandbox to properly /// configure networking on container bootstrap. public struct AllocatedAttachment: Sendable { public let attachment: Attachment public let additionalData: XPCMessage? public let pluginInfo: NetworkPluginInfo public init(attachment: Attachment, additionalData: XPCMessage?, pluginInfo: NetworkPluginInfo) { self.attachment = attachment self.additionalData = additionalData self.pluginInfo = pluginInfo } } ================================================ FILE: Sources/ContainerResource/Network/Attachment.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationExtras /// A snapshot of a network interface for a sandbox. public struct Attachment: Codable, Sendable { /// The network ID associated with the attachment. public let network: String /// The hostname associated with the attachment. public let hostname: String /// The CIDR address describing the interface IPv4 address, with the prefix length of the subnet. public let ipv4Address: CIDRv4 /// The IPv4 gateway address. public let ipv4Gateway: IPv4Address /// The CIDR address describing the interface IPv6 address, with the prefix length of the subnet. /// The address is nil if the IPv6 subnet could not be determined at network creation time. public let ipv6Address: CIDRv6? /// The MAC address associated with the attachment (optional). public let macAddress: MACAddress? /// The MTU for the network interface. public let mtu: UInt32? public init( network: String, hostname: String, ipv4Address: CIDRv4, ipv4Gateway: IPv4Address, ipv6Address: CIDRv6?, macAddress: MACAddress?, mtu: UInt32? = nil ) { self.network = network self.hostname = hostname self.ipv4Address = ipv4Address self.ipv4Gateway = ipv4Gateway self.ipv6Address = ipv6Address self.macAddress = macAddress self.mtu = mtu } enum CodingKeys: String, CodingKey { case network case hostname case ipv4Address case ipv4Gateway case ipv6Address case macAddress case mtu // TODO: retain for deserialization compatibility for now, remove later case address case gateway } /// 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) network = try container.decode(String.self, forKey: .network) hostname = try container.decode(String.self, forKey: .hostname) if let address = try? container.decode(CIDRv4.self, forKey: .ipv4Address) { ipv4Address = address } else { ipv4Address = try container.decode(CIDRv4.self, forKey: .address) } if let gateway = try? container.decode(IPv4Address.self, forKey: .ipv4Gateway) { ipv4Gateway = gateway } else { ipv4Gateway = try container.decode(IPv4Address.self, forKey: .gateway) } ipv6Address = try container.decodeIfPresent(CIDRv6.self, forKey: .ipv6Address) macAddress = try container.decodeIfPresent(MACAddress.self, forKey: .macAddress) mtu = try container.decodeIfPresent(UInt32.self, forKey: .mtu) } /// Encode the configuration to the supplied Encoder. public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(network, forKey: .network) try container.encode(hostname, forKey: .hostname) try container.encode(ipv4Address, forKey: .ipv4Address) try container.encode(ipv4Gateway, forKey: .ipv4Gateway) try container.encodeIfPresent(ipv6Address, forKey: .ipv6Address) try container.encodeIfPresent(macAddress, forKey: .macAddress) try container.encodeIfPresent(mtu, forKey: .mtu) } } ================================================ FILE: Sources/ContainerResource/Network/AttachmentConfiguration.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationExtras /// Configuration information for attaching a container network interface to a network. public struct AttachmentConfiguration: Codable, Sendable { /// The network ID associated with the attachment. public let network: String /// The option information for the attachment public let options: AttachmentOptions public init(network: String, options: AttachmentOptions) { self.network = network self.options = options } } // Option information for a network attachment. public struct AttachmentOptions: Codable, Sendable { /// The hostname associated with the attachment. public let hostname: String /// The MAC address associated with the attachment (optional). public let macAddress: MACAddress? /// The MTU for the network interface. public let mtu: UInt32? public init(hostname: String, macAddress: MACAddress? = nil, mtu: UInt32? = nil) { self.hostname = hostname self.macAddress = macAddress self.mtu = mtu } } ================================================ FILE: Sources/ContainerResource/Network/NetworkConfiguration.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import ContainerizationExtras import Foundation public struct NetworkPluginInfo: Codable, Sendable, Hashable { public let plugin: String public let variant: String? public init(plugin: String, variant: String? = nil) { self.plugin = plugin self.variant = variant } } /// Configuration parameters for network creation. public struct NetworkConfiguration: Codable, Sendable, Identifiable { /// A unique identifier for the network public let id: String /// The network type public let mode: NetworkMode /// When the network was created. public let creationDate: Date /// The preferred CIDR address for the IPv4 subnet, if specified public let ipv4Subnet: CIDRv4? /// The preferred CIDR address for the IPv6 subnet, if specified public let ipv6Subnet: CIDRv6? /// Key-value labels for the network. public var labels: [String: String] = [:] /// Details about the network plugin that manages this network. /// FIXME: This field only needs to be optional while we wait for the field /// to be proliferated to most users when they update container. public var pluginInfo: NetworkPluginInfo? /// Creates a network configuration public init( id: String, mode: NetworkMode, ipv4Subnet: CIDRv4? = nil, ipv6Subnet: CIDRv6? = nil, labels: [String: String] = [:], pluginInfo: NetworkPluginInfo ) throws { self.id = id self.creationDate = Date() self.mode = mode self.ipv4Subnet = ipv4Subnet self.ipv6Subnet = ipv6Subnet self.labels = labels self.pluginInfo = pluginInfo try validate() } enum CodingKeys: String, CodingKey { case id case creationDate case mode case ipv4Subnet case ipv6Subnet case labels case pluginInfo // TODO: retain for deserialization compatibility for now, remove later case subnet } /// 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) creationDate = try container.decodeIfPresent(Date.self, forKey: .creationDate) ?? Date(timeIntervalSince1970: 0) mode = try container.decode(NetworkMode.self, forKey: .mode) let subnetText = try container.decodeIfPresent(String.self, forKey: .ipv4Subnet) ?? container.decodeIfPresent(String.self, forKey: .subnet) ipv4Subnet = try subnetText.map { try CIDRv4($0) } ipv6Subnet = try container.decodeIfPresent(String.self, forKey: .ipv6Subnet) .map { try CIDRv6($0) } labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:] pluginInfo = try container.decodeIfPresent(NetworkPluginInfo.self, forKey: .pluginInfo) try validate() } /// Encode the configuration to the supplied Encoder. public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(creationDate, forKey: .creationDate) try container.encode(mode, forKey: .mode) try container.encodeIfPresent(ipv4Subnet, forKey: .ipv4Subnet) try container.encodeIfPresent(ipv6Subnet, forKey: .ipv6Subnet) try container.encode(labels, forKey: .labels) try container.encodeIfPresent(pluginInfo, forKey: .pluginInfo) } private func validate() throws { guard id.isValidNetworkID() else { throw ContainerizationError(.invalidArgument, message: "invalid network ID: \(id)") } for (key, value) in labels { try validateLabel(key: key, value: value) } } /// TODO: Extract when we clean up client dependencies. private func validateLabel(key: String, value: String) throws { let keyLengthMax = 128 let labelLengthMax = 4096 guard key.count <= keyLengthMax else { throw ContainerizationError(.invalidArgument, message: "invalid label, key length is greater than \(keyLengthMax): \(key)") } guard key.isValidLabelKey() else { throw ContainerizationError(.invalidArgument, message: "invalid label key: \(key)") } let fullLabel = "\(key)=\(value)" guard fullLabel.count <= labelLengthMax else { throw ContainerizationError(.invalidArgument, message: "invalid label, key length is greater than \(labelLengthMax): \(fullLabel)") } } } extension String { /// Ensure that the network ID has the correct syntax. fileprivate func isValidNetworkID() -> Bool { let pattern = #"^[a-z0-9](?:[a-z0-9._-]{0,61}[a-z0-9])?$"# return self.range(of: pattern, options: .regularExpression) != nil } /// Ensure label key conforms to OCI or Docker label guidelines. /// TODO: Extract when we clean up client dependencies. fileprivate func isValidLabelKey() -> Bool { let dockerPattern = #/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/# let ociPattern = #/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*(?:/(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*))*$/# let dockerMatch = !self.ranges(of: dockerPattern).isEmpty let ociMatch = !self.ranges(of: ociPattern).isEmpty return dockerMatch || ociMatch } } ================================================ FILE: Sources/ContainerResource/Network/NetworkMode.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// /// Networking mode that applies to client containers. public enum NetworkMode: String, Codable, Sendable { /// NAT networking mode. /// Containers do not have routable IPs, and the host performs network /// address translation to allow containers to reach external services. case nat = "nat" /// Host only networking mode /// Containers can talk with each other in the same subnet only. case hostOnly = "hostOnly" } extension NetworkMode { public init() { self = .nat } public init?(_ value: String) { switch value.lowercased() { case "nat": self = .nat case "hostOnly": self = .hostOnly default: return nil } } } ================================================ FILE: Sources/ContainerResource/Network/NetworkState.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationExtras import Foundation public struct NetworkStatus: Codable, Sendable { /// The address allocated for the network if no subnet was specified at /// creation time; otherwise, the subnet from the configuration. public let ipv4Subnet: CIDRv4 /// The gateway IPv4 address. public let ipv4Gateway: IPv4Address /// The address allocated for the IPv6 network if no subnet was specified at /// creation time; otherwise, the IPv6 subnet from the configuration. /// The value is nil if the IPv6 subnet cannot be determined at creation time. public let ipv6Subnet: CIDRv6? public init( ipv4Subnet: CIDRv4, ipv4Gateway: IPv4Address, ipv6Subnet: CIDRv6?, ) { self.ipv4Subnet = ipv4Subnet self.ipv4Gateway = ipv4Gateway self.ipv6Subnet = ipv6Subnet } enum CodingKeys: String, CodingKey { case ipv4Subnet case ipv4Gateway case ipv6Subnet // TODO: retain for deserialization compatibility for now, remove later case address case gateway } /// 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) if let address = try? container.decode(CIDRv4.self, forKey: .ipv4Subnet) { ipv4Subnet = address } else { ipv4Subnet = try container.decode(CIDRv4.self, forKey: .address) } if let gateway = try? container.decode(IPv4Address.self, forKey: .ipv4Gateway) { ipv4Gateway = gateway } else { ipv4Gateway = try container.decode(IPv4Address.self, forKey: .gateway) } ipv6Subnet = try container.decodeIfPresent(String.self, forKey: .ipv6Subnet) .map { try CIDRv6($0) } } /// Encode the configuration to the supplied Encoder. public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(ipv4Subnet, forKey: .ipv4Subnet) try container.encode(ipv4Gateway, forKey: .ipv4Gateway) try container.encodeIfPresent(ipv6Subnet, forKey: .ipv6Subnet) } } /// The configuration and runtime attributes for a network. public enum NetworkState: Codable, Sendable { // The network has been configured. case created(NetworkConfiguration) // The network is running. case running(NetworkConfiguration, NetworkStatus) public var state: String { switch self { case .created: "created" case .running: "running" } } public var id: String { switch self { case .created(let config), .running(let config, _): config.id } } public var creationDate: Date { switch self { case .created(let config), .running(let config, _): config.creationDate } } public var isBuiltin: Bool { switch self { case .created(let config), .running(let config, _): config.labels.isBuiltin } } public var pluginInfo: NetworkPluginInfo? { switch self { case .created(let configuration), .running(let configuration, _): configuration.pluginInfo } } } ================================================ FILE: Sources/ContainerResource/Registry/RegistryResource.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationOS import Foundation /// A container registry resource representing a configured registry endpoint. /// /// Registry resources store authentication and configuration information for /// container registries such as Docker Hub, GitHub Container Registry, or /// private registries. public struct RegistryResource: ManagedResource { /// The registry hostname that uniquely identifies this resource. /// /// For registry resources, the identifier is the same as the hostname. public let id: String /// The hostname of the registry. /// /// This value must be a valid DNS hostname or IPv6 address, optionally /// followed by a port number (e.g., "docker.io", "localhost:5000", "[::1]:5000"). public var name: String /// The username used for authentication with this registry. public let username: String /// The time at which the system created this registry resource. public var creationDate: Date /// The time at which the registry resource was last modified. public var modificationDate: Date /// Key-value properties for the resource. /// /// The user and system may both make use of labels to read and write /// annotations or other metadata. public var labels: [String: String] /// Validates a registry hostname according to OCI distribution specification. /// /// This method validates that a registry hostname conforms to the domain pattern /// used by OCI image references. It supports DNS hostnames, IPv6 addresses, and /// optional port numbers. /// /// - Parameter name: The registry hostname to validate /// - Returns: `true` if the hostname is syntactically valid, `false` otherwise /// /// ## Valid Examples /// - `docker.io` /// - `registry.example.com` /// - `localhost:5000` /// - `[::1]:5000` /// /// ## Implementation Notes /// The validation logic is based on ContainerizationOCI's `Reference.domainPattern`. /// See public static func nameValid(_ name: String) -> Bool { // Domain validation logic based on ContainerizationOCI Reference.domainPattern // See: https://github.com/apple/containerization/blob/main/Sources/ContainerizationOCI/Reference.swift // TODO: if we have domain IP validation API, use that instead let domainNameComponent = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])" let optionalPort = "(?::[0-9]+)?" let ipv6address = "\\[(?:[a-fA-F0-9:]+)\\]" let domainName = "\(domainNameComponent)(?:\\.\(domainNameComponent))*" let host = "(?:\(domainName)|\(ipv6address))" let pattern = "^\(host)\(optionalPort)$" return name.range(of: pattern, options: .regularExpression) != nil } /// Creates a new registry resource. /// /// - Parameters: /// - hostname: The registry hostname (also used as the resource ID) /// - username: The username for authentication /// - creationDate: The time the resource was created /// - modificationDate: The time the resource was last modified /// - labels: Optional key-value labels for metadata (default: empty dictionary) public init( hostname: String, username: String, creationDate: Date, modificationDate: Date, labels: [String: String] = [:] ) { self.id = hostname self.name = hostname self.username = username self.creationDate = creationDate self.modificationDate = modificationDate self.labels = labels } } extension RegistryResource { /// Creates a registry resource from registry information. /// /// - Parameter registryInfo: The registry information to convert public init(from registryInfo: RegistryInfo) { self.init( hostname: registryInfo.hostname, username: registryInfo.username, creationDate: registryInfo.createdDate, modificationDate: registryInfo.modifiedDate ) } } ================================================ FILE: Sources/ContainerResource/Volume/Volume.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// A named or anonymous volume that can be mounted in containers. public struct Volume: Sendable, Codable, Equatable, Identifiable { // id of the volume. public var id: String { name } // Name of the volume. public var name: String // Driver used to create the volume. public var driver: String // Filesystem format of the volume. public var format: String // The mount point of the volume on the host. public var source: String // Timestamp when the volume was created. public var createdAt: Date // User-defined key/value metadata. public var labels: [String: String] // Driver-specific options. public var options: [String: String] // Size of the volume in bytes (optional). public var sizeInBytes: UInt64? public init( name: String, driver: String = "local", format: String = "ext4", source: String, createdAt: Date = Date(), labels: [String: String] = [:], options: [String: String] = [:], sizeInBytes: UInt64? = nil ) { self.name = name self.driver = driver self.format = format self.source = source self.createdAt = createdAt self.labels = labels self.options = options self.sizeInBytes = sizeInBytes } } extension Volume { /// Reserved label key for marking anonymous volumes public static let anonymousLabel = "com.apple.container.resource.anonymous" /// Whether this is an anonymous volume (detected via label) public var isAnonymous: Bool { labels[Self.anonymousLabel] != nil } } /// Error types for volume operations. public enum VolumeError: Error, LocalizedError { case volumeNotFound(String) case volumeAlreadyExists(String) case volumeInUse(String) case invalidVolumeName(String) case driverNotSupported(String) case storageError(String) public var errorDescription: String? { switch self { case .volumeNotFound(let name): return "volume '\(name)' not found" case .volumeAlreadyExists(let name): return "volume '\(name)' already exists" case .volumeInUse(let name): return "volume '\(name)' is currently in use and cannot be accessed by another container, or deleted" case .invalidVolumeName(let name): return "invalid volume name '\(name)'" case .driverNotSupported(let driver): return "volume driver '\(driver)' is not supported" case .storageError(let message): return "storage error: \(message)" } } } /// Volume storage management utilities. public struct VolumeStorage { public static let volumeNamePattern = "^[A-Za-z0-9][A-Za-z0-9_.-]*$" public static let defaultVolumeSizeBytes: UInt64 = 512 * 1024 * 1024 * 1024 // 512GB public static func isValidVolumeName(_ name: String) -> Bool { guard name.count <= 255 else { return false } do { let regex = try Regex(volumeNamePattern) return (try? regex.wholeMatch(in: name)) != nil } catch { return false } } /// Generates an anonymous volume name with UUID format public static func generateAnonymousVolumeName() -> String { UUID().uuidString.lowercased() } } ================================================ FILE: Sources/ContainerVersion/Bundle+AppBundle.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Retrieve the application bundle for a path that refers to a macOS executable. extension Bundle { public static func appBundle(executableURL: URL) -> Bundle? { let resolvedURL = executableURL.resolvingSymlinksInPath() let macOSURL = resolvedURL.deletingLastPathComponent() let contentsURL = macOSURL.deletingLastPathComponent() let bundleURL = contentsURL.deletingLastPathComponent() if bundleURL.pathExtension == "app" { return Bundle(url: bundleURL) } return nil } } ================================================ FILE: Sources/ContainerVersion/CommandLine+Executable.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Foundation extension CommandLine { public static var executablePathUrl: URL { /// _NSGetExecutablePath with a zero-length buffer returns the needed buffer length var bufferSize: Int32 = 0 var buffer = [CChar](repeating: 0, count: Int(bufferSize)) _ = _NSGetExecutablePath(&buffer, &bufferSize) /// Create the buffer and get the path buffer = [CChar](repeating: 0, count: Int(bufferSize)) guard _NSGetExecutablePath(&buffer, &bufferSize) == 0 else { fatalError("unexpected: failed to get executable path") } /// Return the path with the executable file component removed the last component and let executablePath = String(cString: &buffer) return URL(filePath: executablePath) } } ================================================ FILE: Sources/ContainerVersion/ReleaseVersion.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import CVersion import Foundation public struct ReleaseVersion { public static func singleLine(appName: String) -> String { var versionDetails: [String: String] = ["build": buildType()] versionDetails["commit"] = gitCommit().map { String($0.prefix(7)) } ?? "unspecified" let extras: String = versionDetails.map { "\($0): \($1)" }.sorted().joined(separator: ", ") return "\(appName) version \(version()) (\(extras))" } public static func buildType() -> String { #if DEBUG return "debug" #else return "release" #endif } public static func version() -> String { let appBundle = Bundle.appBundle(executableURL: CommandLine.executablePathUrl) let bundleVersion = appBundle?.infoDictionary?["CFBundleShortVersionString"] as? String return bundleVersion ?? get_release_version().map { String(cString: $0) } ?? "0.0.0" } public static func gitCommit() -> String? { get_git_commit().map { String(cString: $0) } } } ================================================ FILE: Sources/ContainerXPC/XPCClient.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// #if os(macOS) import ContainerizationError import Foundation public final class XPCClient: Sendable { /// The maximum amount of time to wait for a request to a recently /// registered XPC service. Once a service has launched, XPC /// requests only have milliseconds of overhead, but in some instances, /// macOS can take 5 seconds (or considerably longer) to launch a /// service after it has been registered. public static let xpcRegistrationTimeout: Duration = .seconds(60) private nonisolated(unsafe) let connection: xpc_connection_t private let q: DispatchQueue? private let service: String public init(service: String, queue: DispatchQueue? = nil) { let connection = xpc_connection_create_mach_service(service, queue, 0) self.connection = connection self.q = queue self.service = service xpc_connection_set_event_handler(connection) { _ in } xpc_connection_set_target_queue(connection, self.q) xpc_connection_activate(connection) } public init(connection: xpc_connection_t, label: String, queue: DispatchQueue? = nil) { self.connection = connection self.q = queue self.service = label xpc_connection_set_event_handler(connection) { _ in } xpc_connection_set_target_queue(connection, self.q) xpc_connection_activate(connection) } deinit { self.close() } } extension XPCClient { /// Close the underlying XPC connection. public func close() { xpc_connection_cancel(connection) } /// Returns the pid of process to which we have a connection. /// Note: `xpc_connection_get_pid` returns 0 if no activity /// has taken place on the connection prior to it being called. public func remotePid() -> pid_t { xpc_connection_get_pid(self.connection) } /// Send the provided message to the service. @discardableResult public func send(_ message: XPCMessage, responseTimeout: Duration? = nil) async throws -> XPCMessage { try await withThrowingTaskGroup(of: XPCMessage.self, returning: XPCMessage.self) { group in if let responseTimeout { group.addTask { try await Task.sleep(for: responseTimeout) let route = message.string(key: XPCMessage.routeKey) ?? "nil" throw ContainerizationError( .internalError, message: "XPC timeout for request to \(self.service)/\(route)" ) } } group.addTask { try await withCheckedThrowingContinuation { cont in xpc_connection_send_message_with_reply(self.connection, message.underlying, nil) { reply in do { let message = try self.parseReply(reply) cont.resume(returning: message) } catch { cont.resume(throwing: error) } } } } let response = try await group.next() // once one task has finished, cancel the rest. group.cancelAll() // we don't really care about the second error here // as it's most likely a `CancellationError`. try? await group.waitForAll() guard let response else { throw ContainerizationError(.invalidState, message: "failed to receive XPC response") } return response } } private func parseReply(_ reply: xpc_object_t) throws -> XPCMessage { switch xpc_get_type(reply) { case XPC_TYPE_ERROR: var code = ContainerizationError.Code.invalidState if reply.connectionError { code = .interrupted } throw ContainerizationError( code, message: "XPC connection error: \(reply.errorDescription ?? "unknown")" ) case XPC_TYPE_DICTIONARY: let message = XPCMessage(object: reply) // check errors from our protocol try message.error() return message default: fatalError("unhandled xpc object type: \(xpc_get_type(reply))") } } } #endif ================================================ FILE: Sources/ContainerXPC/XPCMessage.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// #if os(macOS) import ContainerizationError import Foundation /// 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" /// Defined message key storing the error value. public static let errorKey = "com.apple.container.xpc.error" // Access to `object` is protected by a lock private nonisolated(unsafe) let object: xpc_object_t private let lock = NSLock() private let isErr: Bool /// The underlying xpc object that the message wraps. public var underlying: xpc_object_t { lock.withLock { object } } public var isErrorType: Bool { isErr } public init(object: xpc_object_t) { self.object = object self.isErr = xpc_get_type(self.object) == XPC_TYPE_ERROR } public init(route: String) { self.object = xpc_dictionary_create_empty() self.isErr = false xpc_dictionary_set_string(self.object, Self.routeKey, route) } } extension XPCMessage { public static func == (lhs: XPCMessage, rhs: xpc_object_t) -> Bool { xpc_equal(lhs.underlying, rhs) } public func reply() -> XPCMessage { lock.withLock { XPCMessage(object: xpc_dictionary_create_reply(object)!) } } public func errorKeyDescription() -> String? { guard self.isErr, let xpcErr = lock.withLock({ xpc_dictionary_get_string( self.object, XPC_ERROR_KEY_DESCRIPTION ) }) else { return nil } return String(cString: xpcErr) } public func error() throws { let data = data(key: Self.errorKey) if let data { let item = try? JSONDecoder().decode(ContainerXPCError.self, from: data) precondition(item != nil, "expected to receive a ContainerXPCXPCError") throw ContainerizationError(item!.code, message: item!.message) } } public func set(error: ContainerizationError) { var message = error.message if let cause = error.cause { message += " (cause: \"\(cause)\")" } let serializableError = ContainerXPCError(code: error.code.description, message: message) let data = try? JSONEncoder().encode(serializableError) precondition(data != nil) set(key: Self.errorKey, value: data!) } } struct ContainerXPCError: Codable { let code: String let message: String } extension XPCMessage { public func data(key: String) -> Data? { var length: Int = 0 let bytes = lock.withLock { xpc_dictionary_get_data(self.object, key, &length) } guard let bytes else { return nil } return Data(bytes: bytes, count: length) } /// dataNoCopy is similar to data, except the data is not copied /// to a new buffer. What this means in practice is the second the /// underlying xpc_object_t gets released by ARC the data will be /// released as well. This variant should be used when you know the /// data will be used before the object has no more references. public func dataNoCopy(key: String) -> Data? { var length: Int = 0 let bytes = lock.withLock { xpc_dictionary_get_data(self.object, key, &length) } guard let bytes else { return nil } return Data( bytesNoCopy: UnsafeMutableRawPointer(mutating: bytes), count: length, deallocator: .none ) } public func set(key: String, value: Data) { value.withUnsafeBytes { ptr in if let addr = ptr.baseAddress { lock.withLock { xpc_dictionary_set_data(self.object, key, addr, value.count) } } } } public func string(key: String) -> String? { let _id = lock.withLock { xpc_dictionary_get_string(self.object, key) } if let _id { return String(cString: _id) } return nil } public func set(key: String, value: String) { lock.withLock { xpc_dictionary_set_string(self.object, key, value) } } public func bool(key: String) -> Bool { lock.withLock { xpc_dictionary_get_bool(self.object, key) } } public func set(key: String, value: Bool) { lock.withLock { xpc_dictionary_set_bool(self.object, key, value) } } public func uint64(key: String) -> UInt64 { lock.withLock { xpc_dictionary_get_uint64(self.object, key) } } public func set(key: String, value: UInt64) { lock.withLock { xpc_dictionary_set_uint64(self.object, key, value) } } public func int64(key: String) -> Int64 { lock.withLock { xpc_dictionary_get_int64(self.object, key) } } public func set(key: String, value: Int64) { lock.withLock { xpc_dictionary_set_int64(self.object, key, value) } } public func date(key: String) -> Date { lock.withLock { let nsSinceEpoch = xpc_dictionary_get_date(self.object, key) return Date(timeIntervalSince1970: TimeInterval(nsSinceEpoch) / 1_000_000_000) } } public func set(key: String, value: Date) { lock.withLock { let nsSinceEpoch = Int64(value.timeIntervalSince1970 * 1_000_000_000) xpc_dictionary_set_date(self.object, key, nsSinceEpoch) } } public func fileHandle(key: String) -> FileHandle? { let fd = lock.withLock { xpc_dictionary_get_value(self.object, key) } if let fd { let fd2 = xpc_fd_dup(fd) return FileHandle(fileDescriptor: fd2, closeOnDealloc: false) } return nil } public func set(key: String, value: FileHandle) { let fd = xpc_fd_create(value.fileDescriptor) close(value.fileDescriptor) lock.withLock { xpc_dictionary_set_value(self.object, key, fd) } } public func fileHandles(key: String) -> [FileHandle]? { let fds = lock.withLock { xpc_dictionary_get_value(self.object, key) } if let fds { let fd1 = xpc_array_dup_fd(fds, 0) let fd2 = xpc_array_dup_fd(fds, 1) if fd1 == -1 || fd2 == -1 { return nil } return [ FileHandle(fileDescriptor: fd1, closeOnDealloc: false), FileHandle(fileDescriptor: fd2, closeOnDealloc: false), ] } return nil } public func set(key: String, value: [FileHandle]) throws { let fdArray = xpc_array_create(nil, 0) for fh in value { guard let xpcFd = xpc_fd_create(fh.fileDescriptor) else { throw ContainerizationError( .internalError, message: "failed to create xpc fd for \(fh.fileDescriptor)" ) } xpc_array_append_value(fdArray, xpcFd) close(fh.fileDescriptor) } lock.withLock { xpc_dictionary_set_value(self.object, key, fdArray) } } public func set(key: String, xpcDictionary: xpc_object_t) { lock.withLock { xpc_dictionary_set_value(self.object, key, xpcDictionary) } } public func endpoint(key: String) -> xpc_endpoint_t? { lock.withLock { xpc_dictionary_get_value(self.object, key) } } public func set(key: String, value: xpc_endpoint_t) { lock.withLock { xpc_dictionary_set_value(self.object, key, value) } } } #endif ================================================ FILE: Sources/ContainerXPC/XPCServer.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// #if os(macOS) import CAuditToken import ContainerizationError import Foundation import Logging import os import Synchronization public struct XPCServer: Sendable { public typealias RouteHandler = @Sendable (XPCMessage) async throws -> XPCMessage private let routes: [String: RouteHandler] // Access to `connection` is protected by a lock. private nonisolated(unsafe) let connection: xpc_connection_t private let lock = NSLock() let log: Logging.Logger public init(identifier: String, routes: [String: RouteHandler], log: Logging.Logger) { let connection = xpc_connection_create_mach_service( identifier, nil, UInt64(XPC_CONNECTION_MACH_SERVICE_LISTENER) ) self.routes = routes self.connection = connection self.log = log } public init(connection: xpc_connection_t, routes: [String: RouteHandler], log: Logging.Logger) { self.routes = routes self.connection = connection self.log = log } public func listen() async throws { let connections = AsyncStream { cont in lock.withLock { xpc_connection_set_event_handler(self.connection) { object in switch xpc_get_type(object) { case XPC_TYPE_CONNECTION: // `object` isn't used concurrently. nonisolated(unsafe) let object = object cont.yield(object) case XPC_TYPE_ERROR: if object.connectionError { cont.finish() } default: fatalError("unhandled xpc object type: \(xpc_get_type(object))") } } } } defer { lock.withLock { xpc_connection_cancel(self.connection) } } lock.withLock { xpc_connection_activate(self.connection) } try await withThrowingDiscardingTaskGroup { group in for await conn in connections { // `conn` isn't used concurrently. nonisolated(unsafe) let conn = conn let added = group.addTaskUnlessCancelled { @Sendable in try await self.handleClientConnection(connection: conn) xpc_connection_cancel(conn) } if !added { break } } group.cancelAll() } } func handleClientConnection(connection: xpc_connection_t) async throws { let replySent = Mutex(false) let objects = AsyncStream { cont in xpc_connection_set_event_handler(connection) { object in switch xpc_get_type(object) { case XPC_TYPE_DICTIONARY: // `object` isn't used concurrently. nonisolated(unsafe) let object = object cont.yield(object) case XPC_TYPE_ERROR: if object.connectionError { cont.finish() } if !(replySent.withLock({ $0 }) && object.connectionClosed) { // When a xpc connection is closed, the framework sends // a final XPC_ERROR_CONNECTION_INVALID message. // We can ignore this if we know we have already handled // the request. self.log.error( "xpc client handler connection error", metadata: [ "error": "\(object.errorDescription ?? "no description")" ]) } default: fatalError("unhandled xpc object type: \(xpc_get_type(object))") } } } defer { xpc_connection_cancel(connection) } xpc_connection_activate(connection) try await withThrowingDiscardingTaskGroup { group in // `connection` isn't used concurrently. nonisolated(unsafe) let connection = connection for await object in objects { // `object` isn't used concurrently. nonisolated(unsafe) let object = object let added = group.addTaskUnlessCancelled { @Sendable in try await self.handleMessage(connection: connection, object: object) replySent.withLock { $0 = true } } if !added { break } } group.cancelAll() } } func handleMessage(connection: xpc_connection_t, object: xpc_object_t) async throws { // All requests are dictionary-valued. guard xpc_get_type(object) == XPC_TYPE_DICTIONARY else { log.error("invalid request - not a dictionary") Self.replyWithError( connection: connection, object: object, err: ContainerizationError(.invalidArgument, message: "invalid request") ) return } // Ensure that the client has our EUID var token = audit_token_t() xpc_dictionary_get_audit_token(object, &token) let serverEuid = geteuid() let clientEuid = audit_token_to_euid(token) guard clientEuid == serverEuid else { log.error( "unauthorized request - uid mismatch", metadata: [ "server_euid": "\(serverEuid)", "client_euid": "\(clientEuid)", ]) Self.replyWithError( connection: connection, object: object, err: ContainerizationError(.invalidState, message: "unauthorized request") ) return } guard let route = object.route else { log.error("invalid request - empty route") Self.replyWithError( connection: connection, object: object, err: ContainerizationError(.invalidArgument, message: "invalid request") ) return } if let handler = routes[route] { do { let message = XPCMessage(object: object) let response = try await handler(message) xpc_connection_send_message(connection, response.underlying) } catch let error as ContainerizationError { log.error( "route handler threw an error", metadata: [ "route": "\(route)", "error": "\(error)", ]) Self.replyWithError( connection: connection, object: object, err: error ) } catch { log.error( "route handler threw an error", metadata: [ "route": "\(route)", "error": "\(error)", ]) let message = XPCMessage(object: object) let reply = message.reply() // Check if this is a VolumeError by looking at the error description let errorMessage = error.localizedDescription let errorTypeString = String(describing: type(of: error)) if errorTypeString.contains("VolumeError") || errorMessage.contains("Volume") { let err = ContainerizationError(.invalidArgument, message: errorMessage) reply.set(error: err) } else { let err = ContainerizationError(.unknown, message: String(describing: error)) reply.set(error: err) } xpc_connection_send_message(connection, reply.underlying) } } } private static func replyWithError(connection: xpc_connection_t, object: xpc_object_t, err: ContainerizationError) { let message = XPCMessage(object: object) let reply = message.reply() reply.set(error: err) xpc_connection_send_message(connection, reply.underlying) } } extension xpc_object_t { var route: String? { let croute = xpc_dictionary_get_string(self, XPCMessage.routeKey) guard let croute else { return nil } return String(cString: croute) } var connectionError: Bool { precondition(isError, "not an error") return xpc_equal(self, XPC_ERROR_CONNECTION_INVALID) || xpc_equal(self, XPC_ERROR_CONNECTION_INTERRUPTED) } var connectionClosed: Bool { precondition(isError, "not an error") return xpc_equal(self, XPC_ERROR_CONNECTION_INVALID) } var isError: Bool { xpc_get_type(self) == XPC_TYPE_ERROR } var errorDescription: String? { precondition(isError, "not an error") let cstring = xpc_dictionary_get_string(self, XPC_ERROR_KEY_DESCRIPTION) guard let cstring else { return nil } return String(cString: cstring) } } #endif ================================================ FILE: Sources/DNSServer/DNSHandler.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// /// Protocol for implementing custom DNS handlers. public protocol DNSHandler { /// Attempt to answer a DNS query /// - Parameter query: the query message /// - Throws: a server failure occurred during the query /// - Returns: The response message for the query, or nil if the request /// is not within the scope of the handler. func answer(query: Message) async throws -> Message? } ================================================ FILE: Sources/DNSServer/DNSServer+Handle.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 NIOCore import NIOPosix extension DNSServer { /// Handles the DNS request. /// - Parameters: /// - outbound: The NIOAsyncChannelOutboundWriter for which to respond. /// - packet: The request packet. func handle( outbound: NIOAsyncChannelOutboundWriter>, packet: inout AddressedEnvelope ) async throws { // RFC 1035 §2.3.4 limits UDP DNS messages to 512 bytes. We don't implement // EDNS0 (RFC 6891), and this server only resolves host A/AAAA queries, so a // legitimate query will never approach this limit. Reject oversized packets // before reading to avoid allocating memory for malformed or malicious datagrams. let maxPacketSize = 512 guard packet.data.readableBytes <= maxPacketSize else { self.log?.error("dropping oversized DNS packet: \(packet.data.readableBytes) bytes") return } var data = Data() self.log?.debug("reading data") while packet.data.readableBytes > 0 { if let chunk = packet.data.readBytes(length: packet.data.readableBytes) { data.append(contentsOf: chunk) } } self.log?.debug("deserializing message") // always send response let responseData: Data do { let query = try Message(deserialize: data) self.log?.debug("processing query: \(query.questions)") self.log?.debug("awaiting processing") var response = try await handler.answer(query: query) ?? Message( id: query.id, type: .response, returnCode: .notImplemented, questions: query.questions, answers: [] ) // Only set NXDOMAIN if handler didn't explicitly set noError (NODATA response). // This preserves NODATA responses for AAAA queries when A record exists, // which prevents musl libc from treating empty AAAA as "domain doesn't exist". if response.answers.isEmpty && response.returnCode != .noError { response.returnCode = .nonExistentDomain } self.log?.debug("serializing response") responseData = try response.serialize() } catch let error as DNSBindError { // Best-effort: echo the transaction ID from the first two bytes of the raw packet. let rawId = data.count >= 2 ? data[0..<2].withUnsafeBytes { $0.load(as: UInt16.self) } : 0 let id = UInt16(bigEndian: rawId) let returnCode: ReturnCode switch error { case .unsupportedValue: self.log?.error("not implemented processing DNS message: \(error)") returnCode = .notImplemented default: self.log?.error("format error processing DNS message: \(error)") returnCode = .formatError } let response = Message( id: id, type: .response, returnCode: returnCode, questions: [], answers: [] ) responseData = try response.serialize() } catch { let rawId = data.count >= 2 ? data[0..<2].withUnsafeBytes { $0.load(as: UInt16.self) } : 0 let id = UInt16(bigEndian: rawId) self.log?.error("error processing DNS message: \(error)") let response = Message( id: id, type: .response, returnCode: .serverFailure, questions: [], answers: [] ) responseData = try response.serialize() } self.log?.debug("sending response") let rData = ByteBuffer(bytes: responseData) do { try await outbound.write(AddressedEnvelope(remoteAddress: packet.remoteAddress, data: rData)) } catch { self.log?.error("failed to send DNS response: \(error)") } self.log?.debug("processing done") } } ================================================ FILE: Sources/DNSServer/DNSServer.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 Logging import NIOCore import NIOPosix /// Provides a DNS server. /// - Parameters: /// - host: The host address on which to listen. /// - port: The port for the server to listen. public struct DNSServer { public var handler: DNSHandler let log: Logger? public init( handler: DNSHandler, log: Logger? = nil ) { self.handler = handler self.log = log } public func run(host: String, port: Int) async throws { // TODO: TCP server let srv = try await DatagramBootstrap(group: NIOSingletons.posixEventLoopGroup) .channelOption(.socketOption(.so_reuseaddr), value: 1) .bind(host: host, port: port) .flatMapThrowing { channel in try NIOAsyncChannel( wrappingChannelSynchronously: channel, configuration: NIOAsyncChannel.Configuration( inboundType: AddressedEnvelope.self, outboundType: AddressedEnvelope.self ) ) } .get() try await srv.executeThenClose { inbound, outbound in for try await var packet in inbound { try await self.handle(outbound: outbound, packet: &packet) } } } public func run(socketPath: String) async throws { // TODO: TCP server let srv = try await DatagramBootstrap(group: NIOSingletons.posixEventLoopGroup) .bind(unixDomainSocketPath: socketPath, cleanupExistingSocketFile: true) .flatMapThrowing { channel in try NIOAsyncChannel( wrappingChannelSynchronously: channel, configuration: NIOAsyncChannel.Configuration( inboundType: AddressedEnvelope.self, outboundType: AddressedEnvelope.self ) ) } .get() try await srv.executeThenClose { inbound, outbound in for try await var packet in inbound { log?.debug("received packet from \(packet.remoteAddress)") try await self.handle(outbound: outbound, packet: &packet) log?.debug("sent packet") } } } public func stop() async throws {} } ================================================ FILE: Sources/DNSServer/Handlers/CompositeResolver.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// /// Delegates a query sequentially to handlers until one provides a response. public struct CompositeResolver: DNSHandler { private let handlers: [DNSHandler] public init(handlers: [DNSHandler]) { self.handlers = handlers } public func answer(query: Message) async throws -> Message? { for handler in self.handlers { if let response = try await handler.answer(query: query) { return response } } return nil } } ================================================ FILE: Sources/DNSServer/Handlers/HostTableResolver.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationExtras /// Handler that uses table lookup to resolve hostnames. /// /// Keys in `hosts4` are normalized to `DNSName` on construction, so lookups /// are case-insensitive and trailing dots are optional. public struct HostTableResolver: DNSHandler { public let hosts4: [DNSName: IPv4Address] private let ttl: UInt32 /// Creates a resolver backed by a static IPv4 host table. /// /// - Parameter hosts4: A dictionary mapping domain names to IPv4 addresses. /// Keys are normalized to `DNSName` (lowercased, trailing dot stripped), so /// `"FOO."`, `"foo."`, and `"foo"` all refer to the same entry. /// - Parameter ttl: The TTL in seconds to set on answer records (default is 300). /// - Throws: `DNSBindError.invalidName` if any key is not a valid DNS name. public init(hosts4: [String: IPv4Address], ttl: UInt32 = 300) throws { self.hosts4 = try Dictionary(uniqueKeysWithValues: hosts4.map { (try DNSName($0.key), $0.value) }) self.ttl = ttl } public func answer(query: Message) async throws -> Message? { guard let question = query.questions.first else { return nil } let n = question.name.hasSuffix(".") ? String(question.name.dropLast()) : question.name let key = try DNSName(labels: n.isEmpty ? [] : n.split(separator: ".", omittingEmptySubsequences: false).map(String.init)) let record: ResourceRecord? switch question.type { case ResourceRecordType.host: record = answerHost(question: question, key: key) case ResourceRecordType.host6: // Return NODATA (noError with empty answers) for AAAA queries ONLY if A record exists. // This is required because musl libc has issues when A record exists but AAAA returns NXDOMAIN. // musl treats NXDOMAIN on AAAA as "domain doesn't exist" and fails DNS resolution entirely. // NODATA correctly indicates "no IPv6 address available, but domain exists". if hosts4[key] != nil { return Message( id: query.id, type: .response, returnCode: .noError, questions: query.questions, answers: [] ) } // If hostname doesn't exist, return nil which will become NXDOMAIN return nil default: return Message( id: query.id, type: .response, returnCode: .notImplemented, questions: query.questions, answers: [] ) } guard let record else { return nil } return Message( id: query.id, type: .response, returnCode: .noError, questions: query.questions, answers: [record] ) } private func answerHost(question: Question, key: DNSName) -> ResourceRecord? { guard let ip = hosts4[key] else { return nil } return HostRecord(name: question.name, ttl: ttl, ip: ip) } } ================================================ FILE: Sources/DNSServer/Handlers/NxDomainResolver.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// /// Handler that returns NXDOMAIN for all hostnames. public struct NxDomainResolver: DNSHandler { private let ttl: UInt32 public init(ttl: UInt32 = 300) { self.ttl = ttl } public func answer(query: Message) async throws -> Message? { let question = query.questions[0] switch question.type { case ResourceRecordType.host: return Message( id: query.id, type: .response, returnCode: .nonExistentDomain, questions: query.questions, answers: [] ) default: return Message( id: query.id, type: .response, returnCode: .notImplemented, questions: query.questions, answers: [] ) } } } ================================================ FILE: Sources/DNSServer/Handlers/StandardQueryValidator.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// /// Pass standard queries to a delegate handler. public struct StandardQueryValidator: DNSHandler { private let handler: DNSHandler /// Create the handler. /// - Parameter delegate: the handler that receives valid queries public init(handler: DNSHandler) { self.handler = handler } /// Ensures the query is valid before forwarding it to the delegate. /// - Parameter msg: the query message /// - Returns: the delegate response if the query is valid, and an /// error response otherwise public func answer(query: Message) async throws -> Message? { // Reject response messages. guard query.type == .query else { return Message( id: query.id, type: .response, returnCode: .formatError, questions: query.questions ) } // Standard DNS servers handle only query operations. guard query.operationCode == .query else { return Message( id: query.id, type: .response, returnCode: .notImplemented, questions: query.questions ) } // Standard DNS servers only handle messages with exactly one question. guard query.questions.count == 1 else { return Message( id: query.id, type: .response, returnCode: .formatError, questions: query.questions ) } return try await handler.answer(query: query) } } ================================================ FILE: Sources/DNSServer/Records/DNSBindError.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// /// Errors that can occur during DNS message serialization/deserialization. public enum DNSBindError: Error, CustomStringConvertible { case marshalFailure(type: String, field: String) case unmarshalFailure(type: String, field: String) case unsupportedValue(type: String, field: String) case invalidName(String) case unexpectedOffset(type: String, expected: Int, actual: Int) public var description: String { switch self { case .marshalFailure(let type, let field): return "failed to marshal \(type).\(field)" case .unmarshalFailure(let type, let field): return "failed to unmarshal \(type).\(field)" case .unsupportedValue(let type, let field): return "unsupported value for \(type).\(field)" case .invalidName(let reason): return "invalid DNS name: \(reason)" case .unexpectedOffset(let type, let expected, let actual): return "unexpected offset serializing \(type): expected \(expected), got \(actual)" } } } ================================================ FILE: Sources/DNSServer/Records/DNSEnums.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// /// DNS message type (query or response). public enum MessageType: UInt16, Sendable { case query = 0 case response = 1 } /// DNS operation code (RFC 1035, 1996, 2136). public enum OperationCode: UInt8, Sendable { case query = 0 // Standard query (RFC 1035) case inverseQuery = 1 // Inverse query (obsolete, RFC 3425) case status = 2 // Server status request (RFC 1035) // 3 is reserved case notify = 4 // Zone change notification (RFC 1996) case update = 5 // Dynamic update (RFC 2136) case dso = 6 // DNS Stateful Operations (RFC 8490) // 7-15 reserved } /// DNS response return codes (RFC 1035, 2136, 2845, 6895). public enum ReturnCode: UInt8, Sendable { case noError = 0 // No error case formatError = 1 // Format error - unable to interpret query case serverFailure = 2 // Server failure case nonExistentDomain = 3 // Name error - domain does not exist (NXDOMAIN) case notImplemented = 4 // Not implemented - query type not supported case refused = 5 // Refused - policy restriction case yxDomain = 6 // Name exists when it should not (RFC 2136) case yxRRSet = 7 // RR set exists when it should not (RFC 2136) case nxRRSet = 8 // RR set does not exist when it should (RFC 2136) case notAuthoritative = 9 // Server not authoritative (RFC 2136) / Not authorized (RFC 2845) case notZone = 10 // Name not in zone (RFC 2136) case dsoTypeNotImplemented = 11 // DSO-TYPE not implemented (RFC 8490) // 12-15 reserved case badSignature = 16 // TSIG signature failure (RFC 2845) case badKey = 17 // Key not recognized (RFC 2845) case badTime = 18 // Signature out of time window (RFC 2845) case badMode = 19 // Bad TKEY mode (RFC 2930) case badName = 20 // Duplicate key name (RFC 2930) case badAlgorithm = 21 // Algorithm not supported (RFC 2930) case badTruncation = 22 // Bad truncation (RFC 4635) case badCookie = 23 // Bad/missing server cookie (RFC 7873) } /// DNS resource record types (RFC 1035, 3596, 2782, and others). public enum ResourceRecordType: UInt16, Sendable { case host = 1 // A - IPv4 address (RFC 1035) case nameServer = 2 // NS - Authoritative name server (RFC 1035) case mailDestination = 3 // MD - Mail destination (obsolete, RFC 1035) case mailForwarder = 4 // MF - Mail forwarder (obsolete, RFC 1035) case alias = 5 // CNAME - Canonical name (RFC 1035) case startOfAuthority = 6 // SOA - Start of authority (RFC 1035) case mailbox = 7 // MB - Mailbox domain name (experimental, RFC 1035) case mailGroup = 8 // MG - Mail group member (experimental, RFC 1035) case mailRename = 9 // MR - Mail rename domain name (experimental, RFC 1035) case null = 10 // NULL - Null RR (experimental, RFC 1035) case wellKnownService = 11 // WKS - Well known service (RFC 1035) case pointer = 12 // PTR - Domain name pointer (RFC 1035) case hostInfo = 13 // HINFO - Host information (RFC 1035) case mailInfo = 14 // MINFO - Mailbox information (RFC 1035) case mailExchange = 15 // MX - Mail exchange (RFC 1035) case text = 16 // TXT - Text strings (RFC 1035) case responsiblePerson = 17 // RP - Responsible person (RFC 1183) case afsDatabase = 18 // AFSDB - AFS database location (RFC 1183) case x25 = 19 // X25 - X.25 PSDN address (RFC 1183) case isdn = 20 // ISDN - ISDN address (RFC 1183) case routeThrough = 21 // RT - Route through (RFC 1183) case nsapAddress = 22 // NSAP - NSAP address (RFC 1706) case nsapPointer = 23 // NSAP-PTR - NSAP pointer (RFC 1706) case signature = 24 // SIG - Security signature (RFC 2535) case key = 25 // KEY - Security key (RFC 2535) case pxRecord = 26 // PX - X.400 mail mapping (RFC 2163) case gpos = 27 // GPOS - Geographical position (RFC 1712) case host6 = 28 // AAAA - IPv6 address (RFC 3596) case location = 29 // LOC - Location information (RFC 1876) case nextDomain = 30 // NXT - Next domain (obsolete, RFC 2535) case endpointId = 31 // EID - Endpoint identifier case nimrodLocator = 32 // NIMLOC - Nimrod locator case service = 33 // SRV - Service locator (RFC 2782) case atma = 34 // ATMA - ATM address case namingPointer = 35 // NAPTR - Naming authority pointer (RFC 3403) case keyExchange = 36 // KX - Key exchange (RFC 2230) case cert = 37 // CERT - Certificate (RFC 4398) case a6Record = 38 // A6 - IPv6 address (obsolete, RFC 2874) case dname = 39 // DNAME - Delegation name (RFC 6672) case sink = 40 // SINK - Kitchen sink case opt = 41 // OPT - EDNS option (RFC 6891) case apl = 42 // APL - Address prefix list (RFC 3123) case delegationSigner = 43 // DS - Delegation signer (RFC 4034) case sshFingerprint = 44 // SSHFP - SSH key fingerprint (RFC 4255) case ipsecKey = 45 // IPSECKEY - IPsec key (RFC 4025) case resourceSignature = 46 // RRSIG - Resource record signature (RFC 4034) case nsec = 47 // NSEC - Next secure record (RFC 4034) case dnsKey = 48 // DNSKEY - DNS key (RFC 4034) case dhcid = 49 // DHCID - DHCP identifier (RFC 4701) case nsec3 = 50 // NSEC3 - NSEC3 (RFC 5155) case nsec3Param = 51 // NSEC3PARAM - NSEC3 parameters (RFC 5155) case tlsa = 52 // TLSA - TLSA certificate (RFC 6698) case smimea = 53 // SMIMEA - S/MIME cert association (RFC 8162) // 54 unassigned case hip = 55 // HIP - Host identity protocol (RFC 8005) case ninfo = 56 // NINFO case rkey = 57 // RKEY case taLink = 58 // TALINK - Trust anchor link case cds = 59 // CDS - Child DS (RFC 7344) case cdnsKey = 60 // CDNSKEY - Child DNSKEY (RFC 7344) case openPGPKey = 61 // OPENPGPKEY - OpenPGP key (RFC 7929) case csync = 62 // CSYNC - Child-to-parent sync (RFC 7477) case zoneDigest = 63 // ZONEMD - Zone message digest (RFC 8976) case svcBinding = 64 // SVCB - Service binding (RFC 9460) case httpsBinding = 65 // HTTPS - HTTPS binding (RFC 9460) // 66-98 unassigned case spf = 99 // SPF - Sender policy framework (RFC 7208) case uinfo = 100 // UINFO case uid = 101 // UID case gid = 102 // GID case unspec = 103 // UNSPEC case nid = 104 // NID - Node identifier (RFC 6742) case l32 = 105 // L32 - Locator32 (RFC 6742) case l64 = 106 // L64 - Locator64 (RFC 6742) case lp = 107 // LP - Locator FQDN (RFC 6742) case eui48 = 108 // EUI48 - 48-bit MAC (RFC 7043) case eui64 = 109 // EUI64 - 64-bit MAC (RFC 7043) // 110-248 unassigned case tkey = 249 // TKEY - Transaction key (RFC 2930) case tsig = 250 // TSIG - Transaction signature (RFC 2845) case incrementalZoneTransfer = 251 // IXFR - Incremental zone transfer (RFC 1995) case standardZoneTransfer = 252 // AXFR - Full zone transfer (RFC 1035) case mailboxRecords = 253 // MAILB - Mailbox-related records (RFC 1035) case mailAgentRecords = 254 // MAILA - Mail agent RRs (obsolete, RFC 1035) case all = 255 // * - All records (RFC 1035) case uri = 256 // URI - Uniform resource identifier (RFC 7553) case caa = 257 // CAA - Certification authority authorization (RFC 8659) case avc = 258 // AVC - Application visibility and control case doa = 259 // DOA - Digital object architecture case amtRelay = 260 // AMTRELAY - Automatic multicast tunneling relay (RFC 8777) case resInfo = 261 // RESINFO - Resolver information // ... case ta = 32768 // TA - DNSSEC trust authorities case dlv = 32769 // DLV - DNSSEC lookaside validation (RFC 4431) } /// DNS resource record class (RFC 1035). public enum ResourceRecordClass: UInt16, Sendable { case internet = 1 // IN - Internet (RFC 1035) // 2 unassigned case chaos = 3 // CH - Chaos (RFC 1035) case hesiod = 4 // HS - Hesiod (RFC 1035) // 5-253 unassigned case none = 254 // NONE - None (RFC 2136) case any = 255 // * - Any class (RFC 1035) } ================================================ FILE: Sources/DNSServer/Records/DNSName.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// A DNS name encoded as a sequence of labels. /// /// DNS names are encoded as: `[length][label][length][label]...[0]` /// For example, "example.com" becomes: `[7]example[3]com[0]` public struct DNSName: Sendable, Hashable, CustomStringConvertible { /// The labels that make up this name (e.g., ["example", "com"]). public private(set) var labels: [String] /// Creates a DNS name representing the root (empty label list). public init() { self.labels = [] } /// Creates a validated DNS name from an array of labels. /// /// Validates structural RFC 1035 constraints only: no empty labels, each label ≤ 63 /// bytes, total wire length ≤ 255 bytes. Does not enforce hostname character rules. /// Labels are lowercased to normalize for case-insensitive DNS comparison. /// /// - Throws: `DNSBindError.invalidName` if any label is empty, exceeds 63 bytes, /// or if the total wire representation exceeds 255 bytes. public init(labels: [String]) throws { for label in labels { guard !label.isEmpty else { throw DNSBindError.invalidName("empty label") } guard label.utf8.count <= 63 else { throw DNSBindError.invalidName("label too long: \"\(label)\"") } } let wireLength = labels.reduce(1) { $0 + 1 + $1.utf8.count } guard wireLength <= 255 else { throw DNSBindError.invalidName("name too long") } self.labels = labels.map { $0.lowercased() } } /// Creates a validated DNS name from a dot-separated hostname string /// (e.g., `"example.com."` or `"example.com"`). /// /// A trailing dot is accepted but not required. /// An empty string produces the root name without error. /// /// Labels must start and end with a letter or digit (LDH hostname rule). /// Use `init(labels:)` directly when working with wire-decoded names that /// may contain non-hostname labels (e.g. service-discovery labels like `"_dns"`). /// /// - Throws: `DNSBindError.invalidName` if any label violates the character rules, /// or if structural limits are exceeded (see `init(labels:)`). public init(_ hostname: String) throws { let normalized = hostname.hasSuffix(".") ? String(hostname.dropLast()) : hostname guard !normalized.isEmpty else { self.init() return } let parts = normalized.split(separator: ".", omittingEmptySubsequences: false).map { String($0) } let hostnameRegex = /[a-zA-Z0-9](?:[a-zA-Z0-9\-_]*[a-zA-Z0-9])?/ for part in parts { guard part.wholeMatch(of: hostnameRegex) != nil else { throw DNSBindError.invalidName( "label must start and end with a letter or digit: \"\(part)\"" ) } } try self.init(labels: parts) } /// The wire format size of this name in bytes. public var size: Int { // Each label: 1 byte length + label bytes, plus 1 byte for null terminator labels.reduce(1) { $0 + 1 + $1.utf8.count } } /// The fully-qualified domain name with trailing dot. public var description: String { labels.isEmpty ? "." : labels.joined(separator: ".") + "." } /// Serialize this name into the buffer at the given offset. public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { let startOffset = offset var offset = offset for label in labels { let bytes = Array(label.utf8) guard bytes.count <= 63 else { throw DNSBindError.marshalFailure(type: "DNSName", field: "label") } guard let newOffset = buffer.copyIn(as: UInt8.self, value: UInt8(bytes.count), offset: offset) else { throw DNSBindError.marshalFailure(type: "DNSName", field: "label") } offset = newOffset guard let newOffset = buffer.copyIn(buffer: bytes, offset: offset) else { throw DNSBindError.marshalFailure(type: "DNSName", field: "label") } offset = newOffset } // Null terminator guard let newOffset = buffer.copyIn(as: UInt8.self, value: 0, offset: offset) else { throw DNSBindError.marshalFailure(type: "DNSName", field: "terminator") } guard newOffset == startOffset + size else { throw DNSBindError.unexpectedOffset(type: "DNSName", expected: startOffset + size, actual: newOffset) } return newOffset } /// Deserialize a name from the buffer at the given offset. /// /// - Parameters: /// - buffer: The buffer to read from. /// - offset: The offset to start reading. /// - messageStart: The start of the DNS message (for compression pointer resolution). /// - Returns: The new offset after reading. public mutating func bindBuffer( _ buffer: inout [UInt8], offset: Int, messageStart: Int = 0 ) throws -> Int { var offset = offset var collectedLabels: [String] = [] var jumped = false var returnOffset = offset var pointerHops = 0 while true { guard offset < buffer.count else { throw DNSBindError.unmarshalFailure(type: "DNSName", field: "name") } let length = buffer[offset] // Check for compression pointer (top 2 bits set) if (length & 0xC0) == 0xC0 { guard offset + 1 < buffer.count else { throw DNSBindError.unmarshalFailure(type: "DNSName", field: "pointer") } pointerHops += 1 guard pointerHops <= 10 else { throw DNSBindError.unmarshalFailure(type: "DNSName", field: "pointer") } if !jumped { returnOffset = offset + 2 } // Calculate pointer offset from message start let pointer = Int(length & 0x3F) << 8 | Int(buffer[offset + 1]) let pointerTarget = messageStart + pointer guard pointerTarget >= 0 && pointerTarget < offset && pointerTarget < buffer.count else { throw DNSBindError.unmarshalFailure(type: "DNSName", field: "pointer") } offset = pointerTarget jumped = true continue } offset += 1 // Null terminator - end of name if length == 0 { break } guard offset + Int(length) <= buffer.count else { throw DNSBindError.unmarshalFailure(type: "DNSName", field: "label") } let labelBytes = Array(buffer[offset..> 11) & 0x0F)) else { throw DNSBindError.unsupportedValue(type: "Message", field: "opcode") } self.operationCode = opCode self.authoritativeAnswer = (flags & 0x0400) != 0 self.truncation = (flags & 0x0200) != 0 self.recursionDesired = (flags & 0x0100) != 0 self.recursionAvailable = (flags & 0x0080) != 0 guard let returnCode = ReturnCode(rawValue: UInt8(flags & 0x000F)) else { throw DNSBindError.unsupportedValue(type: "Message", field: "rcode") } self.returnCode = returnCode // Read counts guard let (newOffset, rawQdCount) = buffer.copyOut(as: UInt16.self, offset: offset) else { throw DNSBindError.unmarshalFailure(type: "Message", field: "qdcount") } let qdCount = UInt16(bigEndian: rawQdCount) offset = newOffset guard let (newOffset, rawAnCount) = buffer.copyOut(as: UInt16.self, offset: offset) else { throw DNSBindError.unmarshalFailure(type: "Message", field: "ancount") } let anCount = UInt16(bigEndian: rawAnCount) offset = newOffset guard let (newOffset, rawNsCount) = buffer.copyOut(as: UInt16.self, offset: offset) else { throw DNSBindError.unmarshalFailure(type: "Message", field: "nscount") } // nsCount not used for now, but we need to read past it _ = UInt16(bigEndian: rawNsCount) offset = newOffset guard let (newOffset, rawArCount) = buffer.copyOut(as: UInt16.self, offset: offset) else { throw DNSBindError.unmarshalFailure(type: "Message", field: "arcount") } // arCount not used for now, but we need to read past it _ = UInt16(bigEndian: rawArCount) offset = newOffset // Read questions self.questions = [] for _ in 0.. Data { // Calculate exact buffer size. var bufferSize = Self.headerSize for question in questions { // name + type + class let n = question.name.hasSuffix(".") ? String(question.name.dropLast()) : question.name bufferSize += (try DNSName(labels: n.isEmpty ? [] : n.split(separator: ".", omittingEmptySubsequences: false).map(String.init))).size + 4 } for answer in answers { // name + type + class + ttl + rdlen + rdata let n = answer.name.hasSuffix(".") ? String(answer.name.dropLast()) : answer.name let rdataSize = answer.type == .host ? 4 : 16 bufferSize += (try DNSName(labels: n.isEmpty ? [] : n.split(separator: ".", omittingEmptySubsequences: false).map(String.init))).size + 10 + rdataSize } var buffer = [UInt8](repeating: 0, count: bufferSize) var offset = 0 // Write ID guard let newOffset = buffer.copyIn(as: UInt16.self, value: id.bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "Message", field: "id") } offset = newOffset // Build and write flags var flags: UInt16 = 0 flags |= type == .response ? 0x8000 : 0 flags |= UInt16(operationCode.rawValue) << 11 flags |= authoritativeAnswer ? 0x0400 : 0 flags |= truncation ? 0x0200 : 0 flags |= recursionDesired ? 0x0100 : 0 flags |= recursionAvailable ? 0x0080 : 0 flags |= UInt16(returnCode.rawValue) guard let newOffset = buffer.copyIn(as: UInt16.self, value: flags.bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "Message", field: "flags") } offset = newOffset // Write counts guard questions.count <= UInt16.max else { throw DNSBindError.marshalFailure(type: "Message", field: "qdcount") } guard answers.count <= UInt16.max else { throw DNSBindError.marshalFailure(type: "Message", field: "ancount") } guard authorities.count <= UInt16.max else { throw DNSBindError.marshalFailure(type: "Message", field: "nscount") } guard additional.count <= UInt16.max else { throw DNSBindError.marshalFailure(type: "Message", field: "arcount") } guard let newOffset = buffer.copyIn(as: UInt16.self, value: UInt16(questions.count).bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "Message", field: "qdcount") } offset = newOffset guard let newOffset = buffer.copyIn(as: UInt16.self, value: UInt16(answers.count).bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "Message", field: "ancount") } offset = newOffset guard let newOffset = buffer.copyIn(as: UInt16.self, value: UInt16(authorities.count).bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "Message", field: "nscount") } offset = newOffset guard let newOffset = buffer.copyIn(as: UInt16.self, value: UInt16(additional.count).bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "Message", field: "arcount") } offset = newOffset // Write questions for question in questions { offset = try question.appendBuffer(&buffer, offset: offset) } // Write answers for answer in answers { offset = try answer.appendBuffer(&buffer, offset: offset) } // Write authorities for authority in authorities { offset = try authority.appendBuffer(&buffer, offset: offset) } // Write additional for record in additional { offset = try record.appendBuffer(&buffer, offset: offset) } guard offset == bufferSize else { throw DNSBindError.unexpectedOffset(type: "Message", expected: bufferSize, actual: offset) } return Data(buffer[0.. Int { let startOffset = offset var offset = offset // Write name let normalized = name.hasSuffix(".") ? String(name.dropLast()) : name let dnsName = try DNSName(labels: normalized.isEmpty ? [] : normalized.split(separator: ".", omittingEmptySubsequences: false).map(String.init)) offset = try dnsName.appendBuffer(&buffer, offset: offset) // Write type (big-endian) guard let newOffset = buffer.copyIn(as: UInt16.self, value: type.rawValue.bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "Question", field: "type") } offset = newOffset // Write class (big-endian) guard let newOffset = buffer.copyIn(as: UInt16.self, value: recordClass.rawValue.bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "Question", field: "class") } let expectedOffset = startOffset + dnsName.size + 4 guard newOffset == expectedOffset else { throw DNSBindError.unexpectedOffset(type: "Question", expected: expectedOffset, actual: newOffset) } return newOffset } /// Deserialize a question from the buffer. public mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int, messageStart: Int = 0) throws -> Int { var offset = offset // Read name var dnsName = DNSName() offset = try dnsName.bindBuffer(&buffer, offset: offset, messageStart: messageStart) self.name = dnsName.description // Read type (big-endian) guard let (newOffset, rawType) = buffer.copyOut(as: UInt16.self, offset: offset) else { throw DNSBindError.unmarshalFailure(type: "Question", field: "type") } guard let qtype = ResourceRecordType(rawValue: UInt16(bigEndian: rawType)) else { throw DNSBindError.unsupportedValue(type: "Question", field: "type") } self.type = qtype offset = newOffset // Read class (big-endian) guard let (newOffset, rawClass) = buffer.copyOut(as: UInt16.self, offset: offset) else { throw DNSBindError.unmarshalFailure(type: "Question", field: "class") } guard let qclass = ResourceRecordClass(rawValue: UInt16(bigEndian: rawClass)) else { throw DNSBindError.unsupportedValue(type: "Question", field: "class") } self.recordClass = qclass return newOffset } } ================================================ FILE: Sources/DNSServer/Records/ResourceRecord.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Protocol for DNS resource records. public protocol ResourceRecord: Sendable { /// The domain name this record applies to. var name: String { get } /// The record type. var type: ResourceRecordType { get } /// The record class. var recordClass: ResourceRecordClass { get } /// Time to live in seconds. var ttl: UInt32 { get } /// Serialize this record into the buffer. func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int } /// A host record (A or AAAA) containing an IP address. public struct HostRecord: ResourceRecord { public let name: String public let type: ResourceRecordType public let recordClass: ResourceRecordClass public let ttl: UInt32 public let ip: T public init( name: String, ttl: UInt32 = 300, ip: T, recordClass: ResourceRecordClass = .internet ) { self.name = name self.type = T.recordType self.recordClass = recordClass self.ttl = ttl self.ip = ip } public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { let startOffset = offset var offset = offset // Write name let normalized = name.hasSuffix(".") ? String(name.dropLast()) : name let dnsName = try DNSName(labels: normalized.isEmpty ? [] : normalized.split(separator: ".", omittingEmptySubsequences: false).map(String.init)) offset = try dnsName.appendBuffer(&buffer, offset: offset) // Write type (big-endian) guard let newOffset = buffer.copyIn(as: UInt16.self, value: type.rawValue.bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "HostRecord", field: "type") } offset = newOffset // Write class (big-endian) guard let newOffset = buffer.copyIn(as: UInt16.self, value: recordClass.rawValue.bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "HostRecord", field: "class") } offset = newOffset // Write TTL (big-endian) guard let newOffset = buffer.copyIn(as: UInt32.self, value: ttl.bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "HostRecord", field: "ttl") } offset = newOffset // Write rdlength (big-endian) let rdlength = UInt16(T.size) guard let newOffset = buffer.copyIn(as: UInt16.self, value: rdlength.bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "HostRecord", field: "rdlength") } offset = newOffset // Write IP address bytes guard let newOffset = buffer.copyIn(buffer: ip.bytes, offset: offset) else { throw DNSBindError.marshalFailure(type: "HostRecord", field: "rdata") } let expectedOffset = startOffset + dnsName.size + 10 + T.size guard newOffset == expectedOffset else { throw DNSBindError.unexpectedOffset(type: "HostRecord", expected: expectedOffset, actual: newOffset) } return newOffset } } ================================================ FILE: Sources/DNSServer/Records/UInt8+Binding.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 // TODO: This copies some of the Bindable code from Containerization, // but we can't use Bindable as it presumes a fixed length record. // We can look at refining this later to see if we can use some common // bit fiddling code everywhere. extension [UInt8] { /// Copy a value into the buffer at the given offset. /// - Returns: The new offset after writing, or nil if the buffer is too small. package mutating func copyIn(as type: T.Type, value: T, offset: Int = 0) -> Int? { let size = MemoryLayout.size guard self.count >= size + offset else { return nil } return self.withUnsafeMutableBytes { $0.baseAddress?.advanced(by: offset).assumingMemoryBound(to: T.self).pointee = value return offset + size } } /// Copy a value out of the buffer at the given offset. /// - Returns: A tuple of (new offset, value), or nil if the buffer is too small. package func copyOut(as type: T.Type, offset: Int = 0) -> (Int, T)? { let size = MemoryLayout.size guard self.count >= size + offset else { return nil } return self.withUnsafeBytes { guard let value = $0.baseAddress?.advanced(by: offset).assumingMemoryBound(to: T.self).pointee else { return nil } return (offset + size, value) } } /// Copy a byte array into the buffer at the given offset. /// - Returns: The new offset after writing, or nil if the buffer is too small. package mutating func copyIn(buffer: [UInt8], offset: Int = 0) -> Int? { guard offset + buffer.count <= self.count else { return nil } self[offset.. Int? { guard offset + buffer.count <= self.count else { return nil } buffer[0...self) { group in group.addTask { log.info("starting XPC server") do { try await server.listen() return .success(()) } catch { return .failure(error) } } // start up host table DNS group.addTask { let hostsResolver = ContainerDNSHandler(networkService: networkService) let nxDomainResolver = NxDomainResolver() let compositeResolver = CompositeResolver(handlers: [hostsResolver, nxDomainResolver]) let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver) let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log) log.info( "starting DNS resolver for container hostnames", metadata: [ "host": "\(Self.listenAddress)", "port": "\(Self.dnsPort)", ] ) do { try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort) return .success(()) } catch { return .failure(error) } } // start up realhost DNS group.addTask { do { let localhostResolver = LocalhostDNSHandler(log: log) await localhostResolver.monitorResolvers() let nxDomainResolver = NxDomainResolver() let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver]) let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver) let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log) log.info( "starting DNS resolver for localhost", metadata: [ "host": "\(Self.listenAddress)", "port": "\(Self.localhostDNSPort)", ] ) try await dnsServer.run(host: Self.listenAddress, port: Self.localhostDNSPort) return .success(()) } catch { return .failure(error) } } for await result in group { switch result { case .success(): continue case .failure(let error): log.error("API server task failed: \(error)") } } } } catch { log.error( "helper failed", metadata: [ "name": "\(commandName)", "error": "\(error)", ]) APIServer.exit(withError: error) } } private func initializePluginLoader(log: Logger) throws -> PluginLoader { log.info( "initializing plugin loader", metadata: [ "installRoot": "\(installRoot.path(percentEncoded: false))" ]) let pluginsURL = PluginLoader.userPluginsDir(installRoot: installRoot) log.info("detecting user plugins directory", metadata: ["path": "\(pluginsURL.path(percentEncoded: false))"]) var directoryExists: ObjCBool = false _ = FileManager.default.fileExists(atPath: pluginsURL.path, isDirectory: &directoryExists) let userPluginsURL = directoryExists.boolValue ? pluginsURL : nil // plugins built into the application installed as a macOS app bundle let appBundlePluginsURL = Bundle.main.resourceURL?.appending(path: "plugins") // plugins built into the application installed as a Unix-like application let installRootPluginsURL = installRoot .appendingPathComponent("libexec") .appendingPathComponent("container") .appendingPathComponent("plugins") .standardized let pluginDirectories = [ userPluginsURL, appBundlePluginsURL, installRootPluginsURL, ].compactMap { $0 } let pluginFactories: [PluginFactory] = [ DefaultPluginFactory(), AppBundlePluginFactory(), ] for pluginDirectory in pluginDirectories { log.info("discovered plugin directory", metadata: ["path": "\(pluginDirectory.path(percentEncoded: false))"]) } return try PluginLoader( appRoot: appRoot, installRoot: installRoot, logRoot: logRoot, pluginDirectories: pluginDirectories, pluginFactories: pluginFactories, log: log ) } // First load all of the plugins we can find. Then just expose // the handlers for clients to do whatever they want. private func initializePlugins( pluginLoader: PluginLoader, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler] ) async throws { log.info("initializing plugins") let bootPlugins = pluginLoader.findPlugins().filter { $0.shouldBoot } let service = PluginsService(pluginLoader: pluginLoader, log: log) try await service.loadAll(bootPlugins) let harness = PluginsHarness(service: service, log: log) routes[XPCRoute.pluginGet] = harness.get routes[XPCRoute.pluginList] = harness.list routes[XPCRoute.pluginLoad] = harness.load routes[XPCRoute.pluginUnload] = harness.unload routes[XPCRoute.pluginRestart] = harness.restart } private func initializeHealthCheckService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) { log.info("initializing health check service") let svc = HealthCheckHarness( appRoot: appRoot, installRoot: installRoot, logRoot: logRoot, log: log ) routes[XPCRoute.ping] = svc.ping } private func initializeKernelService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws { log.info("initializing kernel service") let svc = try KernelService(log: log, appRoot: appRoot) let harness = KernelHarness(service: svc, log: log) routes[XPCRoute.installKernel] = harness.install routes[XPCRoute.getDefaultKernel] = harness.getDefaultKernel } private func initializeContainersService(pluginLoader: PluginLoader, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws -> ContainersService { log.info("initializing containers service") let service = try ContainersService( appRoot: appRoot, pluginLoader: pluginLoader, log: log, debugHelpers: debug ) let harness = ContainersHarness(service: service, log: log) routes[XPCRoute.containerList] = harness.list routes[XPCRoute.containerCreate] = harness.create routes[XPCRoute.containerDelete] = harness.delete routes[XPCRoute.containerLogs] = harness.logs routes[XPCRoute.containerBootstrap] = harness.bootstrap routes[XPCRoute.containerDial] = harness.dial routes[XPCRoute.containerStop] = harness.stop routes[XPCRoute.containerStartProcess] = harness.startProcess routes[XPCRoute.containerCreateProcess] = harness.createProcess routes[XPCRoute.containerResize] = harness.resize routes[XPCRoute.containerWait] = harness.wait routes[XPCRoute.containerKill] = harness.kill routes[XPCRoute.containerStats] = harness.stats routes[XPCRoute.containerDiskUsage] = harness.diskUsage routes[XPCRoute.containerExport] = harness.export return service } private func initializeNetworksService( pluginLoader: PluginLoader, containersService: ContainersService, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler] ) async throws -> NetworksService { log.info("initializing networks service") let resourceRoot = appRoot.appendingPathComponent("networks") let service = try await NetworksService( pluginLoader: pluginLoader, resourceRoot: resourceRoot, containersService: containersService, log: log, debugHelpers: debug ) let defaultNetwork = try await service.list() .filter { $0.isBuiltin } .first if defaultNetwork == nil { // FIXME: default network should be configurable elsewhere let config = try NetworkConfiguration( id: ClientNetwork.defaultNetworkName, mode: .nat, labels: [ResourceLabelKeys.role: ResourceRoleValues.builtin], pluginInfo: NetworkPluginInfo(plugin: "container-network-vmnet") ) _ = try await service.create(configuration: config) } let harness = NetworksHarness(service: service, log: log) routes[XPCRoute.networkCreate] = harness.create routes[XPCRoute.networkDelete] = harness.delete routes[XPCRoute.networkList] = harness.list return service } private func initializeVolumeService( containersService: ContainersService, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler] ) throws -> VolumesService { log.info("initializing volume service") let resourceRoot = appRoot.appendingPathComponent("volumes") let service = try VolumesService(resourceRoot: resourceRoot, containersService: containersService, log: log) let harness = VolumesHarness(service: service, log: log) routes[XPCRoute.volumeCreate] = harness.create routes[XPCRoute.volumeDelete] = harness.delete routes[XPCRoute.volumeList] = harness.list routes[XPCRoute.volumeInspect] = harness.inspect routes[XPCRoute.volumeDiskUsage] = harness.diskUsage return service } private func initializeDiskUsageService( containersService: ContainersService, volumesService: VolumesService, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler] ) throws { log.info("initializing disk usage service") let service = DiskUsageService( containersService: containersService, volumesService: volumesService, log: log ) let harness = DiskUsageHarness(service: service, log: log) routes[XPCRoute.systemDiskUsage] = harness.get } } } ================================================ FILE: Sources/Helpers/APIServer/APIServer.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerVersion @main struct APIServer: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "container-apiserver", abstract: "Container management API server", version: ReleaseVersion.singleLine(appName: "container-apiserver"), subcommands: [Start.self], ) } ================================================ FILE: Sources/Helpers/APIServer/ContainerDNSHandler.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIService import ContainerizationExtras import DNSServer /// Handler that uses table lookup to resolve hostnames. struct ContainerDNSHandler: DNSHandler { private let networkService: NetworksService private let ttl: UInt32 public init(networkService: NetworksService, ttl: UInt32 = 5) { self.networkService = networkService self.ttl = ttl } public func answer(query: Message) async throws -> Message? { guard let question = query.questions.first else { return nil } let record: ResourceRecord? switch question.type { case ResourceRecordType.host: record = try await answerHost(question: question) case ResourceRecordType.host6: let result = try await answerHost6(question: question) if result.record == nil && result.hostnameExists { // Return NODATA (noError with empty answers) when hostname exists but has no IPv6. // This is required because musl libc has issues when A record exists but AAAA returns NXDOMAIN. // musl treats NXDOMAIN on AAAA as "domain doesn't exist" and fails DNS resolution entirely. // NODATA correctly indicates "no IPv6 address available, but domain exists". return Message( id: query.id, type: .response, returnCode: .noError, questions: query.questions, answers: [] ) } record = result.record default: return Message( id: query.id, type: .response, returnCode: .notImplemented, questions: query.questions, answers: [] ) } guard let record else { return nil } return Message( id: query.id, type: .response, returnCode: .noError, questions: query.questions, answers: [record] ) } private func answerHost(question: Question) async throws -> ResourceRecord? { guard let ipAllocation = try await networkService.lookup(hostname: question.name) else { return nil } let ipv4 = ipAllocation.ipv4Address.address.description guard let ip = try? IPv4Address(ipv4) else { throw DNSResolverError.serverError("failed to parse IP address: \(ipv4)") } return HostRecord(name: question.name, ttl: ttl, ip: ip) } private func answerHost6(question: Question) async throws -> (record: ResourceRecord?, hostnameExists: Bool) { guard let ipAllocation = try await networkService.lookup(hostname: question.name) else { return (nil, false) } guard let ipv6Address = ipAllocation.ipv6Address else { return (nil, true) } let ipv6 = ipv6Address.address.description guard let ip = try? IPv6Address(ipv6) else { throw DNSResolverError.serverError("failed to parse IPv6 address: \(ipv6)") } return (HostRecord(name: question.name, ttl: ttl, ip: ip), true) } } ================================================ FILE: Sources/Helpers/APIServer/LocalhostDNSHandler.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import ContainerOS import ContainerPersistence import ContainerizationError import ContainerizationExtras import DNSServer import Foundation import Logging import Synchronization actor LocalhostDNSHandler: DNSHandler { private let ttl: UInt32 private let watcher: DirectoryWatcher private var dns: [DNSName: IPv4Address] public init(resolversURL: URL = HostDNSResolver.defaultConfigPath, ttl: UInt32 = 5, log: Logger) { self.ttl = ttl self.watcher = DirectoryWatcher(directoryURL: resolversURL, log: log) self.dns = [DNSName: IPv4Address]() } public func monitorResolvers() async { await self.watcher.startWatching { [weak self] fileURLs in var dns: [DNSName: IPv4Address] = [:] let regex = try Regex(HostDNSResolver.localhostOptionsRegex) for file in fileURLs.filter({ $0.lastPathComponent.starts(with: HostDNSResolver.containerizationPrefix) }) { let content = try String(contentsOf: file, encoding: .utf8) if let match = content.firstMatch(of: regex), let ipv4 = (match[1].substring.flatMap { try? IPv4Address(String($0)) }) { let name = String(file.lastPathComponent.dropFirst(HostDNSResolver.containerizationPrefix.count)) guard let dnsName = try? DNSName(name) else { continue } dns[dnsName] = ipv4 } } Task { await self?.updateDNS(dns) } } } public func answer(query: Message) async throws -> Message? { guard let question = query.questions.first else { return nil } let n = question.name.hasSuffix(".") ? String(question.name.dropLast()) : question.name let key = try DNSName(labels: n.isEmpty ? [] : n.split(separator: ".", omittingEmptySubsequences: false).map(String.init)) var record: ResourceRecord? switch question.type { case ResourceRecordType.host: if let ip = dns[key] { record = HostRecord(name: question.name, ttl: ttl, ip: ip) } case ResourceRecordType.host6: guard dns[key] != nil else { return nil } return Message( id: query.id, type: .response, returnCode: .noError, questions: query.questions, answers: [] ) default: return Message( id: query.id, type: .response, returnCode: .notImplemented, questions: query.questions, answers: [] ) } guard let record else { return nil } return Message( id: query.id, type: .response, returnCode: .noError, questions: query.questions, answers: [record] ) } private func updateDNS(_ dns: [DNSName: IPv4Address]) { self.dns = dns } } ================================================ FILE: Sources/Helpers/Images/ImagesHelper.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerImagesService import ContainerImagesServiceClient import ContainerLog import ContainerPlugin import ContainerVersion import ContainerXPC import Containerization import Foundation import Logging @main struct ImagesHelper: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "container-core-images", abstract: "XPC service for managing OCI images", version: ReleaseVersion.singleLine(appName: "container-core-images"), subcommands: [ Start.self ] ) } extension ImagesHelper { struct Start: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "start", abstract: "Starts the image plugin" ) @Flag(name: .long, help: "Enable debug logging") var debug = false @Option(name: .long, help: "XPC service prefix") var serviceIdentifier: String = "com.apple.container.core.container-core-images" var appRoot = ApplicationRoot.url var installRoot = InstallRoot.url var logRoot = LogRoot.path private static let unpackStrategy = SnapshotStore.defaultUnpackStrategy func run() async throws { let commandName = ImagesHelper._commandName let logPath = logRoot.map { $0.appending("\(commandName).log") } let log = ServiceLogger.bootstrap(category: "ImagesHelper", debug: debug, logPath: logPath) log.info("starting helper", metadata: ["name": "\(commandName)"]) defer { log.info("stopping helper", metadata: ["name": "\(commandName)"]) } do { log.info("configuring XPC server") var routes = [String: XPCServer.RouteHandler]() try self.initializeContentService(root: appRoot, log: log, routes: &routes) try self.initializeImagesService(root: appRoot, log: log, routes: &routes) let xpc = XPCServer( identifier: serviceIdentifier, routes: routes, log: log ) log.info("starting XPC server") try await xpc.listen() } catch { log.error( "helper failed", metadata: [ "name": "\(commandName)", "error": "\(error)", ]) ImagesHelper.exit(withError: error) } } private func initializeImagesService(root: URL, log: Logger, routes: inout [String: XPCServer.RouteHandler]) throws { let contentStore = RemoteContentStoreClient() let imageStore = try ImageStore(path: root, contentStore: contentStore) let snapshotStore = try SnapshotStore(path: root, unpackStrategy: Self.unpackStrategy, log: log) let service = try ImagesService(contentStore: contentStore, imageStore: imageStore, snapshotStore: snapshotStore, log: log) let harness = ImagesServiceHarness(service: service, log: log) routes[ImagesServiceXPCRoute.imagePull.rawValue] = harness.pull routes[ImagesServiceXPCRoute.imageList.rawValue] = harness.list routes[ImagesServiceXPCRoute.imageDelete.rawValue] = harness.delete routes[ImagesServiceXPCRoute.imageTag.rawValue] = harness.tag routes[ImagesServiceXPCRoute.imagePush.rawValue] = harness.push routes[ImagesServiceXPCRoute.imageSave.rawValue] = harness.save routes[ImagesServiceXPCRoute.imageLoad.rawValue] = harness.load routes[ImagesServiceXPCRoute.imageUnpack.rawValue] = harness.unpack routes[ImagesServiceXPCRoute.imageCleanupOrphanedBlobs.rawValue] = harness.cleanUpOrphanedBlobs routes[ImagesServiceXPCRoute.imageDiskUsage.rawValue] = harness.calculateDiskUsage routes[ImagesServiceXPCRoute.snapshotDelete.rawValue] = harness.deleteSnapshot routes[ImagesServiceXPCRoute.snapshotGet.rawValue] = harness.getSnapshot } private func initializeContentService(root: URL, log: Logger, routes: inout [String: XPCServer.RouteHandler]) throws { let service = try ContentStoreService(root: root, log: log) let harness = ContentServiceHarness(service: service, log: log) routes[ImagesServiceXPCRoute.contentClean.rawValue] = harness.clean routes[ImagesServiceXPCRoute.contentGet.rawValue] = harness.get routes[ImagesServiceXPCRoute.contentDelete.rawValue] = harness.delete routes[ImagesServiceXPCRoute.contentIngestStart.rawValue] = harness.newIngestSession routes[ImagesServiceXPCRoute.contentIngestCancel.rawValue] = harness.cancelIngestSession routes[ImagesServiceXPCRoute.contentIngestComplete.rawValue] = harness.completeIngestSession } } } ================================================ FILE: Sources/Helpers/NetworkVmnet/NetworkVmnetHelper+Start.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerLog import ContainerNetworkService import ContainerNetworkServiceClient import ContainerPlugin import ContainerResource import ContainerXPC import ContainerizationError import ContainerizationExtras import Foundation import Logging enum Variant: String, ExpressibleByArgument { case reserved case allocationOnly } extension NetworkMode: ExpressibleByArgument {} extension NetworkVmnetHelper { struct Start: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "start", abstract: "Starts the network plugin" ) @Flag(name: .long, help: "Enable debug logging") var debug = false @Option(name: .long, help: "XPC service identifier") var serviceIdentifier: String @Option(name: .shortAndLong, help: "Network identifier") var id: String @Option(name: .long, help: "Network mode") var mode: NetworkMode = .nat @Option(name: .customLong("subnet"), help: "CIDR address for the IPv4 subnet") var ipv4Subnet: String? @Option(name: .customLong("subnet-v6"), help: "CIDR address for the IPv6 prefix") var ipv6Subnet: String? @Option(name: .long, help: "Variant of the network helper to use.") var variant: Variant = { guard #available(macOS 26, *) else { return .allocationOnly } return .reserved }() var logRoot = LogRoot.path func run() async throws { let commandName = NetworkVmnetHelper._commandName let logPath = logRoot.map { $0.appending("\(commandName)-\(id).log") } let log = ServiceLogger.bootstrap(category: "NetworkVmnetHelper", metadata: ["id": "\(id)"], debug: debug, logPath: logPath) log.info("starting helper", metadata: ["name": "\(commandName)"]) defer { log.info("stopping helper", metadata: ["name": "\(commandName)"]) } do { log.info("configuring XPC server") let ipv4Subnet = try self.ipv4Subnet.map { try CIDRv4($0) } let ipv6Subnet = try self.ipv6Subnet.map { try CIDRv6($0) } let pluginInfo = NetworkPluginInfo( plugin: NetworkVmnetHelper._commandName, variant: self.variant.rawValue ) let configuration = try NetworkConfiguration( id: id, mode: mode, ipv4Subnet: ipv4Subnet, ipv6Subnet: ipv6Subnet, pluginInfo: pluginInfo ) let network = try Self.createNetwork( configuration: configuration, variant: self.variant, log: log ) try await network.start() let server = try await NetworkService(network: network, log: log) let xpc = XPCServer( identifier: serviceIdentifier, routes: [ NetworkRoutes.state.rawValue: server.state, NetworkRoutes.allocate.rawValue: server.allocate, NetworkRoutes.deallocate.rawValue: server.deallocate, NetworkRoutes.lookup.rawValue: server.lookup, NetworkRoutes.disableAllocator.rawValue: server.disableAllocator, ], log: log ) log.info("starting XPC server") try await xpc.listen() } catch { log.error( "helper failed", metadata: [ "name": "\(commandName)", "error": "\(error)", ]) NetworkVmnetHelper.exit(withError: error) } } private static func createNetwork(configuration: NetworkConfiguration, variant: Variant, log: Logger) throws -> Network { switch variant { case .allocationOnly: return try AllocationOnlyVmnetNetwork(configuration: configuration, log: log) case .reserved: guard #available(macOS 26, *) else { throw ContainerizationError( .invalidArgument, message: "variant ReservedVmnetNetwork is only available on macOS 26+" ) } return try ReservedVmnetNetwork(configuration: configuration, log: log) } } } } ================================================ FILE: Sources/Helpers/NetworkVmnet/NetworkVmnetHelper.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerVersion @main struct NetworkVmnetHelper: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "container-network-vmnet", abstract: "XPC service for managing a vmnet network", version: ReleaseVersion.singleLine(appName: "container-network-vmnet"), subcommands: [ Start.self ] ) } ================================================ FILE: Sources/Helpers/RuntimeLinux/IsolatedInterfaceStrategy.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import ContainerSandboxService import ContainerXPC import Containerization /// Isolated container network interface strategy. This strategy prohibits /// container to container networking, but it is the only approach that /// works for macOS Sequoia. struct IsolatedInterfaceStrategy: InterfaceStrategy { public func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) -> Interface { let ipv4Gateway = interfaceIndex == 0 ? attachment.ipv4Gateway : nil return NATInterface( ipv4Address: attachment.ipv4Address, ipv4Gateway: ipv4Gateway, macAddress: attachment.macAddress, // https://github.com/apple/containerization/pull/38 mtu: attachment.mtu ?? 1280 ) } } ================================================ FILE: Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import ContainerSandboxService import ContainerXPC import Containerization import ContainerizationError import Logging import Virtualization import vmnet /// Interface strategy for containers that use macOS's custom network feature. @available(macOS 26, *) struct NonisolatedInterfaceStrategy: InterfaceStrategy { private let log: Logger public init(log: Logger) { self.log = log } public func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) throws -> Interface { guard let additionalData else { throw ContainerizationError(.invalidState, message: "network state does not contain custom network reference") } var status: vmnet_return_t = .VMNET_SUCCESS guard let networkRef = vmnet_network_create_with_serialization(additionalData.underlying, &status) else { throw ContainerizationError(.invalidState, message: "cannot deserialize custom network reference, status \(status)") } log.info("creating NATNetworkInterface with network reference") let ipv4Gateway = interfaceIndex == 0 ? attachment.ipv4Gateway : nil return NATNetworkInterface( ipv4Address: attachment.ipv4Address, ipv4Gateway: ipv4Gateway, reference: networkRef, macAddress: attachment.macAddress, // https://github.com/apple/containerization/pull/38 mtu: attachment.mtu ?? 1280 ) } } ================================================ FILE: Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerLog import ContainerPlugin import ContainerResource import ContainerSandboxService import ContainerSandboxServiceClient import ContainerXPC import Foundation import Logging import NIO extension RuntimeLinuxHelper { struct Start: AsyncParsableCommand { static let label = "com.apple.container.runtime.container-runtime-linux" static let configuration = CommandConfiguration( commandName: "start", abstract: "Start helper for a Linux container" ) @Flag(name: .long, help: "Enable debug logging") var debug = false @Option(name: .shortAndLong, help: "Sandbox UUID") var uuid: String @Option(name: .shortAndLong, help: "Root directory for the sandbox") var root: String var logRoot = LogRoot.path var machServiceLabel: String { "\(Self.label).\(uuid)" } func run() async throws { let commandName = RuntimeLinuxHelper._commandName let logPath = logRoot.map { $0.appending("\(commandName)-\(uuid).log") } let log = ServiceLogger.bootstrap(category: "RuntimeLinuxHelper", metadata: ["uuid": "\(uuid)"], debug: debug, logPath: logPath) log.info("starting helper", metadata: ["name": "\(commandName)"]) defer { log.info("stopping helper", metadata: ["name": "\(commandName)"]) } let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) do { try adjustLimits() signal(SIGPIPE, SIG_IGN) // FIXME: The network plugins that the runtime supports should be configurable elsewhere var interfaceStrategies: [NetworkPluginInfo: InterfaceStrategy] = [ NetworkPluginInfo(plugin: "container-network-vmnet", variant: "allocationOnly"): IsolatedInterfaceStrategy() ] if #available(macOS 26, *) { interfaceStrategies[NetworkPluginInfo(plugin: "container-network-vmnet", variant: "reserved")] = NonisolatedInterfaceStrategy(log: log) } log.info("configuring XPC server") nonisolated(unsafe) let anonymousConnection = xpc_connection_create(nil, nil) let server = SandboxService( root: .init(fileURLWithPath: root), interfaceStrategies: interfaceStrategies, eventLoopGroup: eventLoopGroup, connection: anonymousConnection, log: log ) let endpointServer = XPCServer( identifier: machServiceLabel, routes: [ SandboxRoutes.createEndpoint.rawValue: server.createEndpoint ], log: log ) let mainServer = XPCServer( connection: anonymousConnection, routes: [ SandboxRoutes.bootstrap.rawValue: server.bootstrap, SandboxRoutes.createProcess.rawValue: server.createProcess, SandboxRoutes.state.rawValue: server.state, SandboxRoutes.stop.rawValue: server.stop, SandboxRoutes.kill.rawValue: server.kill, SandboxRoutes.resize.rawValue: server.resize, SandboxRoutes.wait.rawValue: server.wait, SandboxRoutes.start.rawValue: server.startProcess, SandboxRoutes.dial.rawValue: server.dial, SandboxRoutes.shutdown.rawValue: server.shutdown, SandboxRoutes.statistics.rawValue: server.statistics, ], log: log ) log.info("starting XPC server") try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { try await endpointServer.listen() } group.addTask { try await mainServer.listen() } defer { group.cancelAll() } _ = try await group.next() } } catch { log.error( "helper failed", metadata: [ "name": "\(commandName)", "error": "\(error)", ]) try? await eventLoopGroup.shutdownGracefully() RuntimeLinuxHelper.Start.exit(withError: error) } } private func adjustLimits() throws { var limits = rlimit() guard getrlimit(RLIMIT_NOFILE, &limits) == 0 else { throw POSIXError(.init(rawValue: errno)!) } limits.rlim_cur = 65536 limits.rlim_max = 65536 guard setrlimit(RLIMIT_NOFILE, &limits) == 0 else { throw POSIXError(.init(rawValue: errno)!) } } } } ================================================ FILE: Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR 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 ContainerVersion @main struct RuntimeLinuxHelper: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "container-runtime-linux", abstract: "XPC Service for managing a Linux sandbox", version: ReleaseVersion.singleLine(appName: "container-runtime-linux"), subcommands: [ Start.self ] ) } ================================================ FILE: Sources/Services/ContainerAPIService/Client/Arch.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// public enum Arch: String { case arm64, amd64 public init?(rawValue: String) { switch rawValue.lowercased() { case "arm64", "aarch64": self = .arm64 case "amd64", "x86_64", "x86-64": self = .amd64 default: return nil } } public static func hostArchitecture() -> Arch { #if arch(arm64) return .arm64 #elseif arch(x86_64) return .amd64 #endif } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/Archiver.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationArchive import ContainerizationOS import CryptoKit import Foundation public final class Archiver: Sendable { public struct ArchiveEntryInfo: Sendable, Codable { public let pathOnHost: URL public let pathInArchive: URL public let owner: UInt32? public let group: UInt32? public let permissions: UInt16? public init( pathOnHost: URL, pathInArchive: URL, owner: UInt32? = nil, group: UInt32? = nil, permissions: UInt16? = nil ) { self.pathOnHost = pathOnHost self.pathInArchive = pathInArchive self.owner = owner self.group = group self.permissions = permissions } } public static func compress( source: URL, destination: URL, followSymlinks: Bool = false, writerConfiguration: ArchiveWriterConfiguration = ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip), closure: (URL) -> ArchiveEntryInfo? ) throws -> SHA256.Digest { let source = source.standardizedFileURL let destination = destination.standardizedFileURL let fileManager = FileManager.default try? fileManager.removeItem(at: destination) var hasher = SHA256() do { let directory = destination.deletingLastPathComponent() try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) guard let enumerator = FileManager.default.enumerator(atPath: source.path) else { throw Error.fileDoesNotExist(source) } var entryInfo = [ArchiveEntryInfo]() if !source.isDirectory { if let info = closure(source) { entryInfo.append(info) } } else { let relPaths = enumerator.compactMap { $0 as? String } for relPath in relPaths.sorted(by: { $0 < $1 }) { let url = source.appending(path: relPath).standardizedFileURL guard let info = closure(url) else { continue } entryInfo.append(info) } } let archiver = try ArchiveWriter( configuration: writerConfiguration ) try archiver.open(file: destination) let encoder = JSONEncoder() encoder.outputFormatting = .sortedKeys for info in entryInfo { guard let entry = try Self._createEntry(entryInfo: info) else { throw Error.failedToCreateEntry } hasher.update(data: try encoder.encode(info)) try Self._compressFile(item: info.pathOnHost, entry: entry, archiver: archiver, hasher: &hasher) } try archiver.finishEncoding() } catch { try? fileManager.removeItem(at: destination) throw error } return hasher.finalize() } public static func uncompress(source: URL, destination: URL) throws { let source = source.standardizedFileURL let destination = destination.standardizedFileURL // TODO: ArchiveReader needs some enhancement to support buffered uncompression let reader = try ArchiveReader( format: .paxRestricted, filter: .gzip, file: source ) for (entry, data) in reader { guard let path = entry.path else { continue } let uncompressPath = destination.appendingPathComponent(path) let fileManager = FileManager.default switch entry.fileType { case .blockSpecial, .characterSpecial, .socket: continue case .directory: try fileManager.createDirectory( at: uncompressPath, withIntermediateDirectories: true, attributes: [ FileAttributeKey.posixPermissions: entry.permissions ] ) case .regular: try fileManager.createDirectory( at: uncompressPath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: [ FileAttributeKey.posixPermissions: 0o755 ] ) let success = fileManager.createFile( atPath: uncompressPath.path, contents: data, attributes: [ FileAttributeKey.posixPermissions: entry.permissions ] ) if !success { throw POSIXError.fromErrno() } try data.write(to: uncompressPath) case .symbolicLink: guard let target = entry.symlinkTarget else { continue } try fileManager.createDirectory( at: uncompressPath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: [ FileAttributeKey.posixPermissions: 0o755 ] ) try fileManager.createSymbolicLink(atPath: uncompressPath.path, withDestinationPath: target) continue default: continue } // FIXME: uid/gid for compress. try fileManager.setAttributes( [.posixPermissions: NSNumber(value: entry.permissions)], ofItemAtPath: uncompressPath.path ) if let creationDate = entry.creationDate { try fileManager.setAttributes( [.creationDate: creationDate], ofItemAtPath: uncompressPath.path ) } if let modificationDate = entry.modificationDate { try fileManager.setAttributes( [.modificationDate: modificationDate], ofItemAtPath: uncompressPath.path ) } } } // MARK: private functions private static func _compressFile(item: URL, entry: WriteEntry, archiver: ArchiveWriter, hasher: inout SHA256) throws { guard let stream = InputStream(url: item) else { return } let writer = archiver.makeTransactionWriter() let bufferSize = Int(1.mib()) let readBuffer = UnsafeMutablePointer.allocate(capacity: bufferSize) stream.open() try writer.writeHeader(entry: entry) while true { let byteRead = stream.read(readBuffer, maxLength: bufferSize) if byteRead <= 0 { break } else { let data = Data(bytes: readBuffer, count: byteRead) hasher.update(data: data) try data.withUnsafeBytes { pointer in try writer.writeChunk(data: pointer) } } } stream.close() try writer.finish() } private static func _createEntry(entryInfo: ArchiveEntryInfo, pathPrefix: String = "") throws -> WriteEntry? { let entry = WriteEntry() let fileManager = FileManager.default let attributes = try fileManager.attributesOfItem(atPath: entryInfo.pathOnHost.path) if let fileType = attributes[.type] as? FileAttributeType { switch fileType { case .typeBlockSpecial, .typeCharacterSpecial, .typeSocket: return nil case .typeDirectory: entry.fileType = .directory case .typeRegular: entry.fileType = .regular case .typeSymbolicLink: entry.fileType = .symbolicLink let symlinkTarget = try fileManager.destinationOfSymbolicLink(atPath: entryInfo.pathOnHost.path) entry.symlinkTarget = symlinkTarget default: return nil } } if let posixPermissions = attributes[.posixPermissions] as? NSNumber { #if os(macOS) entry.permissions = posixPermissions.uint16Value #else entry.permissions = posixPermissions.uint32Value #endif } if let fileSize = attributes[.size] as? UInt64 { entry.size = Int64(fileSize) } if let uid = attributes[.ownerAccountID] as? NSNumber { entry.owner = uid.uint32Value } if let gid = attributes[.groupOwnerAccountID] as? NSNumber { entry.group = gid.uint32Value } if let creationDate = attributes[.creationDate] as? Date { entry.creationDate = creationDate } if let modificationDate = attributes[.modificationDate] as? Date { entry.modificationDate = modificationDate } // Apply explicit overrides from ArchiveEntryInfo when provided if let overrideOwner = entryInfo.owner { entry.owner = overrideOwner } if let overrideGroup = entryInfo.group { entry.group = overrideGroup } if let overridePerm = entryInfo.permissions { #if os(macOS) entry.permissions = overridePerm #else entry.permissions = UInt32(overridePerm) #endif } let pathTrimmed = Self._trimPathPrefix(entryInfo.pathInArchive.relativePath, pathPrefix: pathPrefix) entry.path = pathTrimmed return entry } private static func _trimPathPrefix(_ path: String, pathPrefix: String) -> String { guard !path.isEmpty && !pathPrefix.isEmpty else { return path } let decodedPath = path.removingPercentEncoding ?? path guard decodedPath.hasPrefix(pathPrefix) else { return decodedPath } let trimmedPath = String(decodedPath.suffix(from: pathPrefix.endIndex)) return trimmedPath } private static func _isSymbolicLink(_ path: URL) throws -> Bool { let resourceValues = try path.resourceValues(forKeys: [.isSymbolicLinkKey]) if let isSymbolicLink = resourceValues.isSymbolicLink { if isSymbolicLink { return true } } return false } } extension Archiver { public enum Error: Swift.Error, CustomStringConvertible { case failedToCreateEntry case fileDoesNotExist(_ url: URL) public var description: String { switch self { case .failedToCreateEntry: return "failed to create entry" case .fileDoesNotExist(let url): return "file \(url.path) does not exist" } } } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/Array+Dedupe.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// extension Array where Element: Hashable { func dedupe() -> [Element] { var elems = Set() return filter { elems.insert($0).inserted } } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/ClientDiskUsage.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerXPC import ContainerizationError import Foundation /// Client API for disk usage operations public struct ClientDiskUsage { static let serviceIdentifier = "com.apple.container.apiserver" /// Get disk usage statistics for all resource types public static func get() async throws -> DiskUsageStats { let client = XPCClient(service: serviceIdentifier) let message = XPCMessage(route: .systemDiskUsage) let reply = try await client.send(message) guard let responseData = reply.dataNoCopy(key: .diskUsageStats) else { throw ContainerizationError( .internalError, message: "invalid response from server: missing disk usage data" ) } return try JSONDecoder().decode(DiskUsageStats.self, from: responseData) } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/ClientHealthCheck.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerXPC import ContainerizationError import Foundation import SystemPackage public enum ClientHealthCheck { static let serviceIdentifier = "com.apple.container.apiserver" } extension ClientHealthCheck { private static func newClient() -> XPCClient { XPCClient(service: serviceIdentifier) } public static func ping(timeout: Duration? = XPCClient.xpcRegistrationTimeout) async throws -> SystemHealth { let client = Self.newClient() let request = XPCMessage(route: .ping) let reply = try await client.send(request, responseTimeout: timeout) guard let appRootValue = reply.string(key: .appRoot), let appRoot = URL(string: appRootValue) else { throw ContainerizationError(.internalError, message: "failed to decode appRoot in health check") } guard let installRootValue = reply.string(key: .installRoot), let installRoot = URL(string: installRootValue) else { throw ContainerizationError(.internalError, message: "failed to decode installRoot in health check") } let logRoot = reply.string(key: .logRoot).map { FilePath($0) } guard let apiServerVersion = reply.string(key: .apiServerVersion) else { throw ContainerizationError(.internalError, message: "failed to decode apiServerVersion in health check") } guard let apiServerCommit = reply.string(key: .apiServerCommit) else { throw ContainerizationError(.internalError, message: "failed to decode apiServerCommit in health check") } guard let apiServerBuild = reply.string(key: .apiServerBuild) else { throw ContainerizationError(.internalError, message: "failed to decode apiServerBuild in health check") } guard let apiServerAppName = reply.string(key: .apiServerAppName) else { throw ContainerizationError(.internalError, message: "failed to decode apiServerAppName in health check") } return .init( appRoot: appRoot, installRoot: installRoot, logRoot: logRoot, apiServerVersion: apiServerVersion, apiServerCommit: apiServerCommit, apiServerBuild: apiServerBuild, apiServerAppName: apiServerAppName ) } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/ClientImage.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerImagesServiceClient import ContainerPersistence import ContainerResource import ContainerXPC import Containerization import ContainerizationError import ContainerizationExtras import ContainerizationOCI import Foundation import TerminalProgress // MARK: ClientImage structure public struct ClientImage: Sendable { private let contentStore: ContentStore = RemoteContentStoreClient() public let description: ImageDescription public var digest: String { description.digest } public var descriptor: Descriptor { description.descriptor } public var reference: String { description.reference } public init(description: ImageDescription) { self.description = description } /// Returns the underlying OCI index for the image. public func index() async throws -> Index { guard let content: Content = try await contentStore.get(digest: description.digest) else { throw ContainerizationError(.notFound, message: "content with digest \(description.digest)") } return try content.decode() } /// Returns the manifest for the specified platform. public func manifest(for platform: Platform) async throws -> Manifest { let index = try await self.index() let desc = index.manifests.first { desc in desc.platform == platform } guard let desc else { throw ContainerizationError(.unsupported, message: "platform \(platform.description)") } guard let content: Content = try await contentStore.get(digest: desc.digest) else { throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)") } return try content.decode() } /// Returns the OCI config for the specified platform. public func config(for platform: Platform) async throws -> ContainerizationOCI.Image { let manifest = try await self.manifest(for: platform) let desc = manifest.config guard let content: Content = try await contentStore.get(digest: desc.digest) else { throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)") } return try content.decode() } /// Returns the resolved OCI descriptor for the image. package func resolved() async throws -> Descriptor { let index = try await self.index() let indirect = index.annotations?[AnnotationKeys.containerizationIndexIndirect] // If this is not an indirect index, return its own descriptor guard let indirect, ["1", "true"].contains(indirect.lowercased()) else { return self.descriptor } // For indirect indices, return the first (and only) manifest guard let manifest = index.manifests.first else { throw ContainerizationError( .internalError, message: "failed to resolve indirect index \(self.digest): no manifests found" ) } return manifest } } // MARK: ClientImage constants extension ClientImage { private static let serviceIdentifier = "com.apple.container.core.container-core-images" public static let initImageRef = DefaultsStore.get(key: .defaultInitImage) private static func newXPCClient() -> XPCClient { XPCClient(service: Self.serviceIdentifier) } private static func newRequest(_ route: ImagesServiceXPCRoute) -> XPCMessage { XPCMessage(route: route) } private static var defaultRegistryDomain: String { DefaultsStore.get(key: .defaultRegistryDomain) } } // MARK: Static methods extension ClientImage { private static let legacyDockerRegistryHost = "docker.io" private static let dockerRegistryHost = "registry-1.docker.io" private static let defaultDockerRegistryRepo = "library" public static func normalizeReference(_ ref: String) throws -> String { guard ref != Self.initImageRef else { // Don't modify the default init image reference. // This is to allow for easier local development against // an updated containerization. return ref } // Check if the input reference has a domain specified var updatedRawReference: String = ref let r = try Reference.parse(ref) if r.domain == nil { updatedRawReference = "\(Self.defaultRegistryDomain)/\(ref)" } let updatedReference = try Reference.parse(updatedRawReference) // Handle adding the :latest tag if it isn't specified, // as well as adding the "library/" repository if it isn't set only if the host is docker.io updatedReference.normalize() return updatedReference.description } public static func denormalizeReference(_ ref: String) throws -> String { var updatedRawReference: String = ref let r = try Reference.parse(ref) let defaultRegistry = Self.defaultRegistryDomain if r.domain == defaultRegistry { updatedRawReference = "\(r.path)" if let tag = r.tag { updatedRawReference += ":\(tag)" } else if let digest = r.digest { updatedRawReference += "@\(digest)" } if defaultRegistry == dockerRegistryHost || defaultRegistry == legacyDockerRegistryHost { updatedRawReference.trimPrefix("\(defaultDockerRegistryRepo)/") } } return updatedRawReference } public static func list() async throws -> [ClientImage] { let client = newXPCClient() let request = newRequest(.imageList) let response = try await client.send(request) let imageDescriptions = try response.imageDescriptions() return imageDescriptions.map { desc in ClientImage(description: desc) } } public static func get(names: [String]) async throws -> (images: [ClientImage], error: [String]) { let all = try await self.list() var errors: [String] = [] var found: [ClientImage] = [] for name in names { do { guard let img = try Self._search(reference: name, in: all) else { errors.append(name) continue } found.append(img) } catch { errors.append(name) } } return (found, errors) } public static func get(reference: String) async throws -> ClientImage { let all = try await self.list() guard let found = try self._search(reference: reference, in: all) else { throw ContainerizationError(.notFound, message: "image with reference \(reference)") } return found } /// Returns the total size of an image in bytes. /// - Parameter image: The image to get the size for. /// - Returns: The full image size in bytes. /// - Throws: An error if the image cannot be retrieved. public static func getFullImageSize(image: ClientImage) async throws -> Int64 { for descriptor in try await image.index().manifests { if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], referenceType == "attestation-manifest" { continue } guard let platform = descriptor.platform else { continue } do { let manifest = try await image.manifest(for: platform) return descriptor.size + manifest.config.size + manifest.layers.reduce(0) { $0 + $1.size } } catch { continue } } return 0 } private static func _search(reference: String, in all: [ClientImage]) throws -> ClientImage? { let locallyBuiltImage = try { // Check if we have an image whose index descriptor contains the image name // as an annotation. Prefer this in all cases, since these are locally built images. let r = try Reference.parse(reference) r.normalize() let withDefaultTag = r.description let localImageMatches = all.filter { $0.description.nameFromAnnotation() == withDefaultTag } guard localImageMatches.count > 1 else { return localImageMatches.first } // More than one image matched. Check against the tagged reference return localImageMatches.first { $0.reference == withDefaultTag } }() if let locallyBuiltImage { return locallyBuiltImage } // If we don't find a match, try matching `ImageDescription.name` against the given // input string, while also checking against its normalized form. // Return the first match. let normalizedReference = try Self.normalizeReference(reference) return all.first(where: { image in image.reference == reference || image.reference == normalizedReference }) } public static func pull( reference: String, platform: Platform? = nil, scheme: RequestScheme = .auto, progressUpdate: ProgressUpdateHandler? = nil, maxConcurrentDownloads: Int = 3 ) async throws -> ClientImage { guard maxConcurrentDownloads > 0 else { throw ContainerizationError(.invalidArgument, message: "maximum number of concurrent downloads must be greater than 0, got \(maxConcurrentDownloads)") } let client = newXPCClient() let request = newRequest(.imagePull) let reference = try self.normalizeReference(reference) guard let host = try Reference.parse(reference).domain else { throw ContainerizationError(.invalidArgument, message: "could not extract host from reference \(reference)") } request.set(key: .imageReference, value: reference) try request.set(platform: platform) let insecure = try scheme.schemeFor(host: host) == .http request.set(key: .insecureFlag, value: insecure) request.set(key: .maxConcurrentDownloads, value: Int64(maxConcurrentDownloads)) var progressUpdateClient: ProgressUpdateClient? if let progressUpdate { progressUpdateClient = await ProgressUpdateClient(for: progressUpdate, request: request) } let response = try await client.send(request) let description = try response.imageDescription() let image = ClientImage(description: description) await progressUpdateClient?.finish() return image } public static func delete(reference: String, garbageCollect: Bool = false) async throws { let client = newXPCClient() let request = newRequest(.imageDelete) request.set(key: .imageReference, value: reference) request.set(key: .garbageCollect, value: garbageCollect) let _ = try await client.send(request) } public static func save(references: [String], out: String, platform: Platform? = nil) async throws { let (clientImages, errors) = try await get(names: references) guard errors.isEmpty else { // TODO: Improve error handling here throw ContainerizationError(.invalidArgument, message: "one or more image references are invalid: \(errors.joined(separator: ", "))") } let descriptions = clientImages.map { $0.description } let client = Self.newXPCClient() let request = Self.newRequest(.imageSave) try request.set(descriptions: descriptions) request.set(key: .filePath, value: out) try request.set(platform: platform) let _ = try await client.send(request) } public static func load(from tarFile: String, force: Bool = false) async throws -> ImageLoadResult { let client = newXPCClient() let request = newRequest(.imageLoad) request.set(key: .filePath, value: tarFile) request.set(key: .forceLoad, value: force) let reply = try await client.send(request) let (descriptions, rejectedMembers) = try reply.loadResults() let images = descriptions.map { desc in ClientImage(description: desc) } return ImageLoadResult(images: images, rejectedMembers: rejectedMembers) } public static func cleanUpOrphanedBlobs() async throws -> ([String], UInt64) { let client = newXPCClient() let request = newRequest(.imageCleanupOrphanedBlobs) let response = try await client.send(request) let digests = try response.digests() let size = response.uint64(key: .imageSize) return (digests, size) } /// Calculate disk usage for images /// - Parameter activeReferences: Set of image references currently in use by containers /// - Returns: Tuple of (total count, active count, total size, reclaimable size) public static func calculateDiskUsage(activeReferences: Set) async throws -> (totalCount: Int, activeCount: Int, totalSize: UInt64, reclaimableSize: UInt64) { let client = newXPCClient() let request = newRequest(.imageDiskUsage) // Encode active references let activeRefsData = try JSONEncoder().encode(activeReferences) request.set(key: .activeImageReferences, value: activeRefsData) let response = try await client.send(request) let total = Int(response.int64(key: .totalCount)) let active = Int(response.int64(key: .activeCount)) let size = response.uint64(key: .imageSize) let reclaimable = response.uint64(key: .reclaimableSize) return (totalCount: total, activeCount: active, totalSize: size, reclaimableSize: reclaimable) } public static func fetch( reference: String, platform: Platform? = nil, scheme: RequestScheme = .auto, progressUpdate: ProgressUpdateHandler? = nil, maxConcurrentDownloads: Int = 3 ) async throws -> ClientImage { do { let match = try await self.get(reference: reference) if let platform { // The image exists, but we dont know if we have the right platform pulled // Check if we do, if not pull the requested platform _ = try await match.config(for: platform) } return match } catch let err as ContainerizationError { guard err.isCode(.notFound) else { throw err } return try await Self.pull(reference: reference, platform: platform, scheme: scheme, progressUpdate: progressUpdate, maxConcurrentDownloads: maxConcurrentDownloads) } } } // MARK: Instance methods extension ClientImage { public func push(platform: Platform? = nil, scheme: RequestScheme, progressUpdate: ProgressUpdateHandler?) async throws { let client = Self.newXPCClient() let request = Self.newRequest(.imagePush) guard let host = try Reference.parse(reference).domain else { throw ContainerizationError(.invalidArgument, message: "could not extract host from reference \(reference)") } request.set(key: .imageReference, value: reference) let insecure = try scheme.schemeFor(host: host) == .http request.set(key: .insecureFlag, value: insecure) try request.set(platform: platform) var progressUpdateClient: ProgressUpdateClient? if let progressUpdate { progressUpdateClient = await ProgressUpdateClient(for: progressUpdate, request: request) } _ = try await client.send(request) await progressUpdateClient?.finish() } @discardableResult public func tag(new: String) async throws -> ClientImage { let client = Self.newXPCClient() let request = Self.newRequest(.imageTag) request.set(key: .imageReference, value: self.description.reference) request.set(key: .imageNewReference, value: new) let reply = try await client.send(request) let description = try reply.imageDescription() return ClientImage(description: description) } // MARK: Snapshot Methods public func unpack(platform: Platform?, progressUpdate: ProgressUpdateHandler? = nil) async throws { let client = Self.newXPCClient() let request = Self.newRequest(.imageUnpack) try request.set(description: description) try request.set(platform: platform) var progressUpdateClient: ProgressUpdateClient? if let progressUpdate { progressUpdateClient = await ProgressUpdateClient(for: progressUpdate, request: request) } try await client.send(request) await progressUpdateClient?.finish() } public func deleteSnapshot(platform: Platform?) async throws { let client = Self.newXPCClient() let request = Self.newRequest(.snapshotDelete) try request.set(description: description) try request.set(platform: platform) try await client.send(request) } public func getSnapshot(platform: Platform) async throws -> Filesystem { let client = Self.newXPCClient() let request = Self.newRequest(.snapshotGet) try request.set(description: description) try request.set(platform: platform) let response = try await client.send(request) let fs = try response.filesystem() return fs } @discardableResult public func getCreateSnapshot(platform: Platform, progressUpdate: ProgressUpdateHandler? = nil) async throws -> Filesystem { do { return try await self.getSnapshot(platform: platform) } catch let err as ContainerizationError { guard err.code == .notFound else { throw err } try await self.unpack(platform: platform, progressUpdate: progressUpdate) return try await self.getSnapshot(platform: platform) } } } extension XPCMessage { fileprivate func set(description: ImageDescription) throws { let descData = try JSONEncoder().encode(description) self.set(key: .imageDescription, value: descData) } fileprivate func set(descriptions: [ImageDescription]) throws { let descData = try JSONEncoder().encode(descriptions) self.set(key: .imageDescriptions, value: descData) } fileprivate func set(platform: Platform?) throws { guard let platform else { return } let platformData = try JSONEncoder().encode(platform) self.set(key: .ociPlatform, value: platformData) } fileprivate func imageDescription() throws -> ImageDescription { let responseData = self.dataNoCopy(key: .imageDescription) guard let responseData else { throw ContainerizationError(.empty, message: "imageDescription not received") } let description = try JSONDecoder().decode(ImageDescription.self, from: responseData) return description } fileprivate func imageDescriptions() throws -> [ImageDescription] { let imagesData = self.dataNoCopy(key: .imageDescriptions) guard let imagesData else { throw ContainerizationError(.empty, message: "imageDescriptions not received") } let descriptions = try JSONDecoder().decode([ImageDescription].self, from: imagesData) return descriptions } fileprivate func loadResults() throws -> ([ImageDescription], [String]) { let imagesData = self.dataNoCopy(key: .imageDescriptions) guard let imagesData else { throw ContainerizationError(.empty, message: "imageDescriptions not received") } let descriptions = try JSONDecoder().decode([ImageDescription].self, from: imagesData) let rejectedMembersData = self.dataNoCopy(key: .rejectedMembers) guard let rejectedMembersData else { throw ContainerizationError(.empty, message: "rejectedMembers not received") } let rejectedMembers = try JSONDecoder().decode([String].self, from: rejectedMembersData) return (descriptions, rejectedMembers) } fileprivate func filesystem() throws -> Filesystem { let responseData = self.dataNoCopy(key: .filesystem) guard let responseData else { throw ContainerizationError(.empty, message: "filesystem not received") } let fs = try JSONDecoder().decode(Filesystem.self, from: responseData) return fs } fileprivate func digests() throws -> [String] { let responseData = self.dataNoCopy(key: .digests) guard let responseData else { throw ContainerizationError(.empty, message: "digests not received") } let digests = try JSONDecoder().decode([String].self, from: responseData) return digests } } extension ImageDescription { fileprivate func nameFromAnnotation() -> String? { guard let annotations = self.descriptor.annotations else { return nil } guard let name = annotations[AnnotationKeys.containerizationImageName] else { return nil } return name } } extension ClientImage { public func details() async throws -> ImageDetail { let descriptor = try await self.resolved() let reference = self.reference var variants: [ImageDetail.Variants] = [] for desc in try await self.index().manifests { guard let platform = desc.platform else { continue } let config: ContainerizationOCI.Image let manifest: ContainerizationOCI.Manifest do { config = try await self.config(for: platform) manifest = try await self.manifest(for: platform) } catch { continue } let size = desc.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size }) variants.append(.init(platform: platform, size: size, config: config)) } return ImageDetail(name: reference, index: descriptor, variants: variants) } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/ClientKernel.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerXPC import Containerization import ContainerizationError import ContainerizationOCI import Foundation import TerminalProgress public struct ClientKernel { static let serviceIdentifier = "com.apple.container.apiserver" } extension ClientKernel { private static func newClient() -> XPCClient { XPCClient(service: serviceIdentifier) } public static func installKernel(kernelFilePath: String, platform: SystemPlatform, force: Bool) async throws { let client = newClient() let message = XPCMessage(route: .installKernel) message.set(key: .kernelFilePath, value: kernelFilePath) message.set(key: .kernelForce, value: force) let platformData = try JSONEncoder().encode(platform) message.set(key: .systemPlatform, value: platformData) try await client.send(message) } public static func installKernelFromTar(tarFile: String, kernelFilePath: String, platform: SystemPlatform, progressUpdate: ProgressUpdateHandler? = nil, force: Bool) async throws { let client = newClient() let message = XPCMessage(route: .installKernel) message.set(key: .kernelTarURL, value: tarFile) message.set(key: .kernelFilePath, value: kernelFilePath) message.set(key: .kernelForce, value: force) let platformData = try JSONEncoder().encode(platform) message.set(key: .systemPlatform, value: platformData) var progressUpdateClient: ProgressUpdateClient? if let progressUpdate { progressUpdateClient = await ProgressUpdateClient(for: progressUpdate, request: message) } try await client.send(message) await progressUpdateClient?.finish() } @discardableResult public static func getDefaultKernel(for platform: SystemPlatform) async throws -> Kernel { let client = newClient() let message = XPCMessage(route: .getDefaultKernel) let platformData = try JSONEncoder().encode(platform) message.set(key: .systemPlatform, value: platformData) do { let reply = try await client.send(message) guard let kData = reply.dataNoCopy(key: .kernel) else { throw ContainerizationError(.internalError, message: "missing kernel data from XPC response") } let kernel = try JSONDecoder().decode(Kernel.self, from: kData) return kernel } catch let err as ContainerizationError { guard err.isCode(.notFound) else { throw err } throw ContainerizationError( .notFound, message: "default kernel not configured for architecture \(platform.architecture), please use the `container system kernel set` command to configure it") } } } extension SystemPlatform { public static var current: SystemPlatform { switch Platform.current.architecture { case "arm64": return .linuxArm case "amd64": return .linuxAmd default: fatalError("unknown architecture") } } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/ClientNetwork.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import ContainerXPC import ContainerizationError import ContainerizationExtras import ContainerizationOS import Foundation public struct ClientNetwork { static let serviceIdentifier = "com.apple.container.apiserver" public static let defaultNetworkName = "default" public static let noNetworkName = "none" } extension ClientNetwork { private static func newClient() -> XPCClient { XPCClient(service: serviceIdentifier) } private static func xpcSend( client: XPCClient, message: XPCMessage, timeout: Duration? = XPCClient.xpcRegistrationTimeout ) async throws -> XPCMessage { try await client.send(message, responseTimeout: timeout) } public static func create(configuration: NetworkConfiguration) async throws -> NetworkState { let client = Self.newClient() let request = XPCMessage(route: .networkCreate) request.set(key: .networkId, value: configuration.id) let data = try JSONEncoder().encode(configuration) request.set(key: .networkConfig, value: data) let response = try await xpcSend(client: client, message: request) let responseData = response.dataNoCopy(key: .networkState) guard let responseData else { throw ContainerizationError(.invalidArgument, message: "network configuration not received") } let state = try JSONDecoder().decode(NetworkState.self, from: responseData) return state } public static func list() async throws -> [NetworkState] { let client = Self.newClient() let request = XPCMessage(route: .networkList) let response = try await xpcSend(client: client, message: request, timeout: .seconds(1)) let responseData = response.dataNoCopy(key: .networkStates) guard let responseData else { return [] } let states = try JSONDecoder().decode([NetworkState].self, from: responseData) return states } /// Get the network for the provided id. public static func get(id: String) async throws -> NetworkState { let networks = try await list() guard let network = networks.first(where: { $0.id == id }) else { throw ContainerizationError(.notFound, message: "network \(id) not found") } return network } /// Delete the network with the given id. public static func delete(id: String) async throws { let client = Self.newClient() let request = XPCMessage(route: .networkDelete) request.set(key: .networkId, value: id) let _ = try await xpcSend(client: client, message: request) } /// Retrieve the builtin network. public static var builtin: NetworkState? { get async throws { try await list().first { $0.isBuiltin } } } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/ClientProcess.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerXPC import Containerization import ContainerizationError import ContainerizationOCI import ContainerizationOS import Foundation import NIOCore import NIOPosix import TerminalProgress /// A protocol that defines the methods and data members available to a process /// started inside of a container. public protocol ClientProcess: Sendable { /// Identifier for the process. var id: String { get } /// Start the underlying process inside of the container. func start() async throws /// Send a terminal resize request to the process `id`. func resize(_ size: Terminal.Size) async throws /// Send a signal to the process `id`. /// Kill does not wait for the process to exit, it only delivers the signal. func kill(_ signal: Int32) async throws /// Wait for the process `id` to complete and return its exit code. /// This method blocks until the process exits and the code is obtained. func wait() async throws -> Int32 } struct ClientProcessImpl: ClientProcess, Sendable { static let serviceIdentifier = "com.apple.container.apiserver" /// ID of the process. public var id: String { processId ?? containerId } /// Identifier of the container. public let containerId: String /// Identifier of a process. That is running inside of a container. /// This field is nil if the process this objects refers to is the /// init process of the container. public let processId: String? private let xpcClient: XPCClient init(containerId: String, processId: String? = nil, xpcClient: XPCClient) { self.containerId = containerId self.processId = processId self.xpcClient = xpcClient } /// Start the process. public func start() async throws { let request = XPCMessage(route: .containerStartProcess) request.set(key: .id, value: containerId) request.set(key: .processIdentifier, value: id) try await xpcClient.send(request) } /// Send a signal to the process. public func kill(_ signal: Int32) async throws { let request = XPCMessage(route: .containerKill) request.set(key: .id, value: containerId) request.set(key: .processIdentifier, value: id) request.set(key: .signal, value: Int64(signal)) try await xpcClient.send(request) } /// Resize the processes PTY if it has one. public func resize(_ size: Terminal.Size) async throws { let request = XPCMessage(route: .containerResize) request.set(key: .id, value: containerId) request.set(key: .processIdentifier, value: id) request.set(key: .width, value: UInt64(size.width)) request.set(key: .height, value: UInt64(size.height)) try await xpcClient.send(request) } /// Wait for the process to exit. public func wait() async throws -> Int32 { let request = XPCMessage(route: .containerWait) request.set(key: .id, value: containerId) request.set(key: .processIdentifier, value: id) let response = try await xpcClient.send(request) let code = response.int64(key: .exitCode) return Int32(code) } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/ClientVolume.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import ContainerXPC import Containerization import Foundation public struct ClientVolume { static let serviceIdentifier = "com.apple.container.apiserver" public static func create( name: String, driver: String = "local", driverOpts: [String: String] = [:], labels: [String: String] = [:] ) async throws -> Volume { let client = XPCClient(service: serviceIdentifier) let message = XPCMessage(route: .volumeCreate) message.set(key: .volumeName, value: name) message.set(key: .volumeDriver, value: driver) let driverOptsData = try JSONEncoder().encode(driverOpts) message.set(key: .volumeDriverOpts, value: driverOptsData) let labelsData = try JSONEncoder().encode(labels) message.set(key: .volumeLabels, value: labelsData) let reply = try await client.send(message) guard let responseData = reply.dataNoCopy(key: .volume) else { throw VolumeError.storageError("invalid response from server") } return try JSONDecoder().decode(Volume.self, from: responseData) } public static func delete(name: String) async throws { let client = XPCClient(service: serviceIdentifier) let message = XPCMessage(route: .volumeDelete) message.set(key: .volumeName, value: name) _ = try await client.send(message) } public static func list() async throws -> [Volume] { let client = XPCClient(service: serviceIdentifier) let message = XPCMessage(route: .volumeList) let reply = try await client.send(message) guard let responseData = reply.dataNoCopy(key: .volumes) else { return [] } return try JSONDecoder().decode([Volume].self, from: responseData) } public static func inspect(_ name: String) async throws -> Volume { let client = XPCClient(service: serviceIdentifier) let message = XPCMessage(route: .volumeInspect) message.set(key: .volumeName, value: name) let reply = try await client.send(message) guard let responseData = reply.dataNoCopy(key: .volume) else { throw VolumeError.volumeNotFound(name) } return try JSONDecoder().decode(Volume.self, from: responseData) } public static func volumeDiskUsage(name: String) async throws -> UInt64 { let client = XPCClient(service: serviceIdentifier) let message = XPCMessage(route: .volumeDiskUsage) message.set(key: .volumeName, value: name) let reply = try await client.send(message) let size = reply.uint64(key: .volumeSize) return size } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/Constants.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// /// Global constants for the container API clients. public enum Constants { /// The keychain ID to use for registry credentials. public static let keychainID = "com.apple.container.registry" } ================================================ FILE: Sources/Services/ContainerAPIService/Client/ContainerClient.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import ContainerXPC import Containerization import ContainerizationError import ContainerizationOCI import Foundation /// A client for interacting with the container API server. /// /// This client holds a reusable XPC connection and provides methods for /// container lifecycle operations. All methods that operate on a specific /// container take an `id` parameter. public struct ContainerClient: Sendable { private static let serviceIdentifier = "com.apple.container.apiserver" private let xpcClient: XPCClient /// Creates a new container client with a connection to the API server. public init() { self.xpcClient = XPCClient(service: Self.serviceIdentifier) } @discardableResult private func xpcSend( message: XPCMessage, timeout: Duration? = XPCClient.xpcRegistrationTimeout ) async throws -> XPCMessage { try await xpcClient.send(message, responseTimeout: timeout) } /// Create a new container with the given configuration. public func create( configuration: ContainerConfiguration, options: ContainerCreateOptions = .default, kernel: Kernel, initImage: String? = nil ) async throws { do { let request = XPCMessage(route: .containerCreate) let data = try JSONEncoder().encode(configuration) let kdata = try JSONEncoder().encode(kernel) let odata = try JSONEncoder().encode(options) request.set(key: .containerConfig, value: data) request.set(key: .kernel, value: kdata) request.set(key: .containerOptions, value: odata) if let initImage { request.set(key: .initImage, value: initImage) } try await xpcSend(message: request) } catch { throw ContainerizationError( .internalError, message: "failed to create container", cause: error ) } } /// List containers matching the given filters. public func list(filters: ContainerListFilters = .all) async throws -> [ContainerSnapshot] { do { let request = XPCMessage(route: .containerList) let filterData = try JSONEncoder().encode(filters) request.set(key: .listFilters, value: filterData) let response = try await xpcSend( message: request, timeout: .seconds(10) ) let data = response.dataNoCopy(key: .containers) guard let data else { return [] } return try JSONDecoder().decode([ContainerSnapshot].self, from: data) } catch { throw ContainerizationError( .internalError, message: "failed to list containers", cause: error ) } } /// Get the container for the provided id. public func get(id: String) async throws -> ContainerSnapshot { let containers = try await list(filters: ContainerListFilters(ids: [id])) guard let container = containers.first else { throw ContainerizationError( .notFound, message: "get failed: container \(id) not found" ) } return container } /// Bootstrap the container's init process. public func bootstrap(id: String, stdio: [FileHandle?]) async throws -> ClientProcess { let request = XPCMessage(route: .containerBootstrap) for (i, h) in stdio.enumerated() { let key: XPCKeys = try { switch i { case 0: .stdin case 1: .stdout case 2: .stderr default: throw ContainerizationError(.invalidArgument, message: "invalid fd \(i)") } }() if let h { request.set(key: key, value: h) } } do { request.set(key: .id, value: id) try await xpcClient.send(request) return ClientProcessImpl(containerId: id, xpcClient: xpcClient) } catch { throw ContainerizationError( .internalError, message: "failed to bootstrap container", cause: error ) } } /// Send a signal to the container. public func kill(id: String, signal: Int32) async throws { do { let request = XPCMessage(route: .containerKill) request.set(key: .id, value: id) request.set(key: .processIdentifier, value: id) request.set(key: .signal, value: Int64(signal)) try await xpcClient.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to kill container", cause: error ) } } /// Stop the container and all processes currently executing inside. public func stop(id: String, opts: ContainerStopOptions = ContainerStopOptions.default) async throws { do { let request = XPCMessage(route: .containerStop) let data = try JSONEncoder().encode(opts) request.set(key: .id, value: id) request.set(key: .stopOptions, value: data) try await xpcClient.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to stop container", cause: error ) } } /// Delete the container along with any resources. public func delete(id: String, force: Bool = false) async throws { do { let request = XPCMessage(route: .containerDelete) request.set(key: .id, value: id) request.set(key: .forceDelete, value: force) try await xpcClient.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to delete container", cause: error ) } } /// Get the disk usage for a container. public func diskUsage(id: String) async throws -> UInt64 { let request = XPCMessage(route: .containerDiskUsage) request.set(key: .id, value: id) let reply = try await xpcClient.send(request) let size = reply.uint64(key: .containerSize) return size } /// Create a new process inside a running container. /// The process is in a created state and must still be started. public func createProcess( containerId: String, processId: String, configuration: ProcessConfiguration, stdio: [FileHandle?] ) async throws -> ClientProcess { do { let request = XPCMessage(route: .containerCreateProcess) request.set(key: .id, value: containerId) request.set(key: .processIdentifier, value: processId) let data = try JSONEncoder().encode(configuration) request.set(key: .processConfig, value: data) for (i, h) in stdio.enumerated() { let key: XPCKeys = try { switch i { case 0: .stdin case 1: .stdout case 2: .stderr default: throw ContainerizationError(.invalidArgument, message: "invalid fd \(i)") } }() if let h { request.set(key: key, value: h) } } try await xpcClient.send(request) return ClientProcessImpl(containerId: containerId, processId: processId, xpcClient: xpcClient) } catch { throw ContainerizationError( .internalError, message: "failed to create process in container", cause: error ) } } /// Get the log file handles for a container. public func logs(id: String) async throws -> [FileHandle] { do { let request = XPCMessage(route: .containerLogs) request.set(key: .id, value: id) let response = try await xpcClient.send(request) let fds = response.fileHandles(key: .logs) guard let fds else { throw ContainerizationError( .internalError, message: "no log fds returned" ) } return fds } catch { throw ContainerizationError( .internalError, message: "failed to get logs for container \(id)", cause: error ) } } /// Dial a port on the container via vsock. public func dial(id: String, port: UInt32) async throws -> FileHandle { let request = XPCMessage(route: .containerDial) request.set(key: .id, value: id) request.set(key: .port, value: UInt64(port)) let response: XPCMessage do { response = try await xpcClient.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to dial port \(port) on container", cause: error ) } guard let fh = response.fileHandle(key: .fd) else { throw ContainerizationError( .internalError, message: "failed to get fd for vsock port \(port)" ) } return fh } /// Get resource usage statistics for a container. public func stats(id: String) async throws -> ContainerStats { let request = XPCMessage(route: .containerStats) request.set(key: .id, value: id) do { let response = try await xpcClient.send(request) guard let data = response.dataNoCopy(key: .statistics) else { throw ContainerizationError( .internalError, message: "no statistics data returned" ) } return try JSONDecoder().decode(ContainerStats.self, from: data) } catch { throw ContainerizationError( .internalError, message: "failed to get statistics for container \(id)", cause: error ) } } public func export(id: String, archive: URL) async throws { let request = XPCMessage(route: .containerExport) request.set(key: .id, value: id) request.set(key: .archive, value: archive.absolutePath()) do { try await xpcClient.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to export container", cause: error ) } } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/ContainerizationProgressAdapter.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationExtras import TerminalProgress public enum ContainerizationProgressAdapter: ProgressAdapter { public static func handler(from progressUpdate: ProgressUpdateHandler?) -> ProgressHandler? { guard let progressUpdate else { return nil } return { events in var updateEvents = [ProgressUpdateEvent]() for event in events { if event.event == "add-items" { if let items = event.value as? Int { updateEvents.append(.addItems(items)) } } else if event.event == "add-total-items" { if let totalItems = event.value as? Int { updateEvents.append(.addTotalItems(totalItems)) } } else if event.event == "add-size" { if let size = event.value as? Int64 { updateEvents.append(.addSize(size)) } } else if event.event == "add-total-size" { if let totalSize = event.value as? Int64 { updateEvents.append(.addTotalSize(totalSize)) } } } await progressUpdate(updateEvents) } } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/DefaultPlatform.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import ContainerizationOCI import Foundation import Logging /// Resolves the default platform from the `CONTAINER_DEFAULT_PLATFORM` environment variable. /// /// When set, this variable overrides the native platform as the default for commands /// that support `--platform`. Explicit `--platform` flags always take precedence. public enum DefaultPlatform { /// The name of the environment variable checked for a default platform. public static let environmentVariable = "CONTAINER_DEFAULT_PLATFORM" /// Reads and parses the `CONTAINER_DEFAULT_PLATFORM` environment variable. /// /// When a valid platform is found and a logger is provided, a warning is emitted /// to inform the user that the environment variable is being used. /// /// - Parameters: /// - environment: The environment dictionary to read from. Defaults to the current process environment. /// - log: An optional logger. When provided, a warning is logged if the environment variable is active. /// - Returns: The parsed platform, or `nil` if the variable is not set or empty. /// - Throws: ContainerizationError if the variable is set but contains an invalid platform string. public static func fromEnvironment( environment: [String: String] = ProcessInfo.processInfo.environment, log: Logger? = nil ) throws -> ContainerizationOCI.Platform? { guard let value = environment[environmentVariable], !value.isEmpty else { return nil } let platform: ContainerizationOCI.Platform do { platform = try ContainerizationOCI.Platform(from: value) } catch { throw ContainerizationError( .invalidArgument, message: "invalid platform \"\(value)\" in \(environmentVariable) environment variable", cause: error ) } logNotice(platform, log: log) return platform } /// Resolves the platform for commands where `--os` and `--arch` are optional (image pull, push, save). /// /// Precedence: `--platform` > `--os`/`--arch` > `CONTAINER_DEFAULT_PLATFORM` > `nil`. /// /// - Parameters: /// - platform: The value of the `--platform` flag, if provided. /// - os: The value of the `--os` flag, if provided. /// - arch: The value of the `--arch` flag, if provided. /// - environment: The environment dictionary to read from. Defaults to the current process environment. /// - log: An optional logger for environment variable notices. /// - Returns: The resolved platform, or `nil` if no platform information is available. /// - Throws: ContainerizationError if a platform string (from flags or environment) is invalid. public static func resolve( platform: String?, os: String?, arch: String?, environment: [String: String] = ProcessInfo.processInfo.environment, log: Logger? = nil ) throws -> ContainerizationOCI.Platform? { if let platform { return try ContainerizationOCI.Platform(from: platform) } if let arch { return try ContainerizationOCI.Platform(from: "\(os ?? "linux")/\(arch)") } if let os { return try ContainerizationOCI.Platform(from: "\(os)/\(arch ?? Arch.hostArchitecture().rawValue)") } return try fromEnvironment(environment: environment, log: log) } /// Resolves the platform for commands where `--os` and `--arch` have defaults (run, create). /// /// Precedence: `--platform` > `CONTAINER_DEFAULT_PLATFORM` > `--os`/`--arch` defaults. /// /// - Parameters: /// - platform: The value of the `--platform` flag, if provided. /// - os: The default OS value (always present). /// - arch: The default architecture value (always present). /// - environment: The environment dictionary to read from. Defaults to the current process environment. /// - log: An optional logger for environment variable notices. /// - Returns: The resolved platform. Always returns a value since os/arch defaults are provided. /// - Throws: ContainerizationError if a platform string (from flags or environment) is invalid. public static func resolveWithDefaults( platform: String?, os: String, arch: String, environment: [String: String] = ProcessInfo.processInfo.environment, log: Logger? = nil ) throws -> ContainerizationOCI.Platform { if let platform { return try Parser.platform(from: platform) } if let envPlatform = try fromEnvironment(environment: environment, log: log) { return envPlatform } return Parser.platform(os: os, arch: arch) } private static func logNotice(_ platform: ContainerizationOCI.Platform, log: Logger?) { guard let log else { return } log.warning( "using platform from environment variable", metadata: [ "platform": "\(platform.description)", "variable": "\(environmentVariable)", ] ) } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/DiskUsage.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Disk usage statistics for all resource types public struct DiskUsageStats: Sendable, Codable { /// Disk usage for images public var images: ResourceUsage /// Disk usage for containers public var containers: ResourceUsage /// Disk usage for volumes public var volumes: ResourceUsage public init(images: ResourceUsage, containers: ResourceUsage, volumes: ResourceUsage) { self.images = images self.containers = containers self.volumes = volumes } } /// Disk usage statistics for a specific resource type public struct ResourceUsage: Sendable, Codable { /// Total number of resources public var total: Int /// Number of active/running resources public var active: Int /// Total size in bytes public var sizeInBytes: UInt64 /// Reclaimable size in bytes (from unused/inactive resources) public var reclaimable: UInt64 public init(total: Int, active: Int, sizeInBytes: UInt64, reclaimable: UInt64) { self.total = total self.active = active self.sizeInBytes = sizeInBytes self.reclaimable = reclaimable } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/FileDownloader.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import AsyncHTTPClient import ContainerizationError import ContainerizationExtras import Foundation import TerminalProgress public struct FileDownloader { public static func downloadFile(url: URL, to destination: URL, progressUpdate: ProgressUpdateHandler? = nil) async throws { let request = try HTTPClient.Request(url: url) let delegate = try FileDownloadDelegate( path: destination.path(), reportHead: { let expectedSizeString = $0.headers["Content-Length"].first ?? "" if let expectedSize = Int64(expectedSizeString) { if let progressUpdate { Task { await progressUpdate([ .addTotalSize(expectedSize) ]) } } } }, reportProgress: { let receivedBytes = Int64($0.receivedBytes) if let progressUpdate { Task { await progressUpdate([ .setSize(receivedBytes) ]) } } }) let client = FileDownloader.createClient(url: url) do { _ = try await client.execute(request: request, delegate: delegate).get() } catch { try? await client.shutdown() throw error } try await client.shutdown() } private static func createClient(url: URL) -> HTTPClient { var httpConfiguration = HTTPClient.Configuration() // for large file downloads we keep a generous connect timeout, and // no read timeout since download durations can vary httpConfiguration.timeout = HTTPClient.Configuration.Timeout( connect: .seconds(30), read: .none ) if let host = url.host { let proxyURL = ProxyUtils.proxyFromEnvironment(scheme: url.scheme, host: host) if let proxyURL, let proxyHost = proxyURL.host { httpConfiguration.proxy = HTTPClient.Configuration.Proxy.server(host: proxyHost, port: proxyURL.port ?? 8080) } } return HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration) } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/Flags.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ArgumentParser import ContainerizationError import Foundation public struct Flags { public struct Logging: ParsableArguments { public init() {} public init(debug: Bool) { self.debug = debug } @Flag(name: .long, help: "Enable debug output [environment: CONTAINER_DEBUG]") public var debug = false } public struct Process: ParsableArguments { public init() {} public init( cwd: String?, env: [String], envFile: [String], gid: UInt32?, interactive: Bool, tty: Bool, uid: UInt32?, ulimits: [String], user: String? ) { self.cwd = cwd self.env = env self.envFile = envFile self.gid = gid self.interactive = interactive self.tty = tty self.uid = uid self.ulimits = ulimits self.user = user } @Option(name: .shortAndLong, help: "Set environment variables (key=value, or just key to inherit from host)") public var env: [String] = [] @Option( name: .long, help: "Read in a file of environment variables (key=value format, ignores # comments and blank lines)" ) public var envFile: [String] = [] @Option(name: .long, help: "Set the group ID for the process") public var gid: UInt32? @Flag(name: .shortAndLong, help: "Keep the standard input open even if not attached") public var interactive = false @Flag(name: .shortAndLong, help: "Open a TTY with the process") public var tty = false @Option(name: .shortAndLong, help: "Set the user for the process (format: name|uid[:gid])") public var user: String? @Option(name: .long, help: "Set the user ID for the process") public var uid: UInt32? @Option( name: [.customShort("w"), .customLong("workdir"), .long], help: .init( "Set the initial working directory inside the container", valueName: "dir" ) ) public var cwd: String? @Option( name: .customLong("ulimit"), help: .init( "Set resource limits (format: =[:])", valueName: "limit" ) ) public var ulimits: [String] = [] } public struct Resource: ParsableArguments { public init() {} public init(cpus: Int64?, memory: String?) { self.cpus = cpus self.memory = memory } @Option(name: .shortAndLong, help: "Number of CPUs to allocate to the container") public var cpus: Int64? @Option( name: .shortAndLong, help: "Amount of memory (1MiByte granularity), with optional K, M, G, T, or P suffix" ) public var memory: String? } public struct DNS: ParsableArguments { public init() {} public init(domain: String?, nameservers: [String], options: [String], searchDomains: [String]) { self.domain = domain self.nameservers = nameservers self.options = options self.searchDomains = searchDomains } @Option( name: .customLong("dns"), help: .init("DNS nameserver IP address", valueName: "ip") ) public var nameservers: [String] = [] @Option( name: .customLong("dns-domain"), help: .init("Default DNS domain", valueName: "domain") ) public var domain: String? = nil @Option( name: .customLong("dns-option"), help: .init("DNS options", valueName: "option") ) public var options: [String] = [] @Option( name: .customLong("dns-search"), help: .init("DNS search domains", valueName: "domain") ) public var searchDomains: [String] = [] } public struct Registry: ParsableArguments { public init() {} public init(scheme: String) { self.scheme = scheme } @Option(help: "Scheme to use when connecting to the container registry. One of (http, https, auto)") public var scheme: String = "auto" } public struct Management: ParsableArguments { public init() {} public init( arch: String, cidfile: String, detach: Bool, dns: Flags.DNS, dnsDisabled: Bool, entrypoint: String?, initImage: String?, kernel: String?, labels: [String], mounts: [String], name: String?, networks: [String], os: String, platform: String?, publishPorts: [String], publishSockets: [String], readOnly: Bool, remove: Bool, rosetta: Bool, runtime: String?, ssh: Bool, tmpFs: [String], useInit: Bool, virtualization: Bool, volumes: [String] ) { self.arch = arch self.cidfile = cidfile self.detach = detach self.dns = dns self.dnsDisabled = dnsDisabled self.entrypoint = entrypoint self.initImage = initImage self.kernel = kernel self.labels = labels self.mounts = mounts self.name = name self.networks = networks self.os = os self.platform = platform self.publishPorts = publishPorts self.publishSockets = publishSockets self.readOnly = readOnly self.remove = remove self.rosetta = rosetta self.runtime = runtime self.ssh = ssh self.tmpFs = tmpFs self.useInit = useInit self.virtualization = virtualization self.volumes = volumes } @Option(name: .shortAndLong, help: "Set arch if image can target multiple architectures") public var arch: String = Arch.hostArchitecture().rawValue @Option(name: .long, help: "Write the container ID to the path provided") public var cidfile = "" @Flag(name: .shortAndLong, help: "Run the container and detach from the process") public var detach = false @OptionGroup public var dns: Flags.DNS @Option( name: .long, help: .init( "Override the entrypoint of the image", valueName: "cmd" ) ) public var entrypoint: String? @Flag(name: .customLong("init"), help: "Run an init process inside the container that forwards signals and reaps processes") public var useInit = false @Option( name: .long, help: .init("Use a custom init image instead of the default", valueName: "image") ) public var initImage: String? @Option( name: .shortAndLong, help: .init("Set a custom kernel path", valueName: "path"), completion: .file(), transform: { str in URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) } ) public var kernel: String? @Option(name: [.short, .customLong("label")], help: "Add a key=value label to the container") public var labels: [String] = [] @Option(name: .customLong("mount"), help: "Add a mount to the container (format: type=<>,source=<>,target=<>,readonly)") public var mounts: [String] = [] @Option(name: .long, help: "Use the specified name as the container ID") public var name: String? @Option(name: [.customLong("network")], help: "Attach the container to a network (format: [,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE])") public var networks: [String] = [] @Flag(name: [.customLong("no-dns")], help: "Do not configure DNS in the container") public var dnsDisabled = false @Option(name: .long, help: "Set OS if image can target multiple operating systems") public var os = "linux" @Option( name: [.customShort("p"), .customLong("publish")], help: .init( "Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol])", valueName: "spec" ) ) public var publishPorts: [String] = [] @Option(name: .long, help: "Platform for the image if it's multi-platform. This takes precedence over --os and --arch [environment: CONTAINER_DEFAULT_PLATFORM]") public var platform: String? @Option( name: .customLong("publish-socket"), help: .init( "Publish a socket from container to host (format: host_path:container_path)", valueName: "spec" ) ) public var publishSockets: [String] = [] @Flag(name: .long, help: "Mount the container's root filesystem as read-only") public var readOnly = false @Flag(name: [.customLong("rm"), .long], help: "Remove the container after it stops") public var remove = false @Flag(name: .long, help: "Enable Rosetta in the container") public var rosetta = false @Option(name: .long, help: "Set the runtime handler for the container (default: container-runtime-linux)") public var runtime: String? @Flag(name: .long, help: "Forward SSH agent socket to container") public var ssh = false @Option(name: .customLong("tmpfs"), help: "Add a tmpfs mount to the container at the given path") public var tmpFs: [String] = [] @Flag( name: .long, help: "Expose virtualization capabilities to the container (requires host and guest support)" ) public var virtualization: Bool = false @Option(name: [.customLong("volume"), .short], help: "Bind mount a volume into the container") public var volumes: [String] = [] } public struct Progress: ParsableArguments { public init() {} public init(progress: ProgressType) { self.progress = progress } public enum ProgressType: String, ExpressibleByArgument { case none case ansi } @Option(name: .long, help: ArgumentHelp("Progress type (format: none|ansi)", valueName: "type")) public var progress: ProgressType = .ansi } public struct ImageFetch: ParsableArguments { public init() {} public init(maxConcurrentDownloads: Int) { self.maxConcurrentDownloads = maxConcurrentDownloads } @Option(name: .long, help: "Maximum number of concurrent downloads (default: 3)") public var maxConcurrentDownloads: Int = 3 } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import ContainerizationExtras import Foundation /// Functions for managing local DNS domains for containers. public struct HostDNSResolver { public static let defaultConfigPath = URL(filePath: "/etc/resolver") // prefix used to mark our files as /etc/resolver/{prefix}{domainName} public static let containerizationPrefix = "containerization." public static let localhostOptionsRegex = #"options localhost:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"# private let configURL: URL public init(configURL: URL = Self.defaultConfigPath) { self.configURL = configURL } /// Creates a DNS resolver configuration file for domain resolved by the application. public func createDomain(name: String, localhost: IPAddress? = nil) throws { let path = self.configURL.appending(path: "\(Self.containerizationPrefix)\(name)").path let fm: FileManager = FileManager.default if fm.fileExists(atPath: self.configURL.path) { guard let isDir = try self.configURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory, isDir else { throw ContainerizationError(.invalidState, message: "expected \(self.configURL.path) to be a directory, but found a file") } } else { try fm.createDirectory(at: self.configURL, withIntermediateDirectories: true) } guard !fm.fileExists(atPath: path) else { throw ContainerizationError(.exists, message: "domain \(name) already exists") } let dnsPort = localhost == nil ? "2053" : "1053" let options = localhost.map { HostDNSResolver.localhostOptionsRegex.replacingOccurrences( of: #"\((.*?)\)"#, with: $0.description, options: .regularExpression) } ?? "" let resolverText = """ domain \(name) search \(name) nameserver 127.0.0.1 port \(dnsPort) \(options) """ try resolverText.write(toFile: path, atomically: true, encoding: .utf8) } /// Removes a DNS resolver configuration file for domain resolved by the application. public func deleteDomain(name: String) throws -> IPAddress? { let path = self.configURL.appending(path: "\(Self.containerizationPrefix)\(name)").path let fm = FileManager.default guard fm.fileExists(atPath: path) else { throw ContainerizationError(.notFound, message: "domain \(name) at \(path) not found") } var localhost: IPAddress? let content = try String(contentsOfFile: path, encoding: .utf8) if let match = content.firstMatch(of: try Regex(HostDNSResolver.localhostOptionsRegex)) { localhost = try? IPAddress(String(match[1].substring ?? "")) } do { try fm.removeItem(atPath: path) } catch { throw ContainerizationError(.invalidState, message: "cannot delete domain (try sudo?)") } return localhost } /// Lists application-created local DNS domains. public func listDomains() -> [String] { let fm: FileManager = FileManager.default guard let resolverPaths = try? fm.contentsOfDirectory( at: self.configURL, includingPropertiesForKeys: [.isDirectoryKey] ) else { return [] } return resolverPaths .filter { $0.lastPathComponent.starts(with: Self.containerizationPrefix) } .compactMap { try? getDomainFromResolver(url: $0) } .sorted() } /// Reinitializes the macOS DNS daemon. public static func reinitialize() throws { do { let kill = Foundation.Process() kill.executableURL = URL(fileURLWithPath: "/usr/bin/killall") kill.arguments = ["-HUP", "mDNSResponder"] let null = FileHandle.nullDevice kill.standardOutput = null kill.standardError = null try kill.run() kill.waitUntilExit() let status = kill.terminationStatus guard status == 0 else { throw ContainerizationError(.internalError, message: "mDNSResponder restart failed with status \(status)") } } } private func getDomainFromResolver(url: URL) throws -> String? { let text = try String(contentsOf: url, encoding: .utf8) for line in text.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) let components = trimmed.split(whereSeparator: { $0.isWhitespace }) guard components.count == 2 else { continue } guard components[0] == "domain" else { continue } return String(components[1]) } return nil } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/ImageLoadResult.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// /// The result of loading an archive file into the image store. public struct ImageLoadResult { /// The successfully loaded images public let images: [ClientImage] /// The archive member files that were not extracted due /// to invalid paths or attempted symlink traversal. public let rejectedMembers: [String] } ================================================ FILE: Sources/Services/ContainerAPIService/Client/Measurement+Parse.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 private let binaryUnits: [Character: UnitInformationStorage] = [ "b": .bytes, "k": .kibibytes, "m": .mebibytes, "g": .gibibytes, "t": .tebibytes, "p": .pebibytes, ] extension Measurement { public enum ParseError: Swift.Error, CustomStringConvertible { case invalidSize case invalidSymbol(String) public var description: String { switch self { case .invalidSize: return "invalid size" case .invalidSymbol(let symbol): return "invalid symbol: \(symbol)" } } } /// parseMemory the provided string into a measurement that is able to be converted to various byte sizes using binary exponents public static func parse(parsing: String) throws -> Measurement { let check = "01234567890." let trimmed = parsing.trimmingCharacters(in: .whitespaces).lowercased() guard !trimmed.isEmpty else { throw ParseError.invalidSize } let i = trimmed.firstIndex { !check.contains($0) } let rawValue = i .map { trimmed[..<$0].trimmingCharacters(in: .whitespaces) } ?? trimmed let rawUnit = i.map { trimmed[$0...].trimmingCharacters(in: .whitespaces) } ?? "" let value = Double(rawValue) guard let value else { throw ParseError.invalidSize } let unitSymbol = try Self.parseUnit(rawUnit) let unit = binaryUnits[unitSymbol] guard let unit else { throw ParseError.invalidSymbol(rawUnit) } return Measurement(value: value, unit: unit) } static func parseUnit(_ unit: String) throws -> Character { let s = unit.dropFirst() let unitSymbol = unit.first ?? "b" switch s { case "", "ib", "b": return unitSymbol default: throw ParseError.invalidSymbol(unit) } } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/PacketFilter.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import ContainerizationExtras import Foundation public struct PacketFilter { public static let anchor = "com.apple.container" public static let defaultConfigPath = URL(filePath: "/etc/pf.conf") public static let defaultAnchorsPath = URL(filePath: "/etc/pf.anchors") private let configURL: URL private let anchorsURL: URL public init(configURL: URL = Self.defaultConfigPath, anchorsURL: URL = Self.defaultAnchorsPath) { self.configURL = configURL self.anchorsURL = anchorsURL } public func createRedirectRule(from: IPAddress, to: IPAddress, domain: String) throws { guard type(of: from) == type(of: to) else { throw ContainerizationError(.invalidArgument, message: "protocol does not match: \(from) vs. \(to)") } let fm: FileManager = FileManager.default let anchorURL = self.anchorsURL.appending(path: Self.anchor) let inet: String switch from { case .v4: inet = "inet" case .v6: inet = "inet6" } let redirectRule = "rdr \(inet) from any to \(from.description) -> \(to.description) # \(domain)" var content = "" if fm.fileExists(atPath: anchorURL.path) { content = try String(contentsOfFile: anchorURL.path, encoding: .utf8) } else { try addAnchorToConfig() } var lines = content.components(separatedBy: .newlines) if !content.contains(redirectRule) { lines.insert(redirectRule, at: lines.endIndex - 1) } try lines.joined(separator: "\n").write(toFile: anchorURL.path, atomically: true, encoding: .utf8) } public func removeRedirectRule(from: IPAddress, to: IPAddress, domain: String) throws { guard type(of: from) == type(of: to) else { throw ContainerizationError(.invalidArgument, message: "protocol does not match: \(from) vs. \(to)") } let fm: FileManager = FileManager.default let anchorURL = self.anchorsURL.appending(path: Self.anchor) let inet: String switch from { case .v4: inet = "inet" case .v6: inet = "inet6" } let redirectRule = "rdr \(inet) from any to \(from.description) -> \(to.description) # \(domain)" guard fm.fileExists(atPath: anchorURL.path) else { return } let content = try String(contentsOfFile: anchorURL.path, encoding: .utf8) let lines = content.components(separatedBy: .newlines) let removedLines = lines.filter { l in l != redirectRule } if removedLines == [""] { try fm.removeItem(atPath: anchorURL.path) try removeAnchorFromConfig() } else { try removedLines.joined(separator: "\n").write(toFile: anchorURL.path, atomically: true, encoding: .utf8) } } private func addAnchorToConfig() throws { let fm: FileManager = FileManager.default let anchorURL = self.anchorsURL.appending(path: Self.anchor) /* PF requires strict ordering of anchors: scrub-anchor, nat-anchor, rdr-anchor, dummynet-anchor, anchor, load anchor */ let anchorKeywords = ["scrub-anchor", "nat-anchor", "rdr-anchor", "dummynet-anchor", "anchor", "load anchor"] let loadAnchorText = "load anchor \"\(Self.anchor)\" from \"\(anchorURL.path)\"" var content: String = "" var lines: [String] = [] if fm.fileExists(atPath: self.configURL.path) { content = try String(contentsOfFile: self.configURL.path, encoding: .utf8) } lines = content.components(separatedBy: .newlines) for (i, keyword) in anchorKeywords[..<(anchorKeywords.endIndex - 1)].enumerated() { let anchorText = "\(keyword) \"\(Self.anchor)\"" if content.contains(anchorText) { continue } let idx = lines.firstIndex { l in anchorKeywords[i...].map { k in l.starts(with: k) }.contains(true) } lines.insert(anchorText, at: idx ?? lines.endIndex - 1) } if !content.contains(loadAnchorText) { lines.insert(loadAnchorText, at: lines.endIndex - 1) } do { try lines.joined(separator: "\n").write(toFile: self.configURL.path, atomically: true, encoding: .utf8) } catch { throw ContainerizationError(.invalidState, message: "failed to write \"\(self.configURL.path)\"") } } private func removeAnchorFromConfig() throws { let fm: FileManager = FileManager.default guard fm.fileExists(atPath: configURL.path) else { return } let content = try String(contentsOfFile: configURL.path, encoding: .utf8) let lines = content.components(separatedBy: .newlines) let removedLines = lines.filter { l in !l.contains(Self.anchor) } do { try removedLines.joined(separator: "\n").write(toFile: configURL.path, atomically: true, encoding: .utf8) } catch { throw ContainerizationError(.invalidState, message: "failed to write \"\(configURL.path)\"") } } public func reinitialize() throws { let null = FileHandle.nullDevice let checkProcess = Foundation.Process() var checkStatus: Int32 checkProcess.executableURL = URL(fileURLWithPath: "/sbin/pfctl") checkProcess.arguments = ["-n", "-f", configURL.path] checkProcess.standardOutput = null checkProcess.standardError = null do { try checkProcess.run() } catch { throw ContainerizationError(.internalError, message: "pfctl rule check exec failed: \"\(error)\"") } checkProcess.waitUntilExit() checkStatus = checkProcess.terminationStatus guard checkStatus == 0 else { throw ContainerizationError(.internalError, message: "invalid pf config \"\(configURL.path)\"") } let reloadProcess = Foundation.Process() var reloadStatus: Int32 reloadProcess.executableURL = URL(fileURLWithPath: "/sbin/pfctl") reloadProcess.arguments = ["-f", configURL.path] reloadProcess.standardOutput = null reloadProcess.standardError = null do { try reloadProcess.run() } catch { throw ContainerizationError(.internalError, message: "pfctl reload exec failed: \"\(error)\"") } reloadProcess.waitUntilExit() reloadStatus = reloadProcess.terminationStatus guard reloadStatus == 0 else { throw ContainerizationError(.invalidState, message: "pfctl -f \"\(configURL.path)\" failed with status \(reloadStatus)") } } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/Parser.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerPersistence import ContainerResource import Containerization import ContainerizationError import ContainerizationExtras import ContainerizationOCI import ContainerizationOS import Foundation /// A parsed volume specification from user input public struct ParsedVolume { public let name: String public let destination: String public let options: [String] public let isAnonymous: Bool public init(name: String, destination: String, options: [String] = [], isAnonymous: Bool = false) { self.name = name self.destination = destination self.options = options self.isAnonymous = isAnonymous } } /// Union type for parsed mount specifications public enum VolumeOrFilesystem { case filesystem(Filesystem) case volume(ParsedVolume) } public struct Parser { public static func memoryStringAsMiB(_ memory: String) throws -> Int64 { let ram = try Measurement.parse(parsing: memory) let mb = ram.converted(to: .mebibytes) return Int64(mb.value) } public static func user( user: String?, uid: UInt32?, gid: UInt32?, defaultUser: ProcessConfiguration.User = .id(uid: 0, gid: 0) ) -> (user: ProcessConfiguration.User, groups: [UInt32]) { var supplementalGroups: [UInt32] = [] let user: ProcessConfiguration.User = { if let user = user, !user.isEmpty { return .raw(userString: user) } if let uid, let gid { return .id(uid: uid, gid: gid) } if uid == nil, gid == nil { // Neither uid nor gid is set. return the default user return defaultUser } // One of uid / gid is left unspecified. Set the user accordingly if let uid { return .raw(userString: "\(uid)") } if let gid { supplementalGroups.append(gid) } return defaultUser }() return (user, supplementalGroups) } public static func platform(os: String, arch: String) -> ContainerizationOCI.Platform { .init(arch: arch, os: os) } public static func platform(from platform: String) throws -> ContainerizationOCI.Platform { try .init(from: platform) } public static func resources( cpus: Int64?, memory: String?, cpuPropertyKey: DefaultsStore.Keys = .defaultContainerCPUs, memoryPropertyKey: DefaultsStore.Keys = .defaultContainerMemory, defaultCPUs: Int = 4, defaultMemoryInBytes: UInt64 = 1024.mib() ) throws -> ContainerConfiguration.Resources { var resource = ContainerConfiguration.Resources() resource.cpus = defaultCPUs resource.memoryInBytes = defaultMemoryInBytes if let cpus { resource.cpus = Int(cpus) } else if let cpuStr = DefaultsStore.getOptional(key: cpuPropertyKey), let cpuVal = Int(cpuStr), cpuVal > 0 { resource.cpus = cpuVal } if let memory { resource.memoryInBytes = try Parser.memoryStringAsMiB(memory).mib() } else if let memStr = DefaultsStore.getOptional(key: memoryPropertyKey) { resource.memoryInBytes = try Parser.memoryStringAsMiB(memStr).mib() } return resource } public static func allEnv(imageEnvs: [String], envFiles: [String], envs: [String]) throws -> [String] { var combined: [String] = [] combined.append(contentsOf: Parser.env(envList: imageEnvs)) for envFile in envFiles { let content = try Parser.envFile(path: envFile) combined.append(contentsOf: content) } combined.append(contentsOf: Parser.env(envList: envs)) let deduped = combined.reduce(into: [String: String]()) { map, entry in let key = String(entry.split(separator: "=", maxSplits: 1).first ?? Substring(entry)) map[key] = entry } return deduped.map { $0.value } } public static func envFile(path: String) throws -> [String] { // This is a somewhat faithful Go->Swift port of Moby's envfile // parsing in the cli: // https://github.com/docker/cli/blob/f5a7a3c72eb35fc5ba9c4d65a2a0e2e1bd216bf2/pkg/kvfile/kvfile.go#L81 let data: Data do { // Use FileHandle to support named pipes (FIFOs) and process substitutions // like --env-file <(echo "KEY=value") let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) defer { try? fileHandle.close() } data = try fileHandle.readToEnd() ?? Data() } catch { throw ContainerizationError( .invalidArgument, message: "failed to read envfile at \(path)", cause: error ) } guard let content = String(data: data, encoding: .utf8) else { throw ContainerizationError( .invalidArgument, message: "env file \(path) contains invalid utf8 bytes" ) } let whiteSpaces = " \t" var lines: [String] = [] let fileLines = content.components(separatedBy: .newlines) for line in fileLines { let trimmedLine = line.drop(while: { $0.isWhitespace }) // Skip empty lines and comments if trimmedLine.isEmpty || trimmedLine.hasPrefix("#") { continue } let hasValue: Bool let variable: String let value: String if let equalIndex = trimmedLine.firstIndex(of: "=") { variable = String(trimmedLine[.. [String] { var envVar: [String] = [] for env in envList { var env = env // Only inherit from host if no "=" is present (e.g., "--env VAR") // "VAR=" should set an explicit empty value, not inherit. if !env.contains("=") { guard let val = ProcessInfo.processInfo.environment[env] else { continue } env = "\(env)=\(val)" } envVar.append(env) } return envVar } public static func labels(_ rawLabels: [String]) throws -> [String: String] { var result: [String: String] = [:] for label in rawLabels { if label.isEmpty { throw ContainerizationError(.invalidArgument, message: "label cannot be an empty string") } let parts = label.split(separator: "=", maxSplits: 2) switch parts.count { case 1: result[String(parts[0])] = "" case 2: result[String(parts[0])] = String(parts[1]) default: throw ContainerizationError(.invalidArgument, message: "invalid label format \(label)") } } return result } public static func process( arguments: [String], processFlags: Flags.Process, managementFlags: Flags.Management, config: ContainerizationOCI.ImageConfig? ) throws -> ProcessConfiguration { let imageEnvVars = config?.env ?? [] let envvars = try Parser.allEnv(imageEnvs: imageEnvVars, envFiles: processFlags.envFile, envs: processFlags.env) let workingDir: String = { if let cwd = processFlags.cwd { return cwd } if let cwd = config?.workingDir { return cwd } return "/" }() let processArguments: [String]? = { var result: [String] = [] var hasEntrypointOverride: Bool = false // ensure the entrypoint is honored if it has been explicitly set by the user if let entrypoint = managementFlags.entrypoint, !entrypoint.isEmpty { result = [entrypoint] hasEntrypointOverride = true } else if let entrypoint = config?.entrypoint, !entrypoint.isEmpty { result = entrypoint } if !arguments.isEmpty { result.append(contentsOf: arguments) } else { if let cmd = config?.cmd, !hasEntrypointOverride, !cmd.isEmpty { result.append(contentsOf: cmd) } } return result.count > 0 ? result : nil }() guard let commandToRun = processArguments, commandToRun.count > 0 else { throw ContainerizationError(.invalidArgument, message: "command/entrypoint not specified for container process") } let defaultUser: ProcessConfiguration.User = { if let u = config?.user { return .raw(userString: u) } return .id(uid: 0, gid: 0) }() let (user, additionalGroups) = Parser.user( user: processFlags.user, uid: processFlags.uid, gid: processFlags.gid, defaultUser: defaultUser) let rlimits = try Parser.rlimits(processFlags.ulimits) return .init( executable: commandToRun.first!, arguments: [String](commandToRun.dropFirst()), environment: envvars, workingDirectory: workingDir, terminal: processFlags.tty, user: user, supplementalGroups: additionalGroups, rlimits: rlimits ) } // MARK: Mounts public static let mountTypes = [ "virtiofs", "bind", "tmpfs", ] public static let defaultDirectives = ["type": "virtiofs"] public static func tmpfsMounts(_ mounts: [String]) throws -> [Filesystem] { var result: [Filesystem] = [] let mounts = mounts.dedupe() for tmpfs in mounts { let fs = Filesystem.tmpfs(destination: tmpfs, options: []) try validateMount(.filesystem(fs)) result.append(fs) } return result } public static func mounts(_ rawMounts: [String], relativeTo basePath: URL? = nil) throws -> [VolumeOrFilesystem] { var mounts: [VolumeOrFilesystem] = [] let rawMounts = rawMounts.dedupe() for mount in rawMounts { let m = try Parser.mount(mount, relativeTo: basePath) try validateMount(m) mounts.append(m) } return mounts } public static func mount(_ mount: String, relativeTo basePath: URL? = nil) throws -> VolumeOrFilesystem { let parts = mount.split(separator: ",") if parts.count == 0 { throw ContainerizationError(.invalidArgument, message: "invalid mount format: \(mount)") } var directives = defaultDirectives for part in parts { let keyVal = part.split(separator: "=", maxSplits: 2) var key = String(keyVal[0]) var skipValue = false switch key { case "type", "size", "mode": break case "source", "src": key = "source" case "destination", "dst", "target": key = "destination" case "readonly", "ro": key = "ro" skipValue = true default: throw ContainerizationError(.invalidArgument, message: "unknown directive \(key) when parsing mount \(mount)") } var value = "" if !skipValue { if keyVal.count != 2 { throw ContainerizationError(.invalidArgument, message: "invalid directive format missing value \(part) in \(mount)") } value = String(keyVal[1]) } directives[key] = value } var fs = Filesystem() var isVolume = false var volumeName = "" for (key, val) in directives { var val = val let type = directives["type"] ?? "" switch key { case "type": if val == "bind" { val = "virtiofs" } switch val { case "virtiofs": fs.type = Filesystem.FSType.virtiofs case "tmpfs": fs.type = Filesystem.FSType.tmpfs case "volume": isVolume = true default: throw ContainerizationError(.invalidArgument, message: "unsupported mount type \(val)") } case "ro": fs.options.append("ro") case "size": if type != "tmpfs" { throw ContainerizationError(.invalidArgument, message: "unsupported option size for \(type) mount") } var overflow: Bool var memory = try Parser.memoryStringAsMiB(val) (memory, overflow) = memory.multipliedReportingOverflow(by: 1024 * 1024) if overflow { throw ContainerizationError(.invalidArgument, message: "overflow encountered when parsing memory string: \(val)") } let s = "size=\(memory)" fs.options.append(s) case "mode": if type != "tmpfs" { throw ContainerizationError(.invalidArgument, message: "unsupported option mode for \(type) mount") } let s = "mode=\(val)" fs.options.append(s) case "source": switch type { case "virtiofs", "bind": // For bind mounts, resolve both absolute and relative paths let url = basePath?.appending(path: val).standardizedFileURL ?? URL(filePath: val) let absolutePath = url.absoluteURL.path var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory) else { throw ContainerizationError(.invalidArgument, message: "path '\(val)' does not exist") } guard isDirectory.boolValue else { throw ContainerizationError(.invalidArgument, message: "path '\(val)' is not a directory") } fs.source = absolutePath case "volume": // For volume mounts, validate as volume name guard VolumeStorage.isValidVolumeName(val) else { throw ContainerizationError(.invalidArgument, message: "invalid volume name '\(val)': must match \(VolumeStorage.volumeNamePattern)") } // This is a named volume volumeName = val fs.source = val case "tmpfs": throw ContainerizationError(.invalidArgument, message: "cannot specify source for tmpfs mount") default: throw ContainerizationError(.invalidArgument, message: "unknown mount type \(type)") } case "destination": fs.destination = val default: throw ContainerizationError(.invalidArgument, message: "unknown mount directive \(key)") } } guard isVolume else { return .filesystem(fs) } // If it's a volume type but no source was provided, create an anonymous volume let isAnonymous = volumeName.isEmpty if isAnonymous { volumeName = VolumeStorage.generateAnonymousVolumeName() } return .volume( ParsedVolume( name: volumeName, destination: fs.destination, options: fs.options, isAnonymous: isAnonymous )) } public static func volumes(_ rawVolumes: [String], relativeTo basePath: URL? = nil) throws -> [VolumeOrFilesystem] { var mounts: [VolumeOrFilesystem] = [] for volume in rawVolumes { let m = try Parser.volume(volume, relativeTo: basePath) try Parser.validateMount(m) mounts.append(m) } return mounts } public static func volume(_ volume: String, relativeTo basePath: URL? = nil) throws -> VolumeOrFilesystem { var vol = volume vol.trimLeft(char: ":") let parts = vol.split(separator: ":") switch parts.count { case 1: // Anonymous volume: -v /path // Generate a random name for the anonymous volume let anonymousName = VolumeStorage.generateAnonymousVolumeName() let destination = String(parts[0]) let options: [String] = [] return .volume( ParsedVolume( name: anonymousName, destination: destination, options: options, isAnonymous: true )) case 2, 3: let src = String(parts[0]) let dst = String(parts[1]) // Check if it's a filesystem path (absolute, or relative like ".", "..", "./foo", "../foo") guard src.contains("/") || src == "." || src == ".." else { // Named volume - validate name syntax only guard VolumeStorage.isValidVolumeName(src) else { throw ContainerizationError(.invalidArgument, message: "invalid volume name '\(src)': must match \(VolumeStorage.volumeNamePattern)") } // This is a named volume let options = parts.count == 3 ? parts[2].split(separator: ",").map { String($0) } : [] return .volume( ParsedVolume( name: src, destination: dst, options: options )) } let url = basePath?.appending(path: src).standardizedFileURL ?? URL(filePath: src) let absolutePath = url.absoluteURL.path var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory) else { throw ContainerizationError(.invalidArgument, message: "path '\(src)' does not exist") } // This is a filesystem mount var fs = Filesystem.virtiofs( source: URL(fileURLWithPath: absolutePath).absolutePath(), destination: dst, options: [] ) if parts.count == 3 { fs.options = parts[2].split(separator: ",").map { String($0) } } return .filesystem(fs) default: throw ContainerizationError(.invalidArgument, message: "invalid volume format \(volume)") } } public static func validMountType(_ type: String) -> Bool { mountTypes.contains(type) } public static func validateMount(_ mount: VolumeOrFilesystem) throws { switch mount { case .filesystem(let fs): if !fs.isTmpfs { if !fs.source.isAbsolutePath() { throw ContainerizationError( .invalidArgument, message: "\(fs.source) is not an absolute path on the host") } if !FileManager.default.fileExists(atPath: fs.source) { throw ContainerizationError(.invalidArgument, message: "file path '\(fs.source)' does not exist") } } if fs.destination.isEmpty { throw ContainerizationError(.invalidArgument, message: "mount destination cannot be empty") } case .volume(let vol): if vol.destination.isEmpty { throw ContainerizationError(.invalidArgument, message: "volume destination cannot be empty") } // Volume name validation already done during parsing } } /// Parse --publish-port arguments into PublishPort objects /// The format of each argument is `[host-ip:]host-port:container-port[/protocol]` /// (e.g., "127.0.0.1:8080:80/tcp") /// host-port and container-port can be ranges (e.g., "127.0.0.1:3456-4567:3456-4567/tcp` /// /// - Parameter rawPublishPorts: Array of port arguments /// - Returns: Array of PublishPort objects /// - Throws: ContainerizationError if parsing fails public static func publishPorts(_ rawPublishPorts: [String]) throws -> [PublishPort] { var publishPorts: [PublishPort] = [] // Process each raw port string for socket in rawPublishPorts { let publishPort = try Parser.publishPort(socket) publishPorts.append(publishPort) } return publishPorts } // Parse a single `--publish-port` argument into a `PublishPort`. public static func publishPort(_ portText: String) throws -> PublishPort { let publishPortRegex = #/((\[(?[^\]]*)\]|(?[^:].*)):)?(?[^:].*):(?[^:/]*)(/(?.*))?/# guard let match = try publishPortRegex.wholeMatch(in: portText) else { throw ContainerizationError(.invalidArgument, message: "invalid publish value: \(portText)") } let proto: PublishProtocol let protoText = match.proto?.lowercased() ?? "tcp" switch protoText { case "tcp": proto = .tcp case "udp": proto = .udp default: throw ContainerizationError(.invalidArgument, message: "invalid publish protocol: \(protoText)") } let hostAddress: IPAddress if let ipv6 = match.ipv6, !ipv6.isEmpty { guard let address = try? IPAddress(String(ipv6)), case .v6 = address else { throw ContainerizationError(.invalidArgument, message: "invalid publish IPv6 address: \(portText)") } hostAddress = address } else if let ipv4 = match.ipv4, !ipv4.isEmpty { guard let address = try? IPAddress(String(ipv4)), case .v4 = address else { throw ContainerizationError(.invalidArgument, message: "invalid publish IPv4 address: \(portText)") } hostAddress = address } else { hostAddress = try IPAddress("0.0.0.0") } let hostPortText = match.hostPort let containerPortText = match.containerPort let hostPortRangeStart: UInt16 let hostPortRangeEnd: UInt16 let containerPortRangeStart: UInt16 let containerPortRangeEnd: UInt16 let hostPortParts = hostPortText.split(separator: "-") switch hostPortParts.count { case 1: guard let start = UInt16(hostPortParts[0]) else { throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)") } hostPortRangeStart = start hostPortRangeEnd = start case 2: guard let start = UInt16(hostPortParts[0]) else { throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)") } guard let end = UInt16(hostPortParts[1]) else { throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)") } hostPortRangeStart = start hostPortRangeEnd = end default: throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)") } let containerPortParts = containerPortText.split(separator: "-") switch containerPortParts.count { case 1: guard let start = UInt16(containerPortParts[0]) else { throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)") } containerPortRangeStart = start containerPortRangeEnd = start case 2: guard let start = UInt16(containerPortParts[0]) else { throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)") } guard let end = UInt16(containerPortParts[1]) else { throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)") } containerPortRangeStart = start containerPortRangeEnd = end default: throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)") } guard hostPortRangeStart > 1, hostPortRangeStart <= hostPortRangeEnd else { throw ContainerizationError(.invalidArgument, message: "invalid publish host port range: \(hostPortText)") } guard containerPortRangeStart > 1, containerPortRangeStart <= containerPortRangeEnd else { throw ContainerizationError(.invalidArgument, message: "invalid publish container port range: \(containerPortText)") } let hostCount = hostPortRangeEnd - hostPortRangeStart + 1 let containerCount = containerPortRangeEnd - containerPortRangeStart + 1 guard hostCount == containerCount else { throw ContainerizationError(.invalidArgument, message: "publish host and container port counts are not equal: \(hostPortText):\(containerPortText)") } return PublishPort( hostAddress: hostAddress, hostPort: hostPortRangeStart, containerPort: containerPortRangeStart, proto: proto, count: hostCount ) } /// Parse --publish-socket arguments into PublishSocket objects /// The format of each argument is `host_path:container_path` /// (e.g., "/tmp/docker.sock:/var/run/docker.sock") /// /// - Parameter rawPublishSockets: Array of socket arguments /// - Returns: Array of PublishSocket objects /// - Throws: ContainerizationError if parsing fails or a path is invalid public 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 into a `PublishSocket`. public static func publishSocket(_ socketText: String) throws -> PublishSocket { // Split by colon to two parts: [host_path, container_path] let parts = socketText.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 \(socketText). Expected: host_path:container_path") } } // MARK: Networks /// Parsed network attachment with optional properties public struct ParsedNetwork { public let name: String public let macAddress: String? public let mtu: UInt32? public init(name: String, macAddress: String? = nil, mtu: UInt32? = nil) { self.name = name self.macAddress = macAddress self.mtu = mtu } } /// Parse network attachment with optional properties /// Format: network_name[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE] /// Example: "backend,mac=02:42:ac:11:00:02,mtu=1500" public static func network(_ networkSpec: String) throws -> ParsedNetwork { guard !networkSpec.isEmpty else { throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty") } let parts = networkSpec.split(separator: ",", omittingEmptySubsequences: false) guard !parts.isEmpty else { throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty") } let networkName = String(parts[0]) if networkName.isEmpty { throw ContainerizationError(.invalidArgument, message: "network name cannot be empty") } var macAddress: String? var mtu: UInt32? // Parse properties if any for part in parts.dropFirst() { let keyVal = part.split(separator: "=", maxSplits: 2, omittingEmptySubsequences: false) let key: String let value: String guard keyVal.count == 2 else { throw ContainerizationError( .invalidArgument, message: "invalid property format '\(part)' in network specification '\(networkSpec)'" ) } key = String(keyVal[0]) value = String(keyVal[1]) switch key { case "mac": if value.isEmpty { throw ContainerizationError( .invalidArgument, message: "mac address value cannot be empty" ) } macAddress = value case "mtu": guard let mtuValue = UInt32(value), mtuValue >= 1280, mtuValue <= 65535 else { throw ContainerizationError( .invalidArgument, message: "invalid mtu value '\(value)': must be between 1280 and 65535" ) } mtu = mtuValue default: throw ContainerizationError( .invalidArgument, message: "unknown network property '\(key)'. Available properties: mac, mtu" ) } } return ParsedNetwork(name: networkName, macAddress: macAddress, mtu: mtu) } // MARK: DNS public static func isValidDomainName(_ name: String) -> Bool { guard !name.isEmpty && name.count <= 255 else { return false } return name.components(separatedBy: ".").allSatisfy { Self.isValidDomainNameLabel($0) } } public static func isValidDomainNameLabel(_ label: String) -> Bool { guard !label.isEmpty && label.count <= 63 else { return false } let pattern = #/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/# return !label.ranges(of: pattern).isEmpty } private static let ulimitNameToRlimit: [String: String] = [ "core": "RLIMIT_CORE", "cpu": "RLIMIT_CPU", "data": "RLIMIT_DATA", "fsize": "RLIMIT_FSIZE", "locks": "RLIMIT_LOCKS", "memlock": "RLIMIT_MEMLOCK", "msgqueue": "RLIMIT_MSGQUEUE", "nice": "RLIMIT_NICE", "nofile": "RLIMIT_NOFILE", "nproc": "RLIMIT_NPROC", "rss": "RLIMIT_RSS", "rtprio": "RLIMIT_RTPRIO", "rttime": "RLIMIT_RTTIME", "sigpending": "RLIMIT_SIGPENDING", "stack": "RLIMIT_STACK", ] /// Parse ulimit specifications into Rlimit objects /// Format: =[:] /// Examples: /// - nofile=1024:2048 (soft=1024, hard=2048) /// - nofile=1024 (soft=hard=1024) /// - nofile=unlimited (soft=hard=UINT64_MAX) /// - nofile=1024:unlimited (soft=1024, hard=UINT64_MAX) public static func rlimits(_ rawUlimits: [String]) throws -> [ProcessConfiguration.Rlimit] { var rlimits: [ProcessConfiguration.Rlimit] = [] var seenTypes: Set = [] for ulimit in rawUlimits { let rlimit = try Parser.rlimit(ulimit) if seenTypes.contains(rlimit.limit) { throw ContainerizationError( .invalidArgument, message: "duplicate ulimit type: \(ulimit.split(separator: "=").first ?? "")" ) } seenTypes.insert(rlimit.limit) rlimits.append(rlimit) } return rlimits } /// Parse a single ulimit specification public static func rlimit(_ ulimit: String) throws -> ProcessConfiguration.Rlimit { let parts = ulimit.split(separator: "=", maxSplits: 1) guard parts.count == 2 else { throw ContainerizationError( .invalidArgument, message: "invalid ulimit format '\(ulimit)': expected =[:]" ) } let typeName = String(parts[0]).lowercased() let valuesPart = String(parts[1]) guard let rlimitType = ulimitNameToRlimit[typeName] else { let validTypes = ulimitNameToRlimit.keys.sorted().joined(separator: ", ") throw ContainerizationError( .invalidArgument, message: "unsupported ulimit type '\(typeName)': valid types are \(validTypes)" ) } let valueParts = valuesPart.split(separator: ":", maxSplits: 1) let soft: UInt64 let hard: UInt64 switch valueParts.count { case 1: // Single value: use for both soft and hard soft = try parseRlimitValue(String(valueParts[0]), typeName: typeName) hard = soft case 2: // Two values: soft:hard soft = try parseRlimitValue(String(valueParts[0]), typeName: typeName) hard = try parseRlimitValue(String(valueParts[1]), typeName: typeName) default: throw ContainerizationError( .invalidArgument, message: "invalid ulimit format '\(ulimit)': expected =[:]" ) } if soft > hard { throw ContainerizationError( .invalidArgument, message: "ulimit '\(typeName)' soft limit (\(soft)) cannot exceed hard limit (\(hard))" ) } return ProcessConfiguration.Rlimit(limit: rlimitType, soft: soft, hard: hard) } private static func parseRlimitValue(_ value: String, typeName: String) throws -> UInt64 { let trimmed = value.trimmingCharacters(in: .whitespaces).lowercased() if trimmed == "unlimited" || trimmed == "-1" { return UInt64.max } guard let parsed = UInt64(trimmed) else { throw ContainerizationError( .invalidArgument, message: "invalid ulimit value '\(value)' for '\(typeName)': must be a non-negative integer or 'unlimited'" ) } return parsed } // MARK: Miscellaneous public static func parseBool(string: String) -> Bool? { let lower = string.lowercased() switch lower { case "true", "t": return true case "false", "f": return false default: return nil } } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/ProcessIO.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationExtras import ContainerizationOS import Foundation import Logging public struct ProcessIO: Sendable { let stdin: Pipe? let stdout: Pipe? let stderr: Pipe? var ioTracker: IoTracker? static let signalSet: [Int32] = [ SIGTERM, SIGINT, SIGUSR1, SIGUSR2, SIGWINCH, ] public struct IoTracker: Sendable { let stream: AsyncStream let cont: AsyncStream.Continuation let configuredStreams: Int } public let stdio: [FileHandle?] public let console: Terminal? public static func create(tty: Bool, interactive: Bool, detach: Bool) throws -> ProcessIO { let current: Terminal? = try { if !tty || !interactive { return nil } let current = try Terminal(descriptor: STDIN_FILENO) try current.setraw() return current }() var stdio = [FileHandle?](repeating: nil, count: 3) let stdin: Pipe? = { if !interactive { return nil } return Pipe() }() if let stdin { let pin = FileHandle.standardInput let stdinOSFile = OSFile(fd: pin.fileDescriptor) let pipeOSFile = OSFile(fd: stdin.fileHandleForWriting.fileDescriptor) try stdinOSFile.makeNonBlocking() nonisolated(unsafe) let buf = UnsafeMutableBufferPointer.allocate(capacity: Int(getpagesize())) pin.readabilityHandler = { _ in Self.streamStdin( from: stdinOSFile, to: pipeOSFile, buffer: buf, ) { pin.readabilityHandler = nil buf.deallocate() try? stdin.fileHandleForWriting.close() } } stdio[0] = stdin.fileHandleForReading } let stdout: Pipe? = { if detach { return nil } return Pipe() }() var configuredStreams = 0 let (stream, cc) = AsyncStream.makeStream() if let stdout { configuredStreams += 1 stdio[1] = stdout.fileHandleForWriting let pout = FileHandle.standardOutput let rout = stdout.fileHandleForReading rout.readabilityHandler = { handle in let data = handle.availableData if data.isEmpty { rout.readabilityHandler = nil cc.yield() return } try! pout.write(contentsOf: data) } } let stderr: Pipe? = { if detach || tty { return nil } return Pipe() }() if let stderr { configuredStreams += 1 let perr: FileHandle = .standardError let rerr = stderr.fileHandleForReading rerr.readabilityHandler = { handle in let data = handle.availableData if data.isEmpty { rerr.readabilityHandler = nil cc.yield() return } try! perr.write(contentsOf: data) } stdio[2] = stderr.fileHandleForWriting } var ioTracker: IoTracker? = nil if configuredStreams > 0 { ioTracker = .init(stream: stream, cont: cc, configuredStreams: configuredStreams) } return .init( stdin: stdin, stdout: stdout, stderr: stderr, ioTracker: ioTracker, stdio: stdio, console: current ) } public func handleProcess(process: ClientProcess, log: Logger) async throws -> Int32 { let signals = AsyncSignalHandler.create(notify: Self.signalSet) return try await withThrowingTaskGroup(of: Int32?.self, returning: Int32.self) { group in try await process.start() try closeAfterStart() let waitAdded = group.addTaskUnlessCancelled { let code = try await process.wait() try await wait() return code } guard waitAdded else { group.cancelAll() return -1 } if let current = console { let size = try current.size // It's supremely possible the process could've exited already. We shouldn't treat // this as fatal. try? await process.resize(size) _ = group.addTaskUnlessCancelled { let winchHandler = AsyncSignalHandler.create(notify: [SIGWINCH]) for await _ in winchHandler.signals { do { try await process.resize(try current.size) } catch { log.error( "failed to send terminal resize event", metadata: [ "error": "\(error)" ] ) } } return nil } } else { _ = group.addTaskUnlessCancelled { for await sig in signals.signals { do { try await process.kill(sig) } catch { log.error( "failed to send signal", metadata: [ "signal": "\(sig)", "error": "\(error)", ] ) } } return nil } } while true { let result = try await group.next() if result == nil { return -1 } let status = result! if let status { group.cancelAll() return status } } return -1 } } public func closeAfterStart() throws { try stdin?.fileHandleForReading.close() try stdout?.fileHandleForWriting.close() try stderr?.fileHandleForWriting.close() } public func close() throws { try console?.reset() } 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 { throw error } } static func streamStdin( from: OSFile, to: OSFile, buffer: UnsafeMutableBufferPointer, onErrorOrEOF: () -> Void, ) { while true { let (bytesRead, action) = from.read(buffer) if bytesRead > 0 { let view = UnsafeMutableBufferPointer( start: buffer.baseAddress, count: bytesRead ) let (bytesWritten, _) = to.write(view) if bytesWritten != bytesRead { onErrorOrEOF() return } } switch action { case .error(_), .eof, .brokenPipe: onErrorOrEOF() return case .again: return case .success: break } } } } public struct OSFile: Sendable { private let fd: Int32 public enum IOAction: Equatable { case eof case again case success case brokenPipe case error(_ errno: Int32) } public init(fd: Int32) { self.fd = fd } public init(handle: FileHandle) { self.fd = handle.fileDescriptor } func makeNonBlocking() throws { let flags = fcntl(fd, F_GETFL) guard flags != -1 else { throw POSIXError.fromErrno() } if fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1 { throw POSIXError.fromErrno() } } func write(_ buffer: UnsafeMutableBufferPointer) -> (wrote: Int, action: IOAction) { if buffer.count == 0 { return (0, .success) } var bytesWrote: Int = 0 while true { let n = Darwin.write( self.fd, buffer.baseAddress!.advanced(by: bytesWrote), buffer.count - bytesWrote ) if n == -1 { if errno == EAGAIN || errno == EIO { return (bytesWrote, .again) } return (bytesWrote, .error(errno)) } if n == 0 { return (bytesWrote, .brokenPipe) } bytesWrote += n if bytesWrote < buffer.count { continue } return (bytesWrote, .success) } } func read(_ buffer: UnsafeMutableBufferPointer) -> (read: Int, action: IOAction) { if buffer.count == 0 { return (0, .success) } var bytesRead: Int = 0 while true { let n = Darwin.read( self.fd, buffer.baseAddress!.advanced(by: bytesRead), buffer.count - bytesRead ) if n == -1 { if errno == EAGAIN || errno == EIO { return (bytesRead, .again) } return (bytesRead, .error(errno)) } if n == 0 { return (bytesRead, .eof) } bytesRead += n if bytesRead < buffer.count { continue } return (bytesRead, .success) } } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/ProgressUpdateClient.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerXPC import ContainerizationExtras import Foundation import TerminalProgress /// A client that can be used to receive progress updates from a service. public actor ProgressUpdateClient { private var endpointConnection: xpc_connection_t? private var endpoint: xpc_endpoint_t? /// Creates a new client for receiving progress updates from a service. /// - Parameters: /// - progressUpdate: The handler to invoke when progress updates are received. /// - request: The XPC message to send the endpoint to connect to. public init(for progressUpdate: @escaping ProgressUpdateHandler, request: XPCMessage) async { createEndpoint(for: progressUpdate) setEndpoint(to: request) } /// Performs a connection setup for receiving progress updates. /// - Parameter progressUpdate: The handler to invoke when progress updates are received. private func createEndpoint(for progressUpdate: @escaping ProgressUpdateHandler) { let endpointConnection = xpc_connection_create(nil, nil) // Access to `reversedConnection` is protected by a lock nonisolated(unsafe) var reversedConnection: xpc_connection_t? let reversedConnectionLock = NSLock() xpc_connection_set_event_handler(endpointConnection) { connectionMessage in reversedConnectionLock.withLock { switch xpc_get_type(connectionMessage) { case XPC_TYPE_CONNECTION: reversedConnection = connectionMessage xpc_connection_set_event_handler(connectionMessage) { updateMessage in Self.handleProgressUpdate(updateMessage, progressUpdate: progressUpdate) } xpc_connection_activate(connectionMessage) case XPC_TYPE_ERROR: if let reversedConnectionUnwrapped = reversedConnection { xpc_connection_cancel(reversedConnectionUnwrapped) reversedConnection = nil } default: fatalError("unhandled xpc object type: \(xpc_get_type(connectionMessage))") } } } xpc_connection_activate(endpointConnection) self.endpointConnection = endpointConnection self.endpoint = xpc_endpoint_create(endpointConnection) } /// Performs a setup of the progress update endpoint. /// - Parameter request: The XPC message containing the endpoint to use. private func setEndpoint(to request: XPCMessage) { guard let endpoint else { return } request.set(key: .progressUpdateEndpoint, value: endpoint) } /// Performs cleanup of the created connection. public func finish() { if let endpointConnection { xpc_connection_cancel(endpointConnection) self.endpointConnection = nil } } private static func handleProgressUpdate(_ message: xpc_object_t, progressUpdate: @escaping ProgressUpdateHandler) { switch xpc_get_type(message) { case XPC_TYPE_DICTIONARY: let message = XPCMessage(object: message) handleProgressUpdate(message, progressUpdate: progressUpdate) case XPC_TYPE_ERROR: break default: fatalError("unhandled xpc object type: \(xpc_get_type(message))") break } } private static func handleProgressUpdate(_ message: XPCMessage, progressUpdate: @escaping ProgressUpdateHandler) { var events = [ProgressUpdateEvent]() if let description = message.string(key: .progressUpdateSetDescription) { events.append(.setDescription(description)) } if let subDescription = message.string(key: .progressUpdateSetSubDescription) { events.append(.setSubDescription(subDescription)) } if let itemsName = message.string(key: .progressUpdateSetItemsName) { events.append(.setItemsName(itemsName)) } var tasks = message.int(key: .progressUpdateAddTasks) if tasks != 0 { events.append(.addTasks(tasks)) } tasks = message.int(key: .progressUpdateSetTasks) if tasks != 0 { events.append(.setTasks(tasks)) } var totalTasks = message.int(key: .progressUpdateAddTotalTasks) if totalTasks != 0 { events.append(.addTotalTasks(totalTasks)) } totalTasks = message.int(key: .progressUpdateSetTotalTasks) if totalTasks != 0 { events.append(.setTotalTasks(totalTasks)) } var items = message.int(key: .progressUpdateAddItems) if items != 0 { events.append(.addItems(items)) } items = message.int(key: .progressUpdateSetItems) if items != 0 { events.append(.setItems(items)) } var totalItems = message.int(key: .progressUpdateAddTotalItems) if totalItems != 0 { events.append(.addTotalItems(totalItems)) } totalItems = message.int(key: .progressUpdateSetTotalItems) if totalItems != 0 { events.append(.setTotalItems(totalItems)) } var size = message.int64(key: .progressUpdateAddSize) if size != 0 { events.append(.addSize(size)) } size = message.int64(key: .progressUpdateSetSize) if size != 0 { events.append(.setSize(size)) } var totalSize = message.int64(key: .progressUpdateAddTotalSize) if totalSize != 0 { events.append(.addTotalSize(totalSize)) } totalSize = message.int64(key: .progressUpdateSetTotalSize) if totalSize != 0 { events.append(.setTotalSize(totalSize)) } Task { await progressUpdate(events) } } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/ProgressUpdateService.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerXPC import ContainerizationExtras import Foundation import TerminalProgress /// A service that sends progress updates to the client. public actor ProgressUpdateService { private let endpointConnection: xpc_connection_t /// Creates a new instance for sending progress updates to the client. /// - Parameter message: The XPC message that contains the endpoint to connect to. public init?(message: XPCMessage) { guard let progressUpdateEndpoint = message.endpoint(key: .progressUpdateEndpoint) else { return nil } endpointConnection = xpc_connection_create_from_endpoint(progressUpdateEndpoint) xpc_connection_set_event_handler(endpointConnection) { _ in } // This connection will be closed by the client. xpc_connection_activate(endpointConnection) } /// Performs a progress update. /// - Parameter events: The events that represent the update. public func handler(_ events: [ProgressUpdateEvent]) async { let object = xpc_dictionary_create(nil, nil, 0) let replyMessage = XPCMessage(object: object) for event in events { switch event { case .setDescription(let description): replyMessage.set(key: .progressUpdateSetDescription, value: description) case .setSubDescription(let subDescription): replyMessage.set(key: .progressUpdateSetSubDescription, value: subDescription) case .setItemsName(let itemsName): replyMessage.set(key: .progressUpdateSetItemsName, value: itemsName) case .addTasks(let tasks): replyMessage.set(key: .progressUpdateAddTasks, value: tasks) case .setTasks(let tasks): replyMessage.set(key: .progressUpdateSetTasks, value: tasks) case .addTotalTasks(let totalTasks): replyMessage.set(key: .progressUpdateAddTotalTasks, value: totalTasks) case .setTotalTasks(let totalTasks): replyMessage.set(key: .progressUpdateSetTotalTasks, value: totalTasks) case .addSize(let size): replyMessage.set(key: .progressUpdateAddSize, value: size) case .setSize(let size): replyMessage.set(key: .progressUpdateSetSize, value: size) case .addTotalSize(let totalSize): replyMessage.set(key: .progressUpdateAddTotalSize, value: totalSize) case .setTotalSize(let totalSize): replyMessage.set(key: .progressUpdateSetTotalSize, value: totalSize) case .addItems(let items): replyMessage.set(key: .progressUpdateAddItems, value: items) case .setItems(let items): replyMessage.set(key: .progressUpdateSetItems, value: items) case .addTotalItems(let totalItems): replyMessage.set(key: .progressUpdateAddTotalItems, value: totalItems) case .setTotalItems(let totalItems): replyMessage.set(key: .progressUpdateSetTotalItems, value: totalItems) case .custom(_): // Unsupported progress update event in XPC communication. break } } xpc_connection_send_message(endpointConnection, replyMessage.underlying) } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/RequestScheme.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerPersistence import ContainerizationError /// The URL scheme to be used for a HTTP request. public enum RequestScheme: String, Sendable { case http = "http" case https = "https" case auto = "auto" public init(_ rawValue: String) throws { switch rawValue { case RequestScheme.http.rawValue: self = .http case RequestScheme.https.rawValue: self = .https case RequestScheme.auto.rawValue: self = .auto default: throw ContainerizationError(.invalidArgument, message: "unsupported scheme \(rawValue)") } } /// Returns the prescribed protocol to use while making a HTTP request to a webserver /// - Parameter host: The domain or IP address of the webserver /// - Returns: RequestScheme public func schemeFor(host: String) throws -> Self { guard host.count > 0 else { throw ContainerizationError(.invalidArgument, message: "host cannot be empty") } switch self { case .http, .https: return self case .auto: return Self.isInternalHost(host: host) ? .http : .https } } /// Checks if the given `host` string is a private IP address /// or a domain typically reachable only on the local system. private static func isInternalHost(host: String) -> Bool { if host.hasPrefix("localhost") || host.hasPrefix("127.") { return true } if host.hasPrefix("192.168.") || host.hasPrefix("10.") { return true } let regex = "(^172\\.1[6-9]\\.)|(^172\\.2[0-9]\\.)|(^172\\.3[0-1]\\.)" if host.range(of: regex, options: .regularExpression) != nil { return true } let dnsDomain = DefaultsStore.get(key: .defaultDNSDomain) if host.hasSuffix(".\(dnsDomain)") { return true } return false } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/SignalThreshold.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationOS // For a lot of programs, they don't install their own signal handlers for // SIGINT/SIGTERM which poses a somewhat fun problem for containers. Because // they're pid 1 (doesn't matter that it isn't in the "root" pid namespace) // the default actions for SIGINT and SIGTERM now are nops. So this type gives // us an opportunity to set a threshold for a certain number of signals received // so we can have an escape hatch for users to escape their horrific mistake // of cat'ing /dev/urandom by exit(1)'ing :) public struct SignalThreshold { private let threshold: Int private let signals: [Int32] private var t: Task<(), Never>? public init( threshold: Int, signals: [Int32], ) { self.threshold = threshold self.signals = signals } // Start kicks off the signal watching. The passed in handler will // run only once upon passing the threshold number passed in the constructor. mutating public func start(handler: @Sendable @escaping () -> Void) { let signals = self.signals let threshold = self.threshold self.t = Task { var received = 0 let signalHandler = AsyncSignalHandler.create(notify: signals) for await _ in signalHandler.signals { received += 1 if received == threshold { handler() signalHandler.cancel() return } } } } public func stop() { self.t?.cancel() } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/String+Extensions.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Foundation extension String { public func fromISO8601DateString(to: String) -> String? { if let date = fromISO8601Date() { let dateformatTo = DateFormatter() dateformatTo.dateFormat = to return dateformatTo.string(from: date) } return nil } public func fromISO8601Date() -> Date? { let iso8601DateFormatter = ISO8601DateFormatter() iso8601DateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return iso8601DateFormatter.date(from: self) } public func isAbsolutePath() -> Bool { self.starts(with: "/") } /// Trim all `char` characters from the left side of the string. Stops when encountering a character that /// doesn't match `char`. mutating public func trimLeft(char: Character) { if self.isEmpty { return } var trimTo = 0 for c in self { if char != c { break } trimTo += 1 } if trimTo != 0 { let index = self.index(self.startIndex, offsetBy: trimTo) self = String(self[index...]) } } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/SystemHealth.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Snapshot of the health of container services and resources public struct SystemHealth: Sendable, Codable { /// The full pathname of the application data root. public let appRoot: URL /// The full pathname of the application install root. public let installRoot: URL /// The full pathname of the application install root. public let logRoot: FilePath? /// The release version of the container services. public let apiServerVersion: String /// The Git commit ID for the container services. public let apiServerCommit: String /// The build type of the API server (debug|release). public let apiServerBuild: String /// The app name label returned by the server. public let apiServerAppName: String } ================================================ FILE: Sources/Services/ContainerAPIService/Client/TableOutput.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 public struct TableOutput { private let rows: [[String]] private let spacing: Int public init( rows: [[String]], spacing: Int = 2 ) { self.rows = rows self.spacing = spacing } public func format() -> String { var output = "" let maxLengths = self.maxLength() for rowIndex in 0.. [Int: Int] { var output: [Int: Int] = [:] for row in self.rows { for (i, column) in row.enumerated() { let currentMax = output[i] ?? 0 output[i] = (column.count > currentMax) ? column.count : currentMax } } return output } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/Utility.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerPersistence import ContainerResource import Containerization import ContainerizationError import ContainerizationExtras import ContainerizationOCI import Foundation import Logging import TerminalProgress public struct Utility { static let publishedPortCountLimit = 64 private static let infraImages = [ DefaultsStore.get(key: .defaultBuilderImage), DefaultsStore.get(key: .defaultInitImage), ] public static func createContainerID(name: String?) -> String { guard let name else { return UUID().uuidString.lowercased() } return name } public static func isInfraImage(name: String) -> Bool { for infraImage in infraImages { if name == infraImage { return true } } return false } public static func trimDigest(digest: String) -> String { var digest = digest digest.trimPrefix("sha256:") if digest.count > 24 { digest = String(digest.prefix(24)) + "..." } return digest } public static func validEntityName(_ name: String) throws { let pattern = #"^[a-zA-Z0-9][a-zA-Z0-9_.-]+$"# let regex = try Regex(pattern) if try regex.firstMatch(in: name) == nil { throw ContainerizationError(.invalidArgument, message: "invalid entity name \(name)") } } public static func validMACAddress(_ macAddress: String) throws { let pattern = #"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"# let regex = try Regex(pattern) if try regex.firstMatch(in: macAddress) == nil { throw ContainerizationError(.invalidArgument, message: "invalid MAC address format \(macAddress), expected format: XX:XX:XX:XX:XX:XX") } } public static func containerConfigFromFlags( id: String, image: String, arguments: [String], process: Flags.Process, management: Flags.Management, resource: Flags.Resource, registry: Flags.Registry, imageFetch: Flags.ImageFetch, progressUpdate: @escaping ProgressUpdateHandler, log: Logger ) async throws -> (ContainerConfiguration, Kernel, String?) { let requestedPlatform = try DefaultPlatform.resolveWithDefaults( platform: management.platform, os: management.os, arch: management.arch, log: log ) let scheme = try RequestScheme(registry.scheme) await progressUpdate([ .setDescription("Fetching image"), .setItemsName("blobs"), ]) let taskManager = ProgressTaskCoordinator() let fetchTask = await taskManager.startTask() let img = try await ClientImage.fetch( reference: image, platform: requestedPlatform, scheme: scheme, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate), maxConcurrentDownloads: imageFetch.maxConcurrentDownloads ) // Unpack a fetched image before use await progressUpdate([ .setDescription("Unpacking image"), .setItemsName("entries"), ]) let unpackTask = await taskManager.startTask() try await img.getCreateSnapshot( platform: requestedPlatform, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate)) await progressUpdate([ .setDescription("Fetching kernel"), .setItemsName("binary"), ]) let kernel = try await self.getKernel(management: management) // Pull and unpack the initial filesystem await progressUpdate([ .setDescription("Fetching init image"), .setItemsName("blobs"), ]) let fetchInitTask = await taskManager.startTask() let initImageRef = management.initImage ?? ClientImage.initImageRef let initImage = try await ClientImage.fetch( reference: initImageRef, platform: .current, scheme: scheme, progressUpdate: ProgressTaskCoordinator.handler(for: fetchInitTask, from: progressUpdate), maxConcurrentDownloads: imageFetch.maxConcurrentDownloads) await progressUpdate([ .setDescription("Unpacking init image"), .setItemsName("entries"), ]) let unpackInitTask = await taskManager.startTask() _ = try await initImage.getCreateSnapshot( platform: .current, progressUpdate: ProgressTaskCoordinator.handler(for: unpackInitTask, from: progressUpdate)) await taskManager.finish() let imageConfig = try await img.config(for: requestedPlatform).config let description = img.description let pc = try Parser.process( arguments: arguments, processFlags: process, managementFlags: management, config: imageConfig ) var config = ContainerConfiguration(id: id, image: description, process: pc) config.platform = requestedPlatform config.resources = try Parser.resources( cpus: resource.cpus, memory: resource.memory ) let tmpfs = try Parser.tmpfsMounts(management.tmpFs) let volumesOrFs = try Parser.volumes(management.volumes) let mountsOrFs = try Parser.mounts(management.mounts) var resolvedMounts: [Filesystem] = [] resolvedMounts.append(contentsOf: tmpfs) // Resolve volumes and filesystems for item in (volumesOrFs + mountsOrFs) { switch item { case .filesystem(let fs): resolvedMounts.append(fs) case .volume(let parsed): let volume = try await getOrCreateVolume(parsed: parsed, log: log) let volumeMount = Filesystem.volume( name: parsed.name, format: volume.format, source: volume.source, destination: parsed.destination, options: parsed.options ) resolvedMounts.append(volumeMount) } } config.mounts = resolvedMounts config.virtualization = management.virtualization // Parse network specifications with properties let parsedNetworks = try management.networks.map { try Parser.network($0) } if management.networks.contains(ClientNetwork.noNetworkName) { guard management.networks.count == 1 else { throw ContainerizationError(.unsupported, message: "no other networks may be created along with network \(ClientNetwork.noNetworkName)") } config.networks = [] } else { let builtinNetworkId = try await ClientNetwork.builtin?.id config.networks = try getAttachmentConfigurations( containerId: config.id, builtinNetworkId: builtinNetworkId, networks: parsedNetworks ) for attachmentConfiguration in config.networks { let network: NetworkState = try await ClientNetwork.get(id: attachmentConfiguration.network) guard case .running(_, _) = network else { throw ContainerizationError(.invalidState, message: "network \(attachmentConfiguration.network) is not running") } } } if management.dnsDisabled { config.dns = nil } else { let domain = management.dns.domain ?? DefaultsStore.getOptional(key: .defaultDNSDomain) config.dns = .init( nameservers: management.dns.nameservers, domain: domain, searchDomains: management.dns.searchDomains, options: management.dns.options ) } config.rosetta = management.rosetta || (Platform.current.architecture == "arm64" && requestedPlatform.architecture == "amd64") if management.rosetta && Platform.current.architecture != "arm64" { throw ContainerizationError(.unsupported, message: "--rosetta flag requires an arm64 host") } config.labels = try Parser.labels(management.labels) config.publishedPorts = try Parser.publishPorts(management.publishPorts) guard config.publishedPorts.count <= publishedPortCountLimit else { throw ContainerizationError(.invalidArgument, message: "cannot exceed more than \(publishedPortCountLimit) port publish descriptors") } guard !config.publishedPorts.hasOverlaps() else { throw ContainerizationError(.invalidArgument, message: "host ports for different publish port specs may not overlap") } // Parse --publish-socket arguments and add to container configuration // to enable socket forwarding from container to host. config.publishedSockets = try Parser.publishSockets(management.publishSockets) config.ssh = management.ssh config.readOnly = management.readOnly config.useInit = management.useInit if let runtime = management.runtime { config.runtimeHandler = runtime } return (config, kernel, management.initImage) } static func getAttachmentConfigurations( containerId: String, builtinNetworkId: String?, networks: [Parser.ParsedNetwork] ) throws -> [AttachmentConfiguration] { // Validate MAC addresses if provided for network in networks { if let mac = network.macAddress { try validMACAddress(mac) } } // make an FQDN for the first interface let fqdn: String? if !containerId.contains(".") { // add default domain if it exists, and container ID is unqualified if let dnsDomain = DefaultsStore.getOptional(key: .defaultDNSDomain) { fqdn = "\(containerId).\(dnsDomain)." } else { fqdn = nil } } else { // use container ID directly if fully qualified fqdn = "\(containerId)." } guard networks.isEmpty else { // Check if this is only the default network with properties (e.g., MAC address) let isOnlyDefaultNetwork = networks.count == 1 && networks[0].name == builtinNetworkId // networks may only be specified for macOS 26+ (except for default network with properties) if !isOnlyDefaultNetwork { guard #available(macOS 26, *) else { throw ContainerizationError(.invalidArgument, message: "non-default network configuration requires macOS 26 or newer") } } // attach the first network using the fqdn, and the rest using just the container ID return try networks.enumerated().map { item in let macAddress = try item.element.macAddress.map { try MACAddress($0) } let mtu = item.element.mtu ?? 1280 guard item.offset == 0 else { return AttachmentConfiguration( network: item.element.name, options: AttachmentOptions(hostname: containerId, macAddress: macAddress, mtu: mtu) ) } return AttachmentConfiguration( network: item.element.name, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: macAddress, mtu: mtu) ) } } // if no networks specified, attach to the default network guard let builtinNetworkId else { throw ContainerizationError(.invalidState, message: "builtin network is not present") } return [AttachmentConfiguration(network: builtinNetworkId, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: nil, mtu: 1280))] } private static func getKernel(management: Flags.Management) async throws -> Kernel { // For the image itself we'll take the user input and try with it as we can do userspace // emulation for x86, but for the kernel we need it to match the hosts architecture. let s: SystemPlatform = .current if let userKernel = management.kernel { guard FileManager.default.fileExists(atPath: userKernel) else { throw ContainerizationError(.notFound, message: "kernel file not found at path \(userKernel)") } let p = URL(filePath: userKernel) return .init(path: p, platform: s) } return try await ClientKernel.getDefaultKernel(for: s) } /// Parses key-value pairs from command line arguments. /// /// Supports formats like "key=value" and standalone keys (treated as "key="). /// - Parameter pairs: Array of strings in "key=value" format /// - Returns: Dictionary mapping keys to values public static func parseKeyValuePairs(_ pairs: [String]) -> [String: String] { var result: [String: String] = [:] for pair in pairs { let components = pair.split(separator: "=", maxSplits: 1) if components.count == 2 { result[String(components[0])] = String(components[1]) } else { result[pair] = "" } } return result } /// Gets an existing volume or creates it if it doesn't exist. /// Shows a warning for named volumes when auto-creating. private static func getOrCreateVolume(parsed: ParsedVolume, log: Logger) async throws -> Volume { let labels = parsed.isAnonymous ? [Volume.anonymousLabel: ""] : [:] let volume: Volume var wasCreated = false do { volume = try await ClientVolume.create( name: parsed.name, driver: "local", driverOpts: [:], labels: labels ) wasCreated = true } catch let error as VolumeError { guard case .volumeAlreadyExists = error else { throw error } // Volume already exists, just inspect it volume = try await ClientVolume.inspect(parsed.name) } catch let error as ContainerizationError { // Handle XPC-wrapped volumeAlreadyExists error guard error.message.contains("already exists") else { throw error } volume = try await ClientVolume.inspect(parsed.name) } if wasCreated && !parsed.isAnonymous { log.warning("named volume was automatically created", metadata: ["volume": "\(parsed.name)"]) } return volume } } ================================================ FILE: Sources/Services/ContainerAPIService/Client/XPC+.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// #if os(macOS) import Foundation import ContainerXPC /// Keys for XPC fields. public enum XPCKeys: String { /// Route key. case route /// Container array key. case containers /// ID key. case id // ID for a process. case processIdentifier /// Container configuration key. case containerConfig /// Container options key. case containerOptions /// Vsock port number key. case port /// Exit code for a process case exitCode /// Exit timestamp for a process case exitedAt /// An event that occurred in a container case containerEvent /// Error key. case error /// FD to a container resource key. case fd /// FDs pointing to container logs key. case logs /// Options for stopping a container key. case stopOptions /// Whether to force stop a container when deleting. case forceDelete /// Plugins case pluginName case plugins case plugin /// Archive path to export rootfs case archive /// Health check request. case ping case appRoot case installRoot case logRoot case apiServerVersion case apiServerCommit case apiServerBuild case apiServerAppName /// Process request keys. case signal case snapshot case stdin case stdout case stderr case status case width case height case processConfig /// Update progress case progressUpdateEndpoint case progressUpdateSetDescription case progressUpdateSetSubDescription case progressUpdateSetItemsName case progressUpdateAddTasks case progressUpdateSetTasks case progressUpdateAddTotalTasks case progressUpdateSetTotalTasks case progressUpdateAddItems case progressUpdateSetItems case progressUpdateAddTotalItems case progressUpdateSetTotalItems case progressUpdateAddSize case progressUpdateSetSize case progressUpdateAddTotalSize case progressUpdateSetTotalSize /// Network case networkId case networkConfig case networkState case networkStates /// Kernel case kernel case kernelTarURL case kernelFilePath case systemPlatform case kernelForce /// Init image reference case initImage /// Volume case volume case volumes case volumeName case volumeSize case volumeDriver case volumeDriverOpts case volumeLabels case volumeReadonly case volumeContainerId /// Container statistics case statistics case containerSize /// Container list filters case listFilters /// Disk usage case diskUsageStats } public enum XPCRoute: String { case containerList case containerCreate case containerBootstrap case containerCreateProcess case containerStartProcess case containerWait case containerDelete case containerStop case containerDial case containerResize case containerKill case containerState case containerLogs case containerEvent case containerStats case containerDiskUsage case containerExport case pluginLoad case pluginGet case pluginRestart case pluginUnload case pluginList case networkCreate case networkDelete case networkList case volumeCreate case volumeDelete case volumeList case volumeInspect case volumeDiskUsage case systemDiskUsage case ping case installKernel case getDefaultKernel } extension XPCMessage { public init(route: XPCRoute) { self.init(route: route.rawValue) } public func data(key: XPCKeys) -> Data? { data(key: key.rawValue) } public func dataNoCopy(key: XPCKeys) -> Data? { dataNoCopy(key: key.rawValue) } public func set(key: XPCKeys, value: Data) { set(key: key.rawValue, value: value) } public func string(key: XPCKeys) -> String? { string(key: key.rawValue) } public func set(key: XPCKeys, value: String) { set(key: key.rawValue, value: value) } public func bool(key: XPCKeys) -> Bool { bool(key: key.rawValue) } public func set(key: XPCKeys, value: Bool) { set(key: key.rawValue, value: value) } public func uint64(key: XPCKeys) -> UInt64 { uint64(key: key.rawValue) } public func set(key: XPCKeys, value: UInt64) { set(key: key.rawValue, value: value) } public func int64(key: XPCKeys) -> Int64 { int64(key: key.rawValue) } public func set(key: XPCKeys, value: Int64) { set(key: key.rawValue, value: value) } public func int(key: XPCKeys) -> Int { Int(int64(key: key.rawValue)) } public func set(key: XPCKeys, value: Int) { set(key: key.rawValue, value: Int64(value)) } public func date(key: XPCKeys) -> Date { date(key: key.rawValue) } public func set(key: XPCKeys, value: Date) { set(key: key.rawValue, value: value) } public func fileHandle(key: XPCKeys) -> FileHandle? { fileHandle(key: key.rawValue) } public func set(key: XPCKeys, value: FileHandle) { set(key: key.rawValue, value: value) } public func fileHandles(key: XPCKeys) -> [FileHandle]? { fileHandles(key: key.rawValue) } public func set(key: XPCKeys, value: [FileHandle]) throws { try set(key: key.rawValue, value: value) } public func endpoint(key: XPCKeys) -> xpc_endpoint_t? { endpoint(key: key.rawValue) } public func set(key: XPCKeys, value: xpc_endpoint_t) { set(key: key.rawValue, value: value) } } #endif ================================================ FILE: Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import ContainerXPC import Containerization import ContainerizationError import ContainerizationOS import Foundation import Logging public struct ContainersHarness: Sendable { let log: Logging.Logger let service: ContainersService public init(service: ContainersService, log: Logging.Logger) { self.log = log self.service = service } @Sendable public func list(_ message: XPCMessage) async throws -> XPCMessage { var filters = ContainerListFilters.all if let filterData = message.dataNoCopy(key: .listFilters) { filters = try JSONDecoder().decode(ContainerListFilters.self, from: filterData) } let containers = try await service.list(filters: filters) let data = try JSONEncoder().encode(containers) let reply = message.reply() reply.set(key: .containers, value: data) return reply } @Sendable public func bootstrap(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) guard let id else { throw ContainerizationError( .invalidArgument, message: "id cannot be empty" ) } let stdio = message.stdio() try await service.bootstrap(id: id, stdio: stdio) return message.reply() } @Sendable public func stop(_ message: XPCMessage) async throws -> XPCMessage { let stopOptions = try message.stopOptions() let id = message.string(key: .id) guard let id else { throw ContainerizationError( .invalidArgument, message: "id cannot be empty" ) } try await service.stop(id: id, options: stopOptions) return message.reply() } @Sendable public func dial(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) guard let id else { throw ContainerizationError( .invalidArgument, message: "id cannot be empty" ) } let port = message.uint64(key: .port) let fh = try await service.dial(id: id, port: UInt32(port)) let reply = message.reply() reply.setFileHandle(fh) return reply } @Sendable public func wait(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) guard let id else { throw ContainerizationError( .invalidArgument, message: "id cannot be empty" ) } let processID = message.string(key: .processIdentifier) guard let processID else { throw ContainerizationError( .invalidArgument, message: "process ID cannot be empty" ) } let exitStatus = try await service.wait(id: id, processID: processID) let reply = message.reply() reply.set(key: .exitCode, value: Int64(exitStatus.exitCode)) reply.set(key: .exitedAt, value: exitStatus.exitedAt) return reply } @Sendable public func resize(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) guard let id else { throw ContainerizationError( .invalidArgument, message: "id cannot be empty" ) } let processID = message.string(key: .processIdentifier) guard let processID else { throw ContainerizationError( .invalidArgument, message: "process ID cannot be empty" ) } let width = message.uint64(key: .width) let height = message.uint64(key: .height) try await service.resize( id: id, processID: processID, size: Terminal.Size(width: UInt16(width), height: UInt16(height)) ) return message.reply() } @Sendable public func kill(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) guard let id else { throw ContainerizationError( .invalidArgument, message: "id cannot be empty" ) } let processID = message.string(key: .processIdentifier) guard let processID else { throw ContainerizationError( .invalidArgument, message: "process ID cannot be empty" ) } try await service.kill( id: id, processID: processID, signal: try message.signal() ) return message.reply() } @Sendable public func create(_ message: XPCMessage) async throws -> XPCMessage { let data = message.dataNoCopy(key: .containerConfig) guard let data else { throw ContainerizationError( .invalidArgument, message: "container configuration cannot be empty" ) } let kdata = message.dataNoCopy(key: .kernel) guard let kdata else { throw ContainerizationError( .invalidArgument, message: "kernel cannot be empty" ) } let odata = message.dataNoCopy(key: .containerOptions) var options: ContainerCreateOptions = .default if let odata { options = try JSONDecoder().decode(ContainerCreateOptions.self, from: odata) } let config = try JSONDecoder().decode(ContainerConfiguration.self, from: data) let kernel = try JSONDecoder().decode(Kernel.self, from: kdata) let initImage = message.string(key: .initImage) try await service.create(configuration: config, kernel: kernel, options: options, initImage: initImage) return message.reply() } @Sendable public func createProcess(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) guard let id else { throw ContainerizationError( .invalidArgument, message: "id cannot be empty" ) } let processID = message.string(key: .processIdentifier) guard let processID else { throw ContainerizationError( .invalidArgument, message: "process ID cannot be empty" ) } let config = try message.processConfig() let stdio = message.stdio() try await service.createProcess( id: id, processID: processID, config: config, stdio: stdio ) return message.reply() } @Sendable public func startProcess(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) guard let id else { throw ContainerizationError( .invalidArgument, message: "id cannot be empty" ) } let processID = message.string(key: .processIdentifier) guard let processID else { throw ContainerizationError( .invalidArgument, message: "process ID cannot be empty" ) } try await service.startProcess( id: id, processID: processID, ) return message.reply() } @Sendable public func delete(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) guard let id else { throw ContainerizationError(.invalidArgument, message: "id cannot be empty") } let forceDelete = message.bool(key: .forceDelete) try await service.delete(id: id, force: forceDelete) return message.reply() } @Sendable public func diskUsage(_ message: XPCMessage) async throws -> XPCMessage { guard let containerId = message.string(key: .id) else { throw ContainerizationError(.invalidArgument, message: "id cannot be empty") } let size = try await service.containerDiskUsage(id: containerId) let reply = message.reply() reply.set(key: .containerSize, value: size) return reply } @Sendable public func logs(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) guard let id else { throw ContainerizationError( .invalidArgument, message: "id cannot be empty" ) } let fds = try await service.logs(id: id) let reply = message.reply() try reply.set(key: .logs, value: fds) return reply } @Sendable public func stats(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) guard let id else { throw ContainerizationError( .invalidArgument, message: "id cannot be empty" ) } let stats = try await service.stats(id: id) let data = try JSONEncoder().encode(stats) let reply = message.reply() reply.set(key: .statistics, value: data) return reply } @Sendable public func export(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) guard let id else { throw ContainerizationError( .invalidArgument, message: "id cannot be empty" ) } let archive = message.string(key: .archive) guard let archive else { throw ContainerizationError( .invalidArgument, message: "archive cannot be empty" ) } let archiveUrl = URL(fileURLWithPath: archive) try await service.exportRootfs(id: id, archive: archiveUrl) return message.reply() } } ================================================ FILE: Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import CVersion import ContainerAPIClient import ContainerPlugin import ContainerResource import ContainerSandboxServiceClient import ContainerXPC import Containerization import ContainerizationEXT4 import ContainerizationError import ContainerizationExtras import ContainerizationOCI import ContainerizationOS import Foundation import Logging import SystemPackage public actor ContainersService { struct ContainerState { var snapshot: ContainerSnapshot var client: SandboxClient? var allocatedAttachments: [AllocatedAttachment] func getClient() throws -> SandboxClient { guard let client else { var message = "no sandbox client exists" if snapshot.status == .stopped { message += ": container is stopped" } throw ContainerizationError(.invalidState, message: message) } return client } } private static let machServicePrefix = "com.apple.container" private static let launchdDomainString = try! ServiceManager.getDomainString() private let log: Logger private let debugHelpers: Bool private let containerRoot: URL private let pluginLoader: PluginLoader private let runtimePlugins: [Plugin] private let exitMonitor: ExitMonitor private let lock: AsyncLock private var containers: [String: ContainerState] // FIXME: Find a better mechanism for services running on the APIServer to work with each other private weak var networksService: NetworksService? public init( appRoot: URL, pluginLoader: PluginLoader, log: Logger, debugHelpers: Bool = false ) throws { let containerRoot = appRoot.appendingPathComponent("containers") try FileManager.default.createDirectory(at: containerRoot, withIntermediateDirectories: true) self.exitMonitor = ExitMonitor(log: log) self.lock = AsyncLock(log: log) self.containerRoot = containerRoot self.pluginLoader = pluginLoader self.log = log self.debugHelpers = debugHelpers self.runtimePlugins = pluginLoader.findPlugins().filter { $0.hasType(.runtime) } self.containers = try Self.loadAtBoot(root: containerRoot, loader: pluginLoader, log: log) } public func setNetworksService(_ service: NetworksService) async { self.networksService = service } static func loadAtBoot(root: URL, loader: PluginLoader, log: Logger) throws -> [String: ContainerState] { var directories = try FileManager.default.contentsOfDirectory( at: root, includingPropertiesForKeys: [.isDirectoryKey] ) directories = directories.filter { $0.isDirectory } let runtimePlugins = loader.findPlugins().filter { $0.hasType(.runtime) } var results = [String: ContainerState]() for dir in directories { do { let (config, options) = try Self.getContainerConfiguration(at: dir) if options?.autoRemove ?? false { let label = Self.fullLaunchdServiceLabel( runtimeName: config.runtimeHandler, instanceId: config.id) var status: Int32 = -1 try? ServiceManager.deregister(fullServiceLabel: label, status: &status) if status == 0 { log.info( "reap auto-remove container", metadata: [ "id": "\(config.id)" ] ) let bundle = ContainerResource.Bundle(path: dir) try? bundle.delete() continue } } let state = ContainerState( snapshot: .init( configuration: config, status: .stopped, networks: [], startedDate: nil ), allocatedAttachments: [] ) results[config.id] = state guard runtimePlugins.first(where: { $0.name == config.runtimeHandler }) != nil else { throw ContainerizationError( .internalError, message: "failed to find runtime plugin \(config.runtimeHandler)" ) } } catch { try? FileManager.default.removeItem(at: dir) log.warning( "failed to load container", metadata: [ "path": "\(dir.path)", "error": "\(error)", ]) } } return results } /// List containers matching the given filters. public func list(filters: ContainerListFilters = .all) async throws -> [ContainerSnapshot] { log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)" ] ) defer { log.debug( "ContainersService: exit", metadata: [ "func": "\(#function)" ] ) } return self.containers.values.compactMap { state -> ContainerSnapshot? in let snapshot = state.snapshot if !filters.ids.isEmpty { guard filters.ids.contains(snapshot.id) else { return nil } } if let status = filters.status { guard snapshot.status == status else { return nil } } for (key, value) in filters.labels { guard snapshot.configuration.labels[key] == value else { return nil } } return snapshot } } /// Execute an operation with the current container list while maintaining atomicity /// This prevents race conditions where containers are created during the operation public func withContainerList( logMetadata: Logger.Metadata? = nil, _ operation: @Sendable @escaping ([ContainerSnapshot]) async throws -> T ) async throws -> T { try await lock.withLock(logMetadata: logMetadata) { context in let snapshots = await self.containers.values.map { $0.snapshot } return try await operation(snapshots) } } /// Calculate disk usage for containers /// - Returns: Tuple of (total count, active count, total size, reclaimable size) public func calculateDiskUsage() async -> (Int, Int, UInt64, UInt64) { await lock.withLock(logMetadata: ["acquirer": "\(#function)"]) { _ in var totalSize: UInt64 = 0 var reclaimableSize: UInt64 = 0 var activeCount = 0 for (id, state) in await self.containers { let bundlePath = self.containerRoot.appendingPathComponent(id) let containerSize = Self.calculateDirectorySize(at: bundlePath.path) totalSize += containerSize if state.snapshot.status == .running { activeCount += 1 } else { // Stopped containers are reclaimable reclaimableSize += containerSize } } return (await self.containers.count, activeCount, totalSize, reclaimableSize) } } /// Get set of image references used by containers (for disk usage calculation) /// - Returns: Set of image references currently in use public func getActiveImageReferences() async -> Set { await lock.withLock(logMetadata: ["acquirer": "\(#function)"]) { _ in var imageRefs = Set() for (_, state) in await self.containers { imageRefs.insert(state.snapshot.configuration.image.reference) } return imageRefs } } /// Calculate directory size using APFS-aware resource keys /// - Parameter path: Path to directory /// - Returns: Total allocated size in bytes private static nonisolated func calculateDirectorySize(at path: String) -> UInt64 { let url = URL(fileURLWithPath: path) let fileManager = FileManager.default guard let enumerator = fileManager.enumerator( at: url, includingPropertiesForKeys: [.totalFileAllocatedSizeKey], options: [.skipsHiddenFiles] ) else { return 0 } var totalSize: UInt64 = 0 for case let fileURL as URL in enumerator { guard let resourceValues = try? fileURL.resourceValues( forKeys: [.totalFileAllocatedSizeKey] ), let fileSize = resourceValues.totalFileAllocatedSize else { continue } totalSize += UInt64(fileSize) } return totalSize } /// Create a new container from the provided id and configuration. public func create(configuration: ContainerConfiguration, kernel: Kernel, options: ContainerCreateOptions, initImage: String? = nil) async throws { log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", "id": "\(configuration.id)", ] ) defer { log.debug( "ContainersService: exit", metadata: [ "func": "\(#function)", "id": "\(configuration.id)", ] ) } try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(configuration.id)"]) { context in guard await self.containers[configuration.id] == nil else { throw ContainerizationError( .exists, message: "container already exists: \(configuration.id)" ) } var allHostnames = Set() for container in await self.containers.values { for attachmentConfiguration in container.snapshot.configuration.networks { allHostnames.insert(attachmentConfiguration.options.hostname) } } var conflictingHostnames = [String]() for attachmentConfiguration in configuration.networks { if allHostnames.contains(attachmentConfiguration.options.hostname) { conflictingHostnames.append(attachmentConfiguration.options.hostname) } } guard conflictingHostnames.isEmpty else { throw ContainerizationError( .exists, message: "hostname(s) already exist: \(conflictingHostnames)" ) } guard self.runtimePlugins.first(where: { $0.name == configuration.runtimeHandler }) != nil else { throw ContainerizationError( .notFound, message: "unable to locate runtime plugin \(configuration.runtimeHandler)" ) } // Protect against a user providing a memory amount that will cause us to not be able // to boot. We can go lower, but this is a somewhat safe threshold. Containerization // also gives a little bit extra than the user asked for to account for guest agent overhead. // // NOTE: We could potentially leave this validation to the sandbox service(s), as // it's possible there could be an implementation that can get away with a lower // amount and be perfectly safe. let minimumMemory: UInt64 = 200.mib() guard configuration.resources.memoryInBytes >= minimumMemory else { throw ContainerizationError( .invalidArgument, message: "minimum memory amount allowed is 200 MiB (got \(configuration.resources.memoryInBytes) bytes)" ) } let path = self.containerRoot.appendingPathComponent(configuration.id) let systemPlatform = kernel.platform // Fetch init image (custom or default) self.log.debug( "ContainersService: get init block", metadata: [ "id": "\(configuration.id)" ] ) let initFilesystem = try await self.getInitBlock(for: systemPlatform.ociPlatform(), imageRef: initImage) do { self.log.debug( "create snapshot", metadata: [ "id": "\(configuration.id)", "ref": "\(configuration.image.reference)", ]) let containerImage = ClientImage(description: configuration.image) let imageFs = try await options.rootFsOverride == nil ? containerImage.getCreateSnapshot(platform: configuration.platform) : nil self.log.debug( "configure runtime", metadata: [ "id": "\(configuration.id)", "kernel": "\(kernel.path)", "initfs": "\(initImage ?? ClientImage.initImageRef)", ]) let runtimeConfig = RuntimeConfiguration( path: path, initialFilesystem: initFilesystem, kernel: kernel, containerConfiguration: configuration, containerRootFilesystem: imageFs, options: options ) try runtimeConfig.writeRuntimeConfiguration() let snapshot = ContainerSnapshot( configuration: configuration, status: .stopped, networks: [], startedDate: nil ) await self.setContainerState(configuration.id, ContainerState(snapshot: snapshot, allocatedAttachments: []), context: context) } catch { throw error } } } /// Bootstrap the init process of the container. public func bootstrap(id: String, stdio: [FileHandle?]) async throws { log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) defer { log.debug( "ContainersService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) } try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in var state = try await self.getContainerState(id: id, context: context) // We've already bootstrapped this container. Ideally we should be able to // return some sort of error code from the sandbox svc to check here, but this // is also a very simple check and faster than doing an rpc to get the same result. if state.client != nil { return } let path = self.containerRoot.appendingPathComponent(id) let (config, _) = try Self.getContainerConfiguration(at: path) var allocatedAttachments = [AllocatedAttachment]() do { for n in config.networks { let allocatedAttach = try await self.networksService?.allocate( id: n.network, hostname: n.options.hostname, macAddress: n.options.macAddress ) guard var allocatedAttach = allocatedAttach else { throw ContainerizationError(.internalError, message: "failed to allocate a network") } if let mtu = n.options.mtu { let a = allocatedAttach.attachment allocatedAttach = AllocatedAttachment( attachment: Attachment( network: a.network, hostname: a.hostname, ipv4Address: a.ipv4Address, ipv4Gateway: a.ipv4Gateway, ipv6Address: a.ipv6Address, macAddress: a.macAddress, mtu: mtu ), additionalData: allocatedAttach.additionalData, pluginInfo: allocatedAttach.pluginInfo ) } allocatedAttachments.append(allocatedAttach) } try Self.registerService( plugin: self.runtimePlugins.first { $0.name == config.runtimeHandler }!, loader: self.pluginLoader, configuration: config, path: path, debug: self.debugHelpers ) let runtime = state.snapshot.configuration.runtimeHandler let sandboxClient = try await SandboxClient.create( id: id, runtime: runtime ) try await sandboxClient.bootstrap(stdio: stdio, allocatedAttachments: allocatedAttachments) try await self.exitMonitor.registerProcess( id: id, onExit: self.handleContainerExit ) state.client = sandboxClient state.allocatedAttachments = allocatedAttachments await self.setContainerState(id, state, context: context) } catch { for allocatedAttach in allocatedAttachments { do { try await self.networksService?.deallocate(attachment: allocatedAttach.attachment) } catch { self.log.error( "failed to deallocate network attachment", metadata: [ "id": "\(id)", "network": "\(allocatedAttach.attachment.network)", "error": "\(error)", ]) } } let label = Self.fullLaunchdServiceLabel( runtimeName: config.runtimeHandler, instanceId: id ) await self.exitMonitor.stopTracking(id: id) try? ServiceManager.deregister(fullServiceLabel: label) throw error } } } /// Create a new process in the container. public func createProcess( id: String, processID: String, config: ProcessConfiguration, stdio: [FileHandle?] ) async throws { log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", "processId": "\(processID)", "command": "\(config.arguments.isEmpty ? "" : config.arguments[0])", ] ) defer { log.debug( "ContainersService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) } let state = try self._getContainerState(id: id) let client = try state.getClient() try await client.createProcess( processID, config: config, stdio: stdio ) } /// Start a process in a container. This can either be a process created via /// createProcess, or the init process of the container which requires /// id == processID. public func startProcess(id: String, processID: String) async throws { log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", "processId": "\(processID)", ] ) defer { log.debug( "ContainersService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", "processId": "\(processID)", ] ) } try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)", "processId": "\(processID)"]) { context in var state = try await self.getContainerState(id: id, context: context) let isInit = Self.isInitProcess(id: id, processID: processID) if state.snapshot.status == .running && isInit { return } let client = try state.getClient() try await client.startProcess(processID) guard isInit else { return } do { let log = self.log let waitFunc: ExitMonitor.WaitHandler = { log.info("registering container with exit monitor") let code = try await client.wait(id) log.info( "container finished in exit monitor", metadata: [ "id": "\(id)", "rc": "\(code)", ]) return code } try await self.exitMonitor.track(id: id, waitingOn: waitFunc) let sandboxSnapshot = try await client.state() state.snapshot.status = .running state.snapshot.networks = sandboxSnapshot.networks state.snapshot.startedDate = Date() await self.setContainerState(id, state, context: context) } catch { await self.exitMonitor.stopTracking(id: id) try? await client.stop(options: ContainerStopOptions.default) throw error } } } /// Send a signal to the container. public func kill(id: String, processID: String, signal: Int64) async throws { log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", "processId": "\(processID)", "signal": "\(signal)", ] ) defer { log.debug( "ContainersService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", "processId": "\(processID)", ] ) } let state = try self._getContainerState(id: id) let client = try state.getClient() try await client.kill(processID, signal: signal) } /// Stop all containers inside the sandbox, aborting any processes currently /// executing inside the container, before stopping the underlying sandbox. public func stop(id: String, options: ContainerStopOptions) async throws { log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) defer { log.debug( "ContainersService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) } let state = try self._getContainerState(id: id) // Stop should be idempotent. let client: SandboxClient do { client = try state.getClient() } catch { return } do { try await client.stop(options: options) } catch let err as ContainerizationError { if err.code != .interrupted { throw err } } try await handleContainerExit(id: id) } public func dial(id: String, port: UInt32) async throws -> FileHandle { log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", "port": "\(port)", ] ) defer { log.debug( "ContainersService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", "port": "\(port)", ] ) } let state = try self._getContainerState(id: id) let client = try state.getClient() return try await client.dial(port) } /// Wait waits for the container's init process or exec to exit and returns the /// exit status. public func wait(id: String, processID: String) async throws -> ExitStatus { log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", "processId": "\(processID)", ] ) defer { log.debug( "ContainersService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", "processId": "\(processID)", ] ) } let state = try self._getContainerState(id: id) let client = try state.getClient() return try await client.wait(processID) } /// Resize resizes the container's PTY if one exists. public func resize(id: String, processID: String, size: Terminal.Size) async throws { log.trace( "ContainersService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", "processId": "\(processID)", ] ) defer { log.trace( "ContainersService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", "processId": "\(processID)", ] ) } let state = try self._getContainerState(id: id) let client = try state.getClient() try await client.resize(processID, size: size) } // Get the logs for the container. public func logs(id: String) async throws -> [FileHandle] { log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) defer { log.debug( "ContainersService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) } // Logs doesn't care if the container is running or not, just that // the bundle is there, and that the files actually exist. We do // first try and get the container state so we get a nicer error message // (container foo not found) however. do { _ = try _getContainerState(id: id) let path = self.containerRoot.appendingPathComponent(id) let bundle = ContainerResource.Bundle(path: path) return [ try FileHandle(forReadingFrom: bundle.containerLog), try FileHandle(forReadingFrom: bundle.bootlog), ] } catch { throw ContainerizationError( .internalError, message: "failed to open container logs: \(error)" ) } } /// Get statistics for the container. public func stats(id: String) async throws -> ContainerStats { log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) defer { log.debug( "ContainersService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) } let state = try self._getContainerState(id: id) let client = try state.getClient() return try await client.statistics() } /// Delete a container and its resources. public func delete(id: String, force: Bool) async throws { log.info( "ContainersService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", "force": "\(force)", ] ) defer { log.debug( "ContainersService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) } let state = try self._getContainerState(id: id) switch state.snapshot.status { case .running: if !force { throw ContainerizationError( .invalidState, message: "container \(id) is \(state.snapshot.status) and can not be deleted" ) } let opts = ContainerStopOptions( timeoutInSeconds: 5, signal: SIGKILL ) let client = try state.getClient() try await client.stop(options: opts) try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in self.log.info( "ContainersService: attempt cleanup", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) try await self.cleanUp(id: id, context: context) self.log.info( "ContainersService: successful cleanup", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) } case .stopping: throw ContainerizationError( .invalidState, message: "container \(id) is \(state.snapshot.status) and can not be deleted" ) default: try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in try await self.cleanUp(id: id, context: context) } } } public func containerDiskUsage(id: String) async throws -> UInt64 { log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) defer { log.debug( "ContainersService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) } let containerPath = self.containerRoot.appendingPathComponent(id).path return Self.calculateDirectorySize(at: containerPath) } public func exportRootfs(id: String, archive: URL) async throws { self.log.debug("\(#function)") let state = try self._getContainerState(id: id) guard state.snapshot.status == .stopped else { throw ContainerizationError(.invalidState, message: "container is not stopped") } let path = self.containerRoot.appendingPathComponent(id) let bundle = ContainerResource.Bundle(path: path) let rootfs = bundle.containerRootfsBlock try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive)) } private func handleContainerExit(id: String, code: ExitStatus? = nil) async throws { try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { [self] context in try await handleContainerExit(id: id, code: code, context: context) } } private func handleContainerExit(id: String, code: ExitStatus?, context: AsyncLock.Context) async throws { if let code { self.log.info( "handling container exit", metadata: [ "id": "\(id)", "rc": "\(code)", ]) } var state: ContainerState do { state = try self.getContainerState(id: id, context: context) if state.snapshot.status == .stopped { return } } catch { // Was auto removed by the background thread, nothing for us to do. return } await self.exitMonitor.stopTracking(id: id) // Shutdown and deregister the sandbox service self.log.info("shutting down sandbox service", metadata: ["id": "\(id)"]) let path = self.containerRoot.appendingPathComponent(id) let bundle = ContainerResource.Bundle(path: path) let config = try bundle.configuration let label = Self.fullLaunchdServiceLabel( runtimeName: config.runtimeHandler, instanceId: id ) // Try to shutdown the client gracefully, but if the sandbox service // is already dead (e.g., killed externally), we should still continue // with state cleanup. if let client = state.client { do { try await client.shutdown() } catch { self.log.error( "failed to shutdown sandbox service", metadata: [ "id": "\(id)", "error": "\(error)", ]) } } // Deregister the service, launchd will terminate the process. // This may also fail if the service was already deregistered or // the process was killed externally. do { try ServiceManager.deregister(fullServiceLabel: label) self.log.info("deregistered sandbox service", metadata: ["id": "\(id)"]) } catch { self.log.error( "failed to deregister sandbox service", metadata: [ "id": "\(id)", "error": "\(error)", ]) } // Best effort deallocate network attachments for the container. Don't throw on // failure so we can continue with state cleanup. self.log.info("deallocating network attachments", metadata: ["id": "\(id)"]) for allocatedAttach in state.allocatedAttachments { do { try await self.networksService?.deallocate(attachment: allocatedAttach.attachment) } catch { self.log.error( "failed to deallocate network attachment", metadata: [ "id": "\(id)", "network": "\(allocatedAttach.attachment.network)", "error": "\(error)", ]) } } state.snapshot.status = .stopped state.snapshot.networks = [] state.client = nil state.allocatedAttachments = [] await self.setContainerState(id, state, context: context) let options = try getContainerCreationOptions(id: id) if options.autoRemove { try await self.cleanUp(id: id, context: context) } } private static func fullLaunchdServiceLabel(runtimeName: String, instanceId: String) -> String { "\(Self.launchdDomainString)/\(Self.machServicePrefix).\(runtimeName).\(instanceId)" } private func _cleanUp(id: String) async throws { log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) defer { log.debug( "ContainersService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) } // Did the exit container handler win? if self.containers[id] == nil { return } // To be pedantic. This is only needed if something in the "launch // the init process" lifecycle fails before actually fork+exec'ing // the OCI runtime. await self.exitMonitor.stopTracking(id: id) let path = self.containerRoot.appendingPathComponent(id) // Try to get config for service deregistration // Don't fail if bundle is incomplete var config: ContainerConfiguration? let bundle = ContainerResource.Bundle(path: path) do { config = try bundle.configuration } catch { self.log.warning( "failed to read bundle configuration during cleanup for container", metadata: [ "id": "\(id)", "error": "\(error)", ]) } // Only try to deregister service if we have a valid config // TODO: Change this so we don't have to reread the config // possibly store the container ID to service label mapping if let config = config { let label = Self.fullLaunchdServiceLabel( runtimeName: config.runtimeHandler, instanceId: id ) try? ServiceManager.deregister(fullServiceLabel: label) } // Always try to delete the bundle directory, even if it's incomplete do { try bundle.delete() } catch { self.log.warning( "failed to delete bundle for container", metadata: [ "id": "\(id)", "error": "\(error)", ]) } self.containers.removeValue(forKey: id) } private func cleanUp(id: String, context: AsyncLock.Context) async throws { try await self._cleanUp(id: id) } private func getContainerCreationOptions(id: String) throws -> ContainerCreateOptions { let path = self.containerRoot.appendingPathComponent(id) let bundle = ContainerResource.Bundle(path: path) let options: ContainerCreateOptions = try bundle.load(filename: "options.json") return options } private func getInitBlock(for platform: Platform, imageRef: String? = nil) async throws -> Filesystem { let ref = imageRef ?? ClientImage.initImageRef let initImage = try await ClientImage.fetch(reference: ref, platform: platform) var fs = try await initImage.getCreateSnapshot(platform: platform) fs.options = ["ro"] return fs } private static func registerService( plugin: Plugin, loader: PluginLoader, configuration: ContainerConfiguration, path: URL, debug: Bool ) throws { let args = [ "start", "--root", path.path, "--uuid", configuration.id, debug ? "--debug" : nil, ].compactMap { $0 } try loader.registerWithLaunchd( plugin: plugin, pluginStateRoot: path, args: args, instanceId: configuration.id ) } private func setContainerState(_ id: String, _ state: ContainerState, context: AsyncLock.Context) async { self.containers[id] = state } private func getContainerState(id: String, context: AsyncLock.Context) throws -> ContainerState { try self._getContainerState(id: id) } private func _getContainerState(id: String) throws -> ContainerState { let state = self.containers[id] guard let state else { throw ContainerizationError( .notFound, message: "container with ID \(id) not found" ) } return state } private static func isInitProcess(id: String, processID: String) -> Bool { id == processID } /// Get container configuration, either from existing bundle or from RuntimeConfiguration private static func getContainerConfiguration(at path: URL) throws -> (ContainerConfiguration, ContainerCreateOptions?) { let bundle = ContainerResource.Bundle(path: path) do { let config = try bundle.configuration let options: ContainerCreateOptions? = try? bundle.load(filename: "options.json") return (config, options) } catch { // Bundle doesn't exist or incomplete, try runtime configuration // This handles containers that were created but not started yet let runtimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: path) guard let config = runtimeConfig.containerConfiguration else { throw ContainerizationError(.internalError, message: "runtime configuration missing container configuration") } return (config, runtimeConfig.options) } } } extension XPCMessage { func signal() throws -> Int64 { self.int64(key: .signal) } func stopOptions() throws -> ContainerStopOptions { guard let data = self.dataNoCopy(key: .stopOptions) else { throw ContainerizationError(.invalidArgument, message: "empty StopOptions") } return try JSONDecoder().decode(ContainerStopOptions.self, from: data) } func setState(_ state: SandboxSnapshot) throws { let data = try JSONEncoder().encode(state) self.set(key: .snapshot, value: data) } func stdio() -> [FileHandle?] { var handles = [FileHandle?](repeating: nil, count: 3) if let stdin = self.fileHandle(key: .stdin) { handles[0] = stdin } if let stdout = self.fileHandle(key: .stdout) { handles[1] = stdout } if let stderr = self.fileHandle(key: .stderr) { handles[2] = stderr } return handles } func setFileHandle(_ handle: FileHandle) { self.set(key: .fd, value: handle) } func processConfig() throws -> ProcessConfiguration { guard let data = self.dataNoCopy(key: .processConfig) else { throw ContainerizationError(.invalidArgument, message: "empty process configuration") } return try JSONDecoder().decode(ProcessConfiguration.self, from: data) } } ================================================ FILE: Sources/Services/ContainerAPIService/Server/DiskUsage/DiskUsageHarness.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import ContainerXPC import ContainerizationError import Foundation import Logging /// XPC harness for disk usage operations public struct DiskUsageHarness: Sendable { let log: Logger let service: DiskUsageService public init(service: DiskUsageService, log: Logger) { self.log = log self.service = service } @Sendable public func get(_ message: XPCMessage) async throws -> XPCMessage { do { let stats = try await service.calculateDiskUsage() let data = try JSONEncoder().encode(stats) let reply = message.reply() reply.set(key: .diskUsageStats, value: data) return reply } catch { log.error("failed to get disk usage", metadata: ["error": "\(error)"]) throw ContainerizationError( .internalError, message: "failed to get disk usage", cause: error ) } } } ================================================ FILE: Sources/Services/ContainerAPIService/Server/DiskUsage/DiskUsageService.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import Logging /// Service for calculating disk usage across all resource types public actor DiskUsageService { private let containersService: ContainersService private let volumesService: VolumesService private let log: Logger public init( containersService: ContainersService, volumesService: VolumesService, log: Logger ) { self.containersService = containersService self.volumesService = volumesService self.log = log } /// Calculate disk usage for all resource types public func calculateDiskUsage() async throws -> DiskUsageStats { log.debug("calculating disk usage for all resources") // Get active image references first (needed for image calculation) let activeImageRefs = await containersService.getActiveImageReferences() // Query all services concurrently async let imageStats = ClientImage.calculateDiskUsage(activeReferences: activeImageRefs) async let containerStats = containersService.calculateDiskUsage() async let volumeStats = volumesService.calculateDiskUsage() let (imageData, containerData, volumeData) = try await (imageStats, containerStats, volumeStats) let stats = DiskUsageStats( images: ResourceUsage( total: imageData.totalCount, active: imageData.activeCount, sizeInBytes: imageData.totalSize, reclaimable: imageData.reclaimableSize ), containers: ResourceUsage( total: containerData.0, active: containerData.1, sizeInBytes: containerData.2, reclaimable: containerData.3 ), volumes: ResourceUsage( total: volumeData.0, active: volumeData.1, sizeInBytes: volumeData.2, reclaimable: volumeData.3 ) ) log.debug( "disk usage calculation complete", metadata: [ "images_total": "\(imageData.totalCount)", "containers_total": "\(containerData.0)", "volumes_total": "\(volumeData.0)", ]) return stats } } ================================================ FILE: Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import CVersion import ContainerAPIClient import ContainerVersion import ContainerXPC import Containerization import Foundation import Logging import SystemPackage public actor HealthCheckHarness { private let appRoot: URL private let installRoot: URL private let logRoot: FilePath? private let log: Logger public init(appRoot: URL, installRoot: URL, logRoot: FilePath?, log: Logger) { self.appRoot = appRoot self.installRoot = installRoot self.logRoot = logRoot self.log = log } @Sendable public func ping(_ message: XPCMessage) async -> XPCMessage { let reply = message.reply() reply.set(key: .appRoot, value: appRoot.absoluteString) reply.set(key: .installRoot, value: installRoot.absoluteString) if let logRoot { reply.set(key: .logRoot, value: logRoot.string) } reply.set(key: .apiServerVersion, value: ReleaseVersion.singleLine(appName: "container-apiserver")) reply.set(key: .apiServerCommit, value: get_git_commit().map { String(cString: $0) } ?? "unspecified") // Extra optional fields for richer client display reply.set(key: .apiServerBuild, value: ReleaseVersion.buildType()) reply.set(key: .apiServerAppName, value: "container-apiserver") return reply } } ================================================ FILE: Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import ContainerXPC import Containerization import ContainerizationError import Foundation import Logging public struct KernelHarness: Sendable { private let log: Logging.Logger private let service: KernelService public init(service: KernelService, log: Logging.Logger) { self.log = log self.service = service } @Sendable public func install(_ message: XPCMessage) async throws -> XPCMessage { let kernelFilePath = try message.kernelFilePath() let platform = try message.platform() let force = try message.kernelForce() guard let kernelTarUrl = try message.kernelTarURL() else { // We have been given a path to a kernel binary on disk guard let kernelFile = URL(string: kernelFilePath) else { throw ContainerizationError(.invalidArgument, message: "invalid kernel file path: \(kernelFilePath)") } try await self.service.installKernel(kernelFile: kernelFile, platform: platform, force: force) return message.reply() } let progressUpdateService = ProgressUpdateService(message: message) try await self.service.installKernelFrom( tar: kernelTarUrl, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progressUpdateService?.handler, force: force) return message.reply() } @Sendable public func getDefaultKernel(_ message: XPCMessage) async throws -> XPCMessage { guard let platformData = message.dataNoCopy(key: .systemPlatform) else { throw ContainerizationError(.invalidArgument, message: "missing SystemPlatform") } let platform = try JSONDecoder().decode(SystemPlatform.self, from: platformData) let kernel = try await self.service.getDefaultKernel(platform: platform) let reply = message.reply() let data = try JSONEncoder().encode(kernel) reply.set(key: .kernel, value: data) return reply } } extension XPCMessage { fileprivate func platform() throws -> SystemPlatform { guard let platformData = self.dataNoCopy(key: .systemPlatform) else { throw ContainerizationError(.invalidArgument, message: "missing SystemPlatform in XPC Message") } let platform = try JSONDecoder().decode(SystemPlatform.self, from: platformData) return platform } fileprivate func kernelFilePath() throws -> String { guard let kernelFilePath = self.string(key: .kernelFilePath) else { throw ContainerizationError(.invalidArgument, message: "missing kernel file path in XPC Message") } return kernelFilePath } fileprivate func kernelTarURL() throws -> URL? { guard let kernelTarURLString = self.string(key: .kernelTarURL) else { return nil } guard let k = URL(string: kernelTarURLString) else { throw ContainerizationError(.invalidArgument, message: "cannot parse URL from \(kernelTarURLString)") } return k } fileprivate func kernelForce() throws -> Bool { self.bool(key: .kernelForce) } } ================================================ FILE: Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import Containerization import ContainerizationArchive import ContainerizationError import ContainerizationExtras import Foundation import Logging import TerminalProgress public actor KernelService { private static let defaultKernelNamePrefix: String = "default.kernel-" private let log: Logger private let kernelDirectory: URL public init(log: Logger, appRoot: URL) throws { self.log = log self.kernelDirectory = appRoot.appending(path: "kernels") try FileManager.default.createDirectory(at: self.kernelDirectory, withIntermediateDirectories: true) } /// Copies a kernel binary from a local path on disk into the managed kernels directory /// as the default kernel for the provided platform. public func installKernel(kernelFile url: URL, platform: SystemPlatform = .linuxArm, force: Bool) throws { log.debug( "KernelService: enter", metadata: [ "func": "\(#function)", "kernelFile": "\(url)", "platform": "\(String(describing: platform))", ] ) defer { log.debug( "KernelService: exit", metadata: [ "func": "\(#function)", "kernelFile": "\(url)", "platform": "\(String(describing: platform))", ] ) } let kFile = url.resolvingSymlinksInPath() let destPath = self.kernelDirectory.appendingPathComponent(kFile.lastPathComponent) if force { do { try FileManager.default.removeItem(at: destPath) } catch let error as NSError { guard error.code == NSFileNoSuchFileError else { throw error } } } try FileManager.default.copyItem(at: kFile, to: destPath) try Task.checkCancellation() do { try self.setDefaultKernel(name: kFile.lastPathComponent, platform: platform) } catch { try? FileManager.default.removeItem(at: destPath) throw error } } /// Copies a kernel binary from inside of tar file into the managed kernels directory /// as the default kernel for the provided platform. /// The parameter `tar` maybe a location to a local file on disk, or a remote URL. public func installKernelFrom(tar: URL, kernelFilePath: String, platform: SystemPlatform, progressUpdate: ProgressUpdateHandler?, force: Bool) async throws { log.debug( "KernelService: enter", metadata: [ "func": "\(#function)", "tar": "\(tar)", "kernelFilePath": "\(kernelFilePath)", "platform": "\(String(describing: platform))", ] ) defer { log.debug( "KernelService: exit", metadata: [ "func": "\(#function)", "tar": "\(tar)", "kernelFilePath": "\(kernelFilePath)", "platform": "\(String(describing: platform))", ] ) } let tempDir = FileManager.default.uniqueTemporaryDirectory() defer { try? FileManager.default.removeItem(at: tempDir) } await progressUpdate?([ .setDescription("Downloading kernel") ]) let taskManager = ProgressTaskCoordinator() let downloadTask = await taskManager.startTask() var tarFile = tar if !FileManager.default.fileExists(atPath: tar.absoluteString) { self.log.debug("KernelService: start download", metadata: ["tar": "\(tar)"]) tarFile = tempDir.appendingPathComponent(tar.lastPathComponent) var downloadProgressUpdate: ProgressUpdateHandler? if let progressUpdate { downloadProgressUpdate = ProgressTaskCoordinator.handler(for: downloadTask, from: progressUpdate) } try await ContainerAPIClient.FileDownloader.downloadFile(url: tar, to: tarFile, progressUpdate: downloadProgressUpdate) } await taskManager.finish() await progressUpdate?([ .setDescription("Unpacking kernel") ]) let kernelFile = try self.extractFile(tarFile: tarFile, at: kernelFilePath, to: tempDir) try self.installKernel(kernelFile: kernelFile, platform: platform, force: force) if !FileManager.default.fileExists(atPath: tar.absoluteString) { try FileManager.default.removeItem(at: tarFile) } } private func setDefaultKernel(name: String, platform: SystemPlatform) throws { log.debug( "KernelService: enter", metadata: [ "func": "\(#function)", "name": "\(name)", "platform": "\(String(describing: platform))", ] ) defer { log.debug( "KernelService: exit", metadata: [ "func": "\(#function)", "name": "\(name)", "platform": "\(String(describing: platform))", ] ) } let kernelPath = self.kernelDirectory.appendingPathComponent(name) guard FileManager.default.fileExists(atPath: kernelPath.path) else { throw ContainerizationError(.notFound, message: "kernel not found at \(kernelPath)") } let name = "\(Self.defaultKernelNamePrefix)\(platform.architecture)" let defaultKernelPath = self.kernelDirectory.appendingPathComponent(name) try? FileManager.default.removeItem(at: defaultKernelPath) try FileManager.default.createSymbolicLink(at: defaultKernelPath, withDestinationURL: kernelPath) } public func getDefaultKernel(platform: SystemPlatform = .linuxArm) async throws -> Kernel { log.debug( "KernelService: enter", metadata: [ "func": "\(#function)", "platform": "\(String(describing: platform))", ] ) defer { log.debug( "KernelService: exit", metadata: [ "func": "\(#function)", "platform": "\(String(describing: platform))", ] ) } let name = "\(Self.defaultKernelNamePrefix)\(platform.architecture)" let defaultKernelPath = self.kernelDirectory.appendingPathComponent(name).resolvingSymlinksInPath() guard FileManager.default.fileExists(atPath: defaultKernelPath.path) else { throw ContainerizationError(.notFound, message: "default kernel not found at \(defaultKernelPath)") } return Kernel(path: defaultKernelPath, platform: platform) } private func extractFile(tarFile: URL, at: String, to directory: URL) throws -> URL { var target = at var archiveReader = try ArchiveReader(file: tarFile) var (entry, data) = try archiveReader.extractFile(path: target) // if the target file is a symlink, get the data for the actual file if entry.fileType == .symbolicLink, let symlinkRelative = entry.symlinkTarget { // the previous extractFile changes the underlying file pointer, so we need to reopen the file // to ensure we traverse all the files in the archive archiveReader = try ArchiveReader(file: tarFile) let symlinkTarget = URL(filePath: target).deletingLastPathComponent().appending(path: symlinkRelative) // standardize so that we remove any and all ../ and ./ in the path since symlink targets // are relative paths to the target file from the symlink's parent dir itself target = symlinkTarget.standardized.relativePath let (_, targetData) = try archiveReader.extractFile(path: target) data = targetData } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) let fileName = URL(filePath: target).lastPathComponent let fileURL = directory.appendingPathComponent(fileName) try data.write(to: fileURL, options: .atomic) return fileURL } } ================================================ FILE: Sources/Services/ContainerAPIService/Server/Networks/NetworksHarness.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import ContainerXPC import ContainerizationError import ContainerizationOS import Foundation import Logging public struct NetworksHarness: Sendable { let log: Logging.Logger let service: NetworksService public init(service: NetworksService, log: Logging.Logger) { self.log = log self.service = service } @Sendable public func list(_ message: XPCMessage) async throws -> XPCMessage { let containers = try await service.list() let data = try JSONEncoder().encode(containers) let reply = message.reply() reply.set(key: .networkStates, value: data) return reply } @Sendable public func create(_ message: XPCMessage) async throws -> XPCMessage { let data = message.dataNoCopy(key: .networkConfig) guard let data else { throw ContainerizationError(.invalidArgument, message: "network configuration cannot be empty") } let config = try JSONDecoder().decode(NetworkConfiguration.self, from: data) let networkState = try await service.create(configuration: config) let networkData = try JSONEncoder().encode(networkState) let reply = message.reply() reply.set(key: .networkState, value: networkData) return reply } @Sendable public func delete(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .networkId) guard let id else { throw ContainerizationError(.invalidArgument, message: "id cannot be empty") } try await service.delete(id: id) return message.reply() } } ================================================ FILE: Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import ContainerNetworkServiceClient import ContainerPersistence import ContainerPlugin import ContainerResource import ContainerXPC import Containerization import ContainerizationError import ContainerizationExtras import ContainerizationOS import Foundation import Logging public actor NetworksService { struct NetworkServiceState { var networkState: NetworkState var client: NetworkClient } private let pluginLoader: PluginLoader private let resourceRoot: URL private let containersService: ContainersService private let log: Logger private let debugHelpers: Bool private let store: FilesystemEntityStore private let networkPlugins: [Plugin] private var busyNetworks = Set() private let stateLock = AsyncLock() private var serviceStates = [String: NetworkServiceState]() public init( pluginLoader: PluginLoader, resourceRoot: URL, containersService: ContainersService, log: Logger, debugHelpers: Bool = false, ) async throws { self.pluginLoader = pluginLoader self.resourceRoot = resourceRoot self.containersService = containersService self.log = log self.debugHelpers = debugHelpers try FileManager.default.createDirectory(at: resourceRoot, withIntermediateDirectories: true) self.store = try FilesystemEntityStore( path: resourceRoot, type: "network", log: log ) let networkPlugins = pluginLoader .findPlugins() .filter { $0.hasType(.network) } guard !networkPlugins.isEmpty else { throw ContainerizationError(.internalError, message: "cannot find any plugins with type network") } self.networkPlugins = networkPlugins let configurations = try await store.list() for var configuration in configurations { // Ensure the network with id "default" is marked as builtin. if configuration.id == ClientNetwork.defaultNetworkName { let role = configuration.labels[ResourceLabelKeys.role] if role == nil || role != ResourceRoleValues.builtin { configuration.labels[ResourceLabelKeys.role] = ResourceRoleValues.builtin try await store.update(configuration) } } // Ensure that the network always has plugin information. // Before this field was added, the code always assumed we were using the // container-network-vmnet network plugin, so it should be safe to fallback to that // if no info was found in an on disk configuration. if configuration.pluginInfo == nil { configuration.pluginInfo = NetworkPluginInfo(plugin: "container-network-vmnet") try await store.update(configuration) } // Start up the network. do { try await registerService(configuration: configuration) } catch { log.error( "failed to start network", metadata: [ "id": "\(configuration.id)", "error": "\(error)", ]) } // This call will normally take ~20-100ms to complete after service // registration, but on a fresh system (e.g. CI runner), it may take // 5 seconds or considerably more from the registration of this first // network service to its execution. let client = try Self.getClient(configuration: configuration) var networkState = try await client.state() // FIXME: Temporary workaround for persisted configuration being overwritten // by what comes back from the network helper, which messes up creationDate. // FIXME: Temporarily need to override the plugin information with the info from // the helper, so we can ensure that older networks get a variant value. var finalConfig = configuration switch networkState { case .created(let helperConfig): finalConfig.pluginInfo = helperConfig.pluginInfo networkState = NetworkState.created(finalConfig) case .running(let helperConfig, let status): finalConfig.pluginInfo = helperConfig.pluginInfo networkState = NetworkState.running(finalConfig, status) } let state = NetworkServiceState( networkState: networkState, client: client ) serviceStates[finalConfig.id] = state guard case .running = networkState else { log.error( "network failed to start", metadata: [ "id": "\(finalConfig.id)", "state": "\(networkState.state)", ]) return } } } /// List all networks registered with the service. public func list() async throws -> [NetworkState] { log.debug("NetworksService: enter", metadata: ["func": "\(#function)"]) defer { log.debug("NetworksService: exit", metadata: ["func": "\(#function)"]) } return serviceStates.reduce(into: [NetworkState]()) { $0.append($1.value.networkState) } } /// Create a new network from the provided configuration. public func create(configuration: NetworkConfiguration) async throws -> NetworkState { log.debug( "NetworksService: enter", metadata: [ "func": "\(#function)", "id": "\(configuration.id)", ] ) defer { log.debug( "NetworksService: exit", metadata: [ "func": "\(#function)", "id": "\(configuration.id)", ] ) } //Ensure that the network is not named "none" if configuration.id == ClientNetwork.noNetworkName { throw ContainerizationError(.unsupported, message: "network \(configuration.id) is not a valid name") } // Ensure nobody is manipulating the network already. guard !busyNetworks.contains(configuration.id) else { throw ContainerizationError(.exists, message: "network \(configuration.id) has a pending operation") } busyNetworks.insert(configuration.id) defer { busyNetworks.remove(configuration.id) } // Ensure the network doesn't already exist. return try await self.stateLock.withLock { _ in guard await self.serviceStates[configuration.id] == nil else { throw ContainerizationError(.exists, message: "network \(configuration.id) already exists") } // Create and start the network. try await self.registerService(configuration: configuration) let client = try Self.getClient(configuration: configuration) // Ensure the network is running, and set up the persistent network state // using our configuration data guard case .running(let helperConfig, let status) = try await client.state() else { throw ContainerizationError(.invalidState, message: "network \(configuration.id) failed to start") } var finalConfig = configuration finalConfig.pluginInfo = helperConfig.pluginInfo let networkState: NetworkState = .running(finalConfig, status) let serviceState = NetworkServiceState(networkState: networkState, client: client) await self.setServiceState(key: finalConfig.id, value: serviceState) // Persist the configuration data. do { try await self.store.create(finalConfig) return networkState } catch { await self.removeServiceState(key: finalConfig.id) do { try await self.deregisterService(configuration: finalConfig) } catch { self.log.error( "failed to deregister network service after failed creation", metadata: [ "id": "\(finalConfig.id)", "error": "\(error.localizedDescription)", ]) } throw error } } } /// Delete a network. public func delete(id: String) async throws { log.debug( "NetworksService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) defer { log.debug( "NetworksService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) } // check actor busy state guard !busyNetworks.contains(id) else { throw ContainerizationError(.exists, message: "network \(id) has a pending operation") } // make actor state busy for this network busyNetworks.insert(id) defer { busyNetworks.remove(id) } log.info( "deleting network", metadata: [ "id": "\(id)" ] ) try await stateLock.withLock { _ in guard let serviceState = await self.serviceStates[id] else { throw ContainerizationError(.notFound, message: "no network for id \(id)") } guard case .running(let netConfig, _) = serviceState.networkState else { throw ContainerizationError(.invalidState, message: "cannot delete network \(id) in state \(serviceState.networkState.state)") } // basic sanity checks on network itself if serviceState.networkState.isBuiltin { throw ContainerizationError(.invalidArgument, message: "cannot delete builtin network: \(id)") } // prevent container operations while we atomically check and delete try await self.containersService.withContainerList(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { containers in // find all containers that refer to the network var referringContainers = Set() for container in containers { for attachmentConfiguration in container.configuration.networks { if attachmentConfiguration.network == id { referringContainers.insert(container.configuration.id) break } } } // bail if any referring containers guard referringContainers.isEmpty else { throw ContainerizationError( .invalidState, message: "cannot delete subnet \(id) with referring containers: \(referringContainers.joined(separator: ", "))" ) } // disable the allocator so nothing else can attach // TODO: remove this from the network helper later, not necesssary now that withContainerList is here guard try await serviceState.client.disableAllocator() else { throw ContainerizationError(.invalidState, message: "cannot delete subnet \(id) because the IP allocator cannot be disabled with active containers") } // start network deletion, this is the last place we'll want to throw do { try await self.deregisterService(configuration: netConfig) } catch { self.log.error( "failed to deregister network service", metadata: [ "id": "\(id)", "error": "\(error.localizedDescription)", ]) } // deletion is underway, do not throw anything now do { try await self.store.delete(id) } catch { self.log.error( "failed to delete network from configuration store", metadata: [ "id": "\(id)", "error": "\(error.localizedDescription)", ]) } } // having deleted successfully, remove the runtime state await self.removeServiceState(key: id) } } /// Perform a hostname lookup on all networks. /// /// - Parameter hostname: A canonical DNS hostname with a trailing dot (e.g. `"example.com."`). public func lookup(hostname: String) async throws -> Attachment? { try await self.stateLock.withLock { _ in for state in await self.serviceStates.values { guard let allocation = try await state.client.lookup(hostname: hostname) else { continue } return allocation } return nil } } public func allocate(id: String, hostname: String, macAddress: MACAddress?) async throws -> AllocatedAttachment { guard let serviceState = serviceStates[id] else { throw ContainerizationError(.notFound, message: "no network for id \(id)") } guard let pluginInfo = serviceState.networkState.pluginInfo else { throw ContainerizationError(.internalError, message: "network \(id) missing plugin information") } let (attach, additionalData) = try await serviceState.client.allocate(hostname: hostname, macAddress: macAddress) return AllocatedAttachment( attachment: attach, additionalData: additionalData, pluginInfo: pluginInfo ) } public func deallocate(attachment: Attachment) async throws { guard let serviceState = serviceStates[attachment.network] else { throw ContainerizationError(.notFound, message: "no network for id \(attachment.network)") } return try await serviceState.client.deallocate(hostname: attachment.hostname) } private static func getClient(configuration: NetworkConfiguration) throws -> NetworkClient { guard let pluginInfo = configuration.pluginInfo else { throw ContainerizationError(.internalError, message: "network \(configuration.id) missing plugin information") } return NetworkClient(id: configuration.id, plugin: pluginInfo.plugin) } private func registerService(configuration: NetworkConfiguration) async throws { guard configuration.mode == .nat || configuration.mode == .hostOnly else { throw ContainerizationError(.invalidArgument, message: "unsupported network mode \(configuration.mode.rawValue)") } guard let pluginInfo = configuration.pluginInfo else { throw ContainerizationError(.internalError, message: "network \(configuration.id) missing plugin information") } guard let networkPlugin = self.networkPlugins.first(where: { $0.name == pluginInfo.plugin }) else { throw ContainerizationError( .notFound, message: "unable to locate network plugin \(pluginInfo.plugin)" ) } guard let serviceIdentifier = networkPlugin.getMachService(instanceId: configuration.id, type: .network) else { throw ContainerizationError(.invalidArgument, message: "unsupported network mode \(configuration.mode.rawValue)") } var args = [ "start", "--id", configuration.id, "--service-identifier", serviceIdentifier, "--mode", configuration.mode.rawValue, ] if debugHelpers { args.append("--debug") } if let ipv4Subnet = configuration.ipv4Subnet { var existingCidrs: [CIDRv4] = [] for serviceState in serviceStates.values { if case .running(_, let status) = serviceState.networkState { existingCidrs.append(status.ipv4Subnet) } } let overlap = existingCidrs.first { $0.contains(ipv4Subnet.lower) || $0.contains(ipv4Subnet.upper) || ipv4Subnet.contains($0.lower) || ipv4Subnet.contains($0.upper) } if let overlap { throw ContainerizationError(.exists, message: "IPv4 subnet \(ipv4Subnet) overlaps an existing network with subnet \(overlap)") } args += ["--subnet", ipv4Subnet.description] } if let ipv6Subnet = configuration.ipv6Subnet { var existingCidrs: [CIDRv6] = [] for serviceState in serviceStates.values { if case .running(_, let status) = serviceState.networkState, let otherIPv6Subnet = status.ipv6Subnet { existingCidrs.append(otherIPv6Subnet) } } let overlap = existingCidrs.first { $0.contains(ipv6Subnet.lower) || $0.contains(ipv6Subnet.upper) || ipv6Subnet.contains($0.lower) || ipv6Subnet.contains($0.upper) } if let overlap { throw ContainerizationError(.exists, message: "IPv6 subnet \(ipv6Subnet) overlaps an existing network with subnet \(overlap)") } args += ["--subnet-v6", ipv6Subnet.description] } if let variant = configuration.pluginInfo?.variant { args += ["--variant", variant] } try await pluginLoader.registerWithLaunchd( plugin: networkPlugin, pluginStateRoot: store.entityUrl(configuration.id), args: args, instanceId: configuration.id ) } private func deregisterService(configuration: NetworkConfiguration) async throws { guard let pluginInfo = configuration.pluginInfo else { throw ContainerizationError(.internalError, message: "network \(configuration.id) missing plugin information") } guard let networkPlugin = self.networkPlugins.first(where: { $0.name == pluginInfo.plugin }) else { throw ContainerizationError( .notFound, message: "unable to locate network plugin \(pluginInfo.plugin)" ) } try self.pluginLoader.deregisterWithLaunchd(plugin: networkPlugin, instanceId: configuration.id) } } extension NetworksService { private func removeServiceState(key: String) { self.serviceStates.removeValue(forKey: key) } private func setServiceState(key: String, value: NetworkServiceState) { self.serviceStates[key] = value } } ================================================ FILE: Sources/Services/ContainerAPIService/Server/Plugin/PluginsHarness.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerXPC import ContainerizationError import Foundation import Logging public struct PluginsHarness: Sendable { private let log: Logging.Logger private let service: PluginsService public init(service: PluginsService, log: Logging.Logger) { self.log = log self.service = service } @Sendable public func load(_ message: XPCMessage) async throws -> XPCMessage { let name = message.string(key: .pluginName) guard let name else { throw ContainerizationError(.invalidArgument, message: "no plugin name found") } try await service.load(name: name) let reply = message.reply() return reply } @Sendable public func get(_ message: XPCMessage) async throws -> XPCMessage { let name = message.string(key: .pluginName) guard let name else { throw ContainerizationError(.invalidArgument, message: "no plugin name found") } let plugin = try await service.get(name: name) let data = try JSONEncoder().encode(plugin) let reply = message.reply() reply.set(key: .plugin, value: data) return reply } @Sendable public func restart(_ message: XPCMessage) async throws -> XPCMessage { let name = message.string(key: .pluginName) guard let name else { throw ContainerizationError(.invalidArgument, message: "no plugin name found") } try await service.restart(name: name) let reply = message.reply() return reply } @Sendable public func unload(_ message: XPCMessage) async throws -> XPCMessage { let name = message.string(key: .pluginName) guard let name else { throw ContainerizationError(.invalidArgument, message: "no plugin name found") } try await service.unload(name: name) let reply = message.reply() return reply } @Sendable public func list(_ message: XPCMessage) async throws -> XPCMessage { let plugins = try await service.list() let data = try JSONEncoder().encode(plugins) let reply = message.reply() reply.set(key: .plugins, value: data) return reply } } ================================================ FILE: Sources/Services/ContainerAPIService/Server/Plugin/PluginsService.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerPlugin import Foundation import Logging public actor PluginsService { private let log: Logger private var loaded: [String: Plugin] private let pluginLoader: PluginLoader public init(pluginLoader: PluginLoader, log: Logger) { self.log = log self.loaded = [:] self.pluginLoader = pluginLoader } /// Load the specified plugins, or all plugins with services defined /// if none are explicitly specified. public func loadAll( _ plugins: [Plugin]? = nil, debug: Bool = false ) throws { let registerPlugins = plugins ?? pluginLoader.findPlugins() for plugin in registerPlugins { try pluginLoader.registerWithLaunchd(plugin: plugin, debug: debug) loaded[plugin.name] = plugin } } /// Stop the specified plugins, or all plugins with services defined /// if none are explicitly specified. public func stopAll(_ plugins: [Plugin]? = nil) throws { let deregisterPlugins = plugins ?? pluginLoader.findPlugins() for plugin in deregisterPlugins { try pluginLoader.deregisterWithLaunchd(plugin: plugin) self.loaded.removeValue(forKey: plugin.name) } } // MARK: XPC API surface. /// Load a single plugin, doing nothing if the plugin is already loaded. public func load(name: String, debug: Bool = false) throws { guard self.loaded[name] == nil else { return } guard let plugin = pluginLoader.findPlugin(name: name) else { throw Error.pluginNotFound(name) } try pluginLoader.registerWithLaunchd(plugin: plugin, debug: debug) self.loaded[plugin.name] = plugin } /// Get information for a loaded plugin. public func get(name: String) throws -> Plugin { guard let plugin = loaded[name] else { throw Error.pluginNotLoaded(name) } return plugin } /// Restart a loaded plugin. public func restart(name: String) throws { guard let plugin = self.loaded[name] else { throw Error.pluginNotLoaded(name) } try ServiceManager.kickstart(fullServiceLabel: plugin.getLaunchdLabel()) } /// Unload a loaded plugin. public func unload(name: String) throws { guard let plugin = self.loaded[name] else { throw Error.pluginNotLoaded(name) } try pluginLoader.deregisterWithLaunchd(plugin: plugin) self.loaded.removeValue(forKey: plugin.name) } /// List all loaded plugins. public func list() throws -> [Plugin] { self.loaded.map { $0.value } } public enum Error: Swift.Error, CustomStringConvertible { case pluginNotFound(String) case pluginNotLoaded(String) public var description: String { switch self { case .pluginNotFound(let name): return "plugin not found: \(name)" case .pluginNotLoaded(let name): return "plugin not loaded: \(name)" } } } } ================================================ FILE: Sources/Services/ContainerAPIService/Server/Volumes/VolumesHarness.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import ContainerXPC import ContainerizationError import Foundation import Logging public struct VolumesHarness: Sendable { let log: Logging.Logger let service: VolumesService public init(service: VolumesService, log: Logging.Logger) { self.log = log self.service = service } @Sendable public func list(_ message: XPCMessage) async throws -> XPCMessage { let volumes = try await service.list() let data = try JSONEncoder().encode(volumes) let reply = message.reply() reply.set(key: .volumes, value: data) return reply } @Sendable public func create(_ message: XPCMessage) async throws -> XPCMessage { guard let name = message.string(key: .volumeName) else { throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty") } let driver = message.string(key: .volumeDriver) ?? "local" let driverOpts: [String: String] if let driverOptsData = message.dataNoCopy(key: .volumeDriverOpts) { driverOpts = try JSONDecoder().decode([String: String].self, from: driverOptsData) } else { driverOpts = [:] } let labels: [String: String] if let labelsData = message.dataNoCopy(key: .volumeLabels) { labels = try JSONDecoder().decode([String: String].self, from: labelsData) } else { labels = [:] } let volume = try await service.create(name: name, driver: driver, driverOpts: driverOpts, labels: labels) let responseData = try JSONEncoder().encode(volume) let reply = message.reply() reply.set(key: .volume, value: responseData) return reply } @Sendable public func delete(_ message: XPCMessage) async throws -> XPCMessage { guard let name = message.string(key: .volumeName) else { throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty") } try await service.delete(name: name) return message.reply() } @Sendable public func inspect(_ message: XPCMessage) async throws -> XPCMessage { guard let name = message.string(key: .volumeName) else { throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty") } let volume = try await service.inspect(name) let data = try JSONEncoder().encode(volume) let reply = message.reply() reply.set(key: .volume, value: data) return reply } @Sendable public func diskUsage(_ message: XPCMessage) async throws -> XPCMessage { guard let name = message.string(key: .volumeName) else { throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty") } let size = try await service.volumeDiskUsage(name: name) let reply = message.reply() reply.set(key: .volumeSize, value: size) return reply } } ================================================ FILE: Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerPersistence import ContainerResource import Containerization import ContainerizationEXT4 import ContainerizationError import ContainerizationExtras import ContainerizationOS import Foundation import Logging import Synchronization import SystemPackage public actor VolumesService { private let resourceRoot: URL private let store: ContainerPersistence.FilesystemEntityStore private let log: Logger private let lock = AsyncLock() private let containersService: ContainersService // Storage constants private static let entityFile = "entity.json" private static let blockFile = "volume.img" public init(resourceRoot: URL, containersService: ContainersService, log: Logger) throws { try FileManager.default.createDirectory(at: resourceRoot, withIntermediateDirectories: true) self.resourceRoot = resourceRoot self.store = try FilesystemEntityStore(path: resourceRoot, type: "volumes", log: log) self.containersService = containersService self.log = log } public func create( name: String, driver: String = "local", driverOpts: [String: String] = [:], labels: [String: String] = [:] ) async throws -> Volume { log.debug( "VolumesService: enter", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) defer { log.debug( "VolumesService: exit", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) } return try await lock.withLock { _ in try await self._create(name: name, driver: driver, driverOpts: driverOpts, labels: labels) } } public func delete(name: String) async throws { log.debug( "VolumesService: enter", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) defer { log.debug( "VolumesService: exit", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) } try await lock.withLock { _ in try await self._delete(name: name) } } public func list() async throws -> [Volume] { log.debug( "VolumesService: enter", metadata: [ "func": "\(#function)" ] ) defer { log.debug( "VolumesService: exit", metadata: [ "func": "\(#function)" ] ) } return try await store.list() } public func inspect(_ name: String) async throws -> Volume { log.debug( "VolumesService: enter", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) defer { log.debug( "VolumesService: exit", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) } return try await lock.withLock { _ in try await self._inspect(name) } } /// Calculate disk usage for a single volume public func volumeDiskUsage(name: String) async throws -> UInt64 { log.debug( "VolumesService: enter", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) defer { log.debug( "VolumesService: exit", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) } let volumePath = self.volumePath(for: name) return self.calculateDirectorySize(at: volumePath) } /// Calculate disk usage for volumes /// - Returns: Tuple of (total count, active count, total size, reclaimable size) public func calculateDiskUsage() async throws -> (Int, Int, UInt64, UInt64) { log.debug( "VolumesService: enter", metadata: [ "func": "\(#function)" ] ) defer { log.debug( "VolumesService: exit", metadata: [ "func": "\(#function)" ] ) } return try await lock.withLock { _ in let allVolumes = try await self.store.list() // Atomically get active volumes with container list return try await self.containersService.withContainerList(logMetadata: ["acquirer": "\(#function)"]) { containers in var inUseSet = Set() // Find all mounted volumes for container in containers { for mount in container.configuration.mounts { if mount.isVolume, let volumeName = mount.volumeName { inUseSet.insert(volumeName) } } } var totalSize: UInt64 = 0 var reclaimableSize: UInt64 = 0 // Calculate sizes for volume in allVolumes { let volumePath = self.volumePath(for: volume.name) let volumeSize = self.calculateDirectorySize(at: volumePath) totalSize += volumeSize if !inUseSet.contains(volume.name) { reclaimableSize += volumeSize } } return (allVolumes.count, inUseSet.count, totalSize, reclaimableSize) } } } private nonisolated func calculateDirectorySize(at path: String) -> UInt64 { let url = URL(fileURLWithPath: path) let fileManager = FileManager.default guard let enumerator = fileManager.enumerator( at: url, includingPropertiesForKeys: [.totalFileAllocatedSizeKey], options: [.skipsHiddenFiles] ) else { return 0 } var totalSize: UInt64 = 0 for case let fileURL as URL in enumerator { guard let resourceValues = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey]), let fileSize = resourceValues.totalFileAllocatedSize else { continue } totalSize += UInt64(fileSize) } return totalSize } private func parseSize(_ sizeString: String) throws -> UInt64 { let measurement = try Measurement.parse(parsing: sizeString) let bytes = measurement.converted(to: .bytes).value // Validate minimum size let minSize: UInt64 = 1.mib() // 1mib minimum let sizeInBytes = UInt64(bytes) guard sizeInBytes >= minSize else { throw VolumeError.storageError("volume size too small: minimum 1MiB") } return sizeInBytes } private nonisolated func volumePath(for name: String) -> String { resourceRoot.appendingPathComponent(name).path } private nonisolated func entityPath(for name: String) -> String { "\(volumePath(for: name))/\(Self.entityFile)" } private nonisolated func blockPath(for name: String) -> String { "\(volumePath(for: name))/\(Self.blockFile)" } private func createVolumeDirectory(for name: String) throws { let volumePath = volumePath(for: name) let fm = FileManager.default try fm.createDirectory(atPath: volumePath, withIntermediateDirectories: true, attributes: nil) } private func createVolumeImage(for name: String, sizeInBytes: UInt64 = VolumeStorage.defaultVolumeSizeBytes) throws { let blockPath = blockPath(for: name) // Use the containerization library's EXT4 formatter let formatter = try EXT4.Formatter( FilePath(blockPath), blockSize: 4096, minDiskSize: sizeInBytes ) try formatter.close() } private nonisolated func removeVolumeDirectory(for name: String) throws { let volumePath = volumePath(for: name) let fm = FileManager.default if fm.fileExists(atPath: volumePath) { try fm.removeItem(atPath: volumePath) } } private func _create( name: String, driver: String, driverOpts: [String: String], labels: [String: String] ) async throws -> Volume { guard VolumeStorage.isValidVolumeName(name) else { throw VolumeError.invalidVolumeName("invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)") } // Check if volume already exists by trying to list and finding it let existingVolumes = try await store.list() if existingVolumes.contains(where: { $0.name == name }) { throw VolumeError.volumeAlreadyExists(name) } try createVolumeDirectory(for: name) // Parse size from driver options (default 512GB) let sizeInBytes: UInt64 if let sizeString = driverOpts["size"] { sizeInBytes = try parseSize(sizeString) } else { sizeInBytes = VolumeStorage.defaultVolumeSizeBytes } try createVolumeImage(for: name, sizeInBytes: sizeInBytes) let volume = Volume( name: name, driver: driver, format: "ext4", source: blockPath(for: name), labels: labels, options: driverOpts, sizeInBytes: sizeInBytes ) try await store.create(volume) log.info( "created volume", metadata: [ "name": "\(name)", "driver": "\(driver)", "isAnonymous": "\(volume.isAnonymous)", ]) return volume } private func _delete(name: String) async throws { guard VolumeStorage.isValidVolumeName(name) else { throw VolumeError.invalidVolumeName("invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)") } // Check if volume exists by trying to list and finding it let existingVolumes = try await store.list() guard existingVolumes.contains(where: { $0.name == name }) else { throw VolumeError.volumeNotFound(name) } // Check if volume is in use by any container atomically try await containersService.withContainerList(logMetadata: ["acquirer": "\(#function)", "name": "\(name)"]) { containers in for container in containers { for mount in container.configuration.mounts { if mount.isVolume && mount.volumeName == name { throw VolumeError.volumeInUse(name) } } } try await self.store.delete(name) try self.removeVolumeDirectory(for: name) } log.info("deleted volume", metadata: ["name": "\(name)"]) } private func _inspect(_ name: String) async throws -> Volume { guard VolumeStorage.isValidVolumeName(name) else { throw VolumeError.invalidVolumeName("invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)") } let volumes = try await store.list() guard let volume = volumes.first(where: { $0.name == name }) else { throw VolumeError.volumeNotFound(name) } return volume } } ================================================ FILE: Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// #if os(macOS) import Foundation import ContainerXPC /// Keys for XPC fields. public enum ImagesServiceXPCKeys: String { case fd /// FDs pointing to container logs key. case logs /// Path to a file on disk key. case filePath /// Images case imageReference case imageNewReference case imageDescription case imageDescriptions case filesystem case ociPlatform case insecureFlag case garbageCollect case maxConcurrentDownloads case forceLoad case rejectedMembers /// ContentStore case digest case digests case directory case contentPath case imageSize case ingestSessionId /// Disk Usage case activeImageReferences case totalCount case activeCount case reclaimableSize } extension XPCMessage { public func set(key: ImagesServiceXPCKeys, value: String) { self.set(key: key.rawValue, value: value) } public func set(key: ImagesServiceXPCKeys, value: UInt64) { self.set(key: key.rawValue, value: value) } public func set(key: ImagesServiceXPCKeys, value: Data) { self.set(key: key.rawValue, value: value) } public func set(key: ImagesServiceXPCKeys, value: Bool) { self.set(key: key.rawValue, value: value) } public func set(key: ImagesServiceXPCKeys, value: Int64) { self.set(key: key.rawValue, value: value) } public func string(key: ImagesServiceXPCKeys) -> String? { self.string(key: key.rawValue) } public func data(key: ImagesServiceXPCKeys) -> Data? { self.data(key: key.rawValue) } public func dataNoCopy(key: ImagesServiceXPCKeys) -> Data? { self.dataNoCopy(key: key.rawValue) } public func uint64(key: ImagesServiceXPCKeys) -> UInt64 { self.uint64(key: key.rawValue) } public func int64(key: ImagesServiceXPCKeys) -> Int64 { self.int64(key: key.rawValue) } public func bool(key: ImagesServiceXPCKeys) -> Bool { self.bool(key: key.rawValue) } } #endif ================================================ FILE: Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// #if os(macOS) import Foundation import ContainerXPC public enum ImagesServiceXPCRoute: String { case imageList case imagePull case imagePush case imageTag case imageBuild case imageDelete case imageSave case imageLoad case imageCleanupOrphanedBlobs case imageDiskUsage case contentGet case contentDelete case contentClean case contentIngestStart case contentIngestComplete case contentIngestCancel case imageUnpack case snapshotDelete case snapshotGet } extension XPCMessage { public init(route: ImagesServiceXPCRoute) { self.init(route: route.rawValue) } } #endif ================================================ FILE: Sources/Services/ContainerImagesService/Client/RemoteContentStoreClient.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// #if os(macOS) import Crypto import ContainerizationError import Foundation import ContainerizationOCI import ContainerXPC public struct RemoteContentStoreClient: ContentStore { private static let serviceIdentifier = "com.apple.container.core.container-core-images" private static let encoder = JSONEncoder() private static func newClient() -> XPCClient { XPCClient(service: serviceIdentifier) } public init() {} private func _get(digest: String) async throws -> URL? { let client = Self.newClient() let request = XPCMessage(route: .contentGet) request.set(key: .digest, value: digest) do { let response = try await client.send(request) guard let path = response.string(key: .contentPath) else { return nil } return URL(filePath: path) } catch let error as ContainerizationError { if error.code == .notFound { return nil } throw error } } public func get(digest: String) async throws -> Content? { guard let url = try await self._get(digest: digest) else { return nil } return try LocalContent(path: url) } public func get(digest: String) async throws -> T? { guard let content: Content = try await self.get(digest: digest) else { return nil } return try content.decode() } public func delete(keeping: [String]) async throws -> ([String], UInt64) { let client = Self.newClient() let request = XPCMessage(route: .contentClean) let d = try Self.encoder.encode(keeping) request.set(key: .digests, value: d) let response = try await client.send(request) guard let data = response.dataNoCopy(key: .digests) else { throw ContainerizationError.init(.internalError, message: "failed to delete digests") } let decoder = JSONDecoder() let deleted = try decoder.decode([String].self, from: data) let size = response.uint64(key: .imageSize) return (deleted, size) } @discardableResult public func delete(digests: [String]) async throws -> ([String], UInt64) { let client = Self.newClient() let request = XPCMessage(route: .contentDelete) let d = try Self.encoder.encode(digests) request.set(key: .digests, value: d) let response = try await client.send(request) guard let data = response.dataNoCopy(key: .digests) else { throw ContainerizationError.init(.internalError, message: "failed to delete digests") } let decoder = JSONDecoder() let deleted = try decoder.decode([String].self, from: data) let size = response.uint64(key: .imageSize) return (deleted, size) } @discardableResult public func ingest(_ body: @Sendable @escaping (URL) async throws -> Void) async throws -> [String] { let (id, tempPath) = try await self.newIngestSession() try await body(tempPath) return try await self.completeIngestSession(id) } public func newIngestSession() async throws -> (id: String, ingestDir: URL) { let client = Self.newClient() let request = XPCMessage(route: .contentIngestStart) let response = try await client.send(request) guard let id = response.string(key: .ingestSessionId) else { throw ContainerizationError.init(.internalError, message: "failed create new ingest session") } guard let dir = response.string(key: .directory) else { throw ContainerizationError.init(.internalError, message: "failed create new ingest session") } return (id, URL(filePath: dir)) } @discardableResult public func completeIngestSession(_ id: String) async throws -> [String] { let client = Self.newClient() let request = XPCMessage(route: .contentIngestComplete) request.set(key: .ingestSessionId, value: id) let response = try await client.send(request) guard let data = response.dataNoCopy(key: .digests) else { throw ContainerizationError.init(.internalError, message: "failed to delete digests") } let decoder = JSONDecoder() let ingested = try decoder.decode([String].self, from: data) return ingested } public func cancelIngestSession(_ id: String) async throws { let client = Self.newClient() let request = XPCMessage(route: .contentIngestCancel) request.set(key: .ingestSessionId, value: id) try await client.send(request) } } #endif ================================================ FILE: Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerImagesServiceClient import ContainerXPC import Containerization import ContainerizationError import Foundation import Logging public struct ContentServiceHarness: Sendable { private let log: Logging.Logger private let service: ContentStoreService public init(service: ContentStoreService, log: Logging.Logger) { self.log = log self.service = service } @Sendable public func get(_ message: XPCMessage) async throws -> XPCMessage { let d = message.string(key: .digest) guard let d else { throw ContainerizationError(.invalidArgument, message: "missing digest") } guard let path = try await service.get(digest: d) else { let err = ContainerizationError(.notFound, message: "digest \(d) not found") let reply = message.reply() reply.set(error: err) return reply } let reply = message.reply() reply.set(key: .contentPath, value: path.path(percentEncoded: false)) return reply } @Sendable public func delete(_ message: XPCMessage) async throws -> XPCMessage { let data = message.dataNoCopy(key: .digests) guard let data else { throw ContainerizationError(.invalidArgument, message: "missing digest") } let digests = try JSONDecoder().decode([String].self, from: data) let (deleted, size) = try await self.service.delete(digests: digests) let d = try JSONEncoder().encode(deleted) let reply = message.reply() reply.set(key: .digests, value: d) reply.set(key: .imageSize, value: size) return reply } @Sendable public func clean(_ message: XPCMessage) async throws -> XPCMessage { let data = message.dataNoCopy(key: .digests) guard let data else { throw ContainerizationError(.invalidArgument, message: "missing digest") } let digests = try JSONDecoder().decode([String].self, from: data) let (deleted, size) = try await self.service.delete(keeping: digests) let d = try JSONEncoder().encode(deleted) let reply = message.reply() reply.set(key: .digests, value: d) reply.set(key: .imageSize, value: size) return reply } @Sendable public func newIngestSession(_ message: XPCMessage) async throws -> XPCMessage { let session = try await self.service.newIngestSession() let id = session.id let dir = session.ingestDir let reply = message.reply() reply.set(key: .directory, value: dir.path(percentEncoded: false)) reply.set(key: .ingestSessionId, value: id) return reply } @Sendable public func cancelIngestSession(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .ingestSessionId) guard let id else { throw ContainerizationError(.invalidArgument, message: "missing ingest session id") } try await self.service.cancelIngestSession(id) let reply = message.reply() return reply } @Sendable public func completeIngestSession(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .ingestSessionId) guard let id else { throw ContainerizationError(.invalidArgument, message: "missing ingest session id") } let ingested = try await self.service.completeIngestSession(id) let d = try JSONEncoder().encode(ingested) let reply = message.reply() reply.set(key: .digests, value: d) return reply } } ================================================ FILE: Sources/Services/ContainerImagesService/Server/ContentStoreService.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerImagesServiceClient import Containerization import ContainerizationOCI import Foundation import Logging public actor ContentStoreService { private let log: Logger private let contentStore: LocalContentStore private let root: URL public init(root: URL, log: Logger) throws { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) self.root = root.appendingPathComponent("content") self.contentStore = try LocalContentStore(path: self.root) self.log = log } public func get(digest: String) async throws -> URL? { self.log.trace( "ContentStoreService: enter", metadata: [ "func": "\(#function)", "digest": "\(digest)", ] ) defer { self.log.trace( "ContentStoreService: exit", metadata: [ "func": "\(#function)", "digest": "\(digest)", ] ) } return try await self.contentStore.get(digest: digest)?.path } @discardableResult public func delete(digests: [String]) async throws -> ([String], UInt64) { self.log.trace( "ContentStoreService: enter", metadata: [ "func": "\(#function)", "digests": "\(digests)", ] ) defer { self.log.trace( "ContentStoreService: exit", metadata: [ "func": "\(#function)", "digests": "\(digests)", ] ) } return try await self.contentStore.delete(digests: digests) } @discardableResult public func delete(keeping: [String]) async throws -> ([String], UInt64) { self.log.debug( "ContentStoreService: enter", metadata: [ "func": "\(#function)", "keeping": "\(keeping)", ] ) defer { self.log.debug( "ContentStoreService: exit", metadata: [ "func": "\(#function)", "keeping": "\(keeping)", ] ) } return try await self.contentStore.delete(keeping: keeping) } public func newIngestSession() async throws -> (id: String, ingestDir: URL) { self.log.debug( "ContentStoreService: enter", metadata: [ "func": "\(#function)" ] ) defer { self.log.debug( "ContentStoreService: exit", metadata: [ "func": "\(#function)" ] ) } return try await self.contentStore.newIngestSession() } public func completeIngestSession(_ id: String) async throws -> [String] { self.log.debug( "ContentStoreService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) defer { self.log.debug( "ContentStoreService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) } return try await self.contentStore.completeIngestSession(id) } public func cancelIngestSession(_ id: String) async throws { self.log.debug( "ContentStoreService: enter", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) defer { self.log.debug( "ContentStoreService: exit", metadata: [ "func": "\(#function)", "id": "\(id)", ] ) } return try await self.contentStore.cancelIngestSession(id) } } ================================================ FILE: Sources/Services/ContainerImagesService/Server/ImagesService.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import ContainerImagesServiceClient import ContainerResource import Containerization import ContainerizationArchive import ContainerizationError import ContainerizationExtras import ContainerizationOCI import Foundation import Logging import TerminalProgress public actor ImagesService { private let log: Logger private let contentStore: ContentStore private let imageStore: ImageStore private let snapshotStore: SnapshotStore public init(contentStore: ContentStore, imageStore: ImageStore, snapshotStore: SnapshotStore, log: Logger) throws { self.contentStore = contentStore self.imageStore = imageStore self.snapshotStore = snapshotStore self.log = log } private func _list() async throws -> [Containerization.Image] { try await imageStore.list() } private func _get(_ reference: String) async throws -> Containerization.Image { try await imageStore.get(reference: reference) } private func _get(_ description: ImageDescription) async throws -> Containerization.Image { let exists = try await self._get(description.reference) guard exists.descriptor == description.descriptor else { throw ContainerizationError(.invalidState, message: "descriptor mismatch: expected \(description.descriptor), got \(exists.descriptor)") } return exists } public func list() async throws -> [ImageDescription] { self.log.debug( "ImagesService: enter", metadata: [ "func": "\(#function)" ] ) defer { self.log.debug( "ImagesService: exit", metadata: [ "func": "\(#function)" ] ) } return try await imageStore.list().map { $0.description.fromCZ } } public func pull(reference: String, platform: Platform?, insecure: Bool, progressUpdate: ProgressUpdateHandler?, maxConcurrentDownloads: Int = 3) async throws -> ImageDescription { self.log.debug( "ImagesService: enter", metadata: [ "func": "\(#function)", "ref": "\(reference)", "platform": "\(String(describing: platform))", "insecure": "\(insecure)", "maxConcurrentDownloads": "\(maxConcurrentDownloads)", ] ) defer { self.log.debug( "ImagesService: exit", metadata: [ "func": "\(#function)", "ref": "\(reference)", "platform": "\(String(describing: platform))", ] ) } let img = try await Self.withAuthentication(ref: reference) { auth in try await self.imageStore.pull( reference: reference, platform: platform, insecure: insecure, auth: auth, progress: ContainerizationProgressAdapter.handler(from: progressUpdate), maxConcurrentDownloads: maxConcurrentDownloads) } guard let img else { throw ContainerizationError(.internalError, message: "failed to pull image \(reference)") } return img.description.fromCZ } public func push(reference: String, platform: Platform?, insecure: Bool, progressUpdate: ProgressUpdateHandler?) async throws { self.log.debug( "ImagesService: enter", metadata: [ "func": "\(#function)", "ref": "\(reference)", "platform": "\(String(describing: platform))", "insecure": "\(insecure)", ] ) defer { self.log.debug( "ImagesService: exit", metadata: [ "func": "\(#function)", "ref": "\(reference)", "platform": "\(String(describing: platform))", ] ) } try await Self.withAuthentication(ref: reference) { auth in try await self.imageStore.push( reference: reference, platform: platform, insecure: insecure, auth: auth, progress: ContainerizationProgressAdapter.handler(from: progressUpdate)) } } public func tag(old: String, new: String) async throws -> ImageDescription { self.log.debug( "ImagesService: enter", metadata: [ "func": "\(#function)", "old": "\(old)", "new": "\(new)", ] ) defer { self.log.debug( "ImagesService: exit", metadata: [ "func": "\(#function)", "old": "\(old)", "new": "\(new)", ] ) } let img = try await self.imageStore.tag(existing: old, new: new) return img.description.fromCZ } public func delete(reference: String, garbageCollect: Bool) async throws { self.log.debug( "ImagesService: enter", metadata: [ "func": "\(#function)", "ref": "\(reference)", ] ) defer { self.log.debug( "ImagesService: exit", metadata: [ "func": "\(#function)", "ref": "\(reference)", ] ) } try await self.imageStore.delete(reference: reference, performCleanup: garbageCollect) } public func save(references: [String], out: URL, platform: Platform?) async throws { self.log.debug( "ImagesService: enter", metadata: [ "func": "\(#function)", "references": "\(references)", ] ) defer { self.log.debug( "ImagesService: exit", metadata: [ "func": "\(#function)", "references": "\(references)", ] ) } let tempDir = FileManager.default.uniqueTemporaryDirectory() defer { try? FileManager.default.removeItem(at: tempDir) } try await self.imageStore.save(references: references, out: tempDir, platform: platform) let writer = try ArchiveWriter(format: .pax, filter: .none, file: out) try writer.archiveDirectory(tempDir) try writer.finishEncoding() } public func load(from tarFile: URL, force: Bool) async throws -> ([ImageDescription], [String]) { let archivePathname = tarFile.absolutePath() self.log.debug( "ImagesService: enter", metadata: [ "func": "\(#function)", "archivePath": "\(archivePathname)", ] ) defer { self.log.debug( "ImagesService: exit", metadata: [ "func": "\(#function)", "archivePath": "\(archivePathname)", ] ) } let reader = try ArchiveReader(file: tarFile) let tempDir = FileManager.default.uniqueTemporaryDirectory() defer { try? FileManager.default.removeItem(at: tempDir) } let rejectedMembers = try reader.extractContents(to: tempDir) guard rejectedMembers.isEmpty || force else { throw ContainerizationError(.invalidArgument, message: "cannot load tar image with rejected paths: \(rejectedMembers)") } let loaded = try await self.imageStore.load(from: tempDir) var images: [ImageDescription] = [] for image in loaded { images.append(image.description.fromCZ) } return (images, rejectedMembers) } public func cleanUpOrphanedBlobs() async throws -> ([String], UInt64) { self.log.debug( "ImagesService: enter", metadata: [ "func": "\(#function)" ] ) defer { self.log.debug( "ImagesService: exit", metadata: [ "func": "\(#function)" ] ) } let images = try await self._list() let freedSnapshotBytes = try await self.snapshotStore.clean(keepingSnapshotsFor: images) let (deleted, freedContentBytes) = try await self.imageStore.cleanUpOrphanedBlobs() return (deleted, freedContentBytes + freedSnapshotBytes) } /// Calculate disk usage for images /// - Parameter activeReferences: Set of image references currently in use by containers /// - Returns: Tuple of (total count, active count, total size, reclaimable size) public func calculateDiskUsage(activeReferences: Set) async throws -> (Int, Int, UInt64, UInt64) { self.log.debug( "ImagesService: enter", metadata: [ "func": "\(#function)", "references": "\(activeReferences)", ] ) defer { self.log.debug( "ImagesService: exit", metadata: [ "func": "\(#function)", "references": "\(activeReferences)", ] ) } let images = try await self._list() var totalSize: UInt64 = 0 var reclaimableSize: UInt64 = 0 var activeCount = 0 for image in images { // Calculate size for all platform variants let imageSize = try await self.calculateImageSize(image) totalSize += imageSize // Check if image is referenced by any container let isActive = activeReferences.contains(image.reference) if isActive { activeCount += 1 } else { reclaimableSize += imageSize } } return (images.count, activeCount, totalSize, reclaimableSize) } /// Calculate total size for an image including all platform variants private func calculateImageSize(_ image: Containerization.Image) async throws -> UInt64 { var totalSize: UInt64 = 0 let index = try await image.index() for descriptor in index.manifests { // Skip attestation manifests if let refType = descriptor.annotations?["vnd.docker.reference.type"], refType == "attestation-manifest" { continue } guard descriptor.platform != nil else { continue } // Get snapshot size for this platform if let snapshotSize = try? await self.snapshotStore.getSnapshotSize(descriptor: descriptor) { totalSize += snapshotSize } } return totalSize } } // MARK: Image Snapshot Methods extension ImagesService { public func unpack(description: ImageDescription, platform: Platform?, progressUpdate: ProgressUpdateHandler?) async throws { self.log.debug( "ImagesService: enter", metadata: [ "func": "\(#function)", "description": "\(description)", "platform": "\(String(describing: platform))", ] ) defer { self.log.debug( "ImagesService: exit", metadata: [ "func": "\(#function)", "description": "\(description)", "platform": "\(String(describing: platform))", ] ) } let img = try await self._get(description) try await self.snapshotStore.unpack(image: img, platform: platform, progressUpdate: progressUpdate) } public func deleteImageSnapshot(description: ImageDescription, platform: Platform?) async throws { self.log.debug( "ImagesService: enter", metadata: [ "func": "\(#function)", "description": "\(description)", "platform": "\(String(describing: platform))", ] ) defer { self.log.debug( "ImagesService: exit", metadata: [ "func": "\(#function)", "description": "\(description)", "platform": "\(String(describing: platform))", ] ) } let img = try await self._get(description) try await self.snapshotStore.delete(for: img, platform: platform) } public func getImageSnapshot(description: ImageDescription, platform: Platform) async throws -> Filesystem { self.log.debug( "ImagesService: enter", metadata: [ "func": "\(#function)", "description": "\(description)", "platform": "\(String(describing: platform))", ] ) defer { self.log.debug( "ImagesService: exit", metadata: [ "func": "\(#function)", "description": "\(description)", "platform": "\(String(describing: platform))", ] ) } let img = try await self._get(description) return try await self.snapshotStore.get(for: img, platform: platform) } } // MARK: Static Methods extension ImagesService { private static func withAuthentication( ref: String, _ body: @Sendable @escaping (_ auth: Authentication?) async throws -> T? ) async throws -> T? { var authentication: Authentication? let ref = try Reference.parse(ref) guard let host = ref.resolvedDomain else { throw ContainerizationError(.invalidArgument, message: "no host specified in image reference: \(ref)") } authentication = Self.authenticationFromEnv(host: host) if let authentication { return try await body(authentication) } let keychain = KeychainHelper(securityDomain: Constants.keychainID) do { authentication = try keychain.lookup(hostname: host) } catch let err as KeychainHelper.Error { guard case .keyNotFound = err else { throw ContainerizationError(.internalError, message: "error querying keychain for \(host)", cause: err) } } do { return try await body(authentication) } catch let err as RegistryClient.Error { guard case .invalidStatus(_, let status, _) = err else { throw err } guard status == .unauthorized || status == .forbidden else { throw err } guard authentication != nil else { throw ContainerizationError(.internalError, message: "\(String(describing: err)), no credentials found for host \(host)") } throw err } } private static func authenticationFromEnv(host: String) -> Authentication? { let env = ProcessInfo.processInfo.environment guard env["CONTAINER_REGISTRY_HOST"] == host else { return nil } guard let user = env["CONTAINER_REGISTRY_USER"], let password = env["CONTAINER_REGISTRY_TOKEN"] else { return nil } return BasicAuthentication(username: user, password: password) } } extension ImageDescription { public var toCZ: Containerization.Image.Description { .init(reference: self.reference, descriptor: self.descriptor) } } extension Containerization.Image.Description { public var fromCZ: ImageDescription { .init( reference: self.reference, descriptor: self.descriptor ) } } ================================================ FILE: Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import ContainerImagesServiceClient import ContainerResource import ContainerXPC import Containerization import ContainerizationError import ContainerizationOCI import Foundation import Logging public struct ImagesServiceHarness: Sendable { let log: Logging.Logger let service: ImagesService public init(service: ImagesService, log: Logging.Logger) { self.log = log self.service = service } @Sendable public func pull(_ message: XPCMessage) async throws -> XPCMessage { let ref = message.string(key: .imageReference) guard let ref else { throw ContainerizationError( .invalidArgument, message: "missing image reference" ) } let platformData = message.dataNoCopy(key: .ociPlatform) var platform: Platform? = nil if let platformData { platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData) } let insecure = message.bool(key: .insecureFlag) let maxConcurrentDownloads = message.int64(key: .maxConcurrentDownloads) let progressUpdateService = ProgressUpdateService(message: message) let imageDescription = try await service.pull( reference: ref, platform: platform, insecure: insecure, progressUpdate: progressUpdateService?.handler, maxConcurrentDownloads: Int(maxConcurrentDownloads)) let imageData = try JSONEncoder().encode(imageDescription) let reply = message.reply() reply.set(key: .imageDescription, value: imageData) return reply } @Sendable public func push(_ message: XPCMessage) async throws -> XPCMessage { let ref = message.string(key: .imageReference) guard let ref else { throw ContainerizationError( .invalidArgument, message: "missing image reference" ) } let platformData = message.dataNoCopy(key: .ociPlatform) var platform: Platform? = nil if let platformData { platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData) } let insecure = message.bool(key: .insecureFlag) let progressUpdateService = ProgressUpdateService(message: message) try await service.push(reference: ref, platform: platform, insecure: insecure, progressUpdate: progressUpdateService?.handler) let reply = message.reply() return reply } @Sendable public func tag(_ message: XPCMessage) async throws -> XPCMessage { let old = message.string(key: .imageReference) guard let old else { throw ContainerizationError( .invalidArgument, message: "missing image reference" ) } let new = message.string(key: .imageNewReference) guard let new else { throw ContainerizationError( .invalidArgument, message: "missing new image reference" ) } let newDescription = try await service.tag(old: old, new: new) let descData = try JSONEncoder().encode(newDescription) let reply = message.reply() reply.set(key: .imageDescription, value: descData) return reply } @Sendable public func list(_ message: XPCMessage) async throws -> XPCMessage { let images = try await service.list() let imageData = try JSONEncoder().encode(images) let reply = message.reply() reply.set(key: .imageDescriptions, value: imageData) return reply } @Sendable public func delete(_ message: XPCMessage) async throws -> XPCMessage { let ref = message.string(key: .imageReference) guard let ref else { throw ContainerizationError( .invalidArgument, message: "missing image reference" ) } let garbageCollect = message.bool(key: .garbageCollect) try await self.service.delete(reference: ref, garbageCollect: garbageCollect) let reply = message.reply() return reply } @Sendable public func save(_ message: XPCMessage) async throws -> XPCMessage { let data = message.dataNoCopy(key: .imageDescriptions) guard let data else { throw ContainerizationError( .invalidArgument, message: "missing image description" ) } let imageDescriptions = try JSONDecoder().decode([ImageDescription].self, from: data) let references = imageDescriptions.map { $0.reference } let platformData = message.dataNoCopy(key: .ociPlatform) var platform: Platform? = nil if let platformData { platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData) } let out = message.string(key: .filePath) guard let out else { throw ContainerizationError( .invalidArgument, message: "missing output file path" ) } try await service.save(references: references, out: URL(filePath: out), platform: platform) let reply = message.reply() return reply } @Sendable public func load(_ message: XPCMessage) async throws -> XPCMessage { let input = message.string(key: .filePath) let force = message.bool(key: .forceLoad) guard let input else { throw ContainerizationError( .invalidArgument, message: "missing input file path" ) } let (images, rejectedMembers) = try await service.load( from: URL(filePath: input), force: force ) let reply = message.reply() let imagesData = try JSONEncoder().encode(images) reply.set(key: .imageDescriptions, value: imagesData) let rejectedData = try JSONEncoder().encode(rejectedMembers) reply.set(key: .rejectedMembers, value: rejectedData) return reply } @Sendable public func cleanUpOrphanedBlobs(_ message: XPCMessage) async throws -> XPCMessage { let (deleted, size) = try await service.cleanUpOrphanedBlobs() let reply = message.reply() let data = try JSONEncoder().encode(deleted) reply.set(key: .digests, value: data) reply.set(key: .imageSize, value: size) return reply } @Sendable public func calculateDiskUsage(_ message: XPCMessage) async throws -> XPCMessage { // Decode active image references from the message let activeRefsData = message.dataNoCopy(key: .activeImageReferences) let activeRefs: Set if let activeRefsData { activeRefs = try JSONDecoder().decode(Set.self, from: activeRefsData) } else { activeRefs = Set() } let (total, active, size, reclaimable) = try await service.calculateDiskUsage(activeReferences: activeRefs) let reply = message.reply() reply.set(key: .totalCount, value: Int64(total)) reply.set(key: .activeCount, value: Int64(active)) reply.set(key: .imageSize, value: size) reply.set(key: .reclaimableSize, value: reclaimable) return reply } } // MARK: Image Snapshot Methods extension ImagesServiceHarness { @Sendable public func unpack(_ message: XPCMessage) async throws -> XPCMessage { let descriptionData = message.dataNoCopy(key: .imageDescription) guard let descriptionData else { throw ContainerizationError( .invalidArgument, message: "missing Image description" ) } let description = try JSONDecoder().decode(ImageDescription.self, from: descriptionData) var platform: Platform? if let platformData = message.dataNoCopy(key: .ociPlatform) { platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData) } let progressUpdateService = ProgressUpdateService(message: message) try await self.service.unpack(description: description, platform: platform, progressUpdate: progressUpdateService?.handler) let reply = message.reply() return reply } @Sendable public func deleteSnapshot(_ message: XPCMessage) async throws -> XPCMessage { let descriptionData = message.dataNoCopy(key: .imageDescription) guard let descriptionData else { throw ContainerizationError( .invalidArgument, message: "missing image description" ) } let description = try JSONDecoder().decode(ImageDescription.self, from: descriptionData) let platformData = message.dataNoCopy(key: .ociPlatform) var platform: Platform? if let platformData { platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData) } try await self.service.deleteImageSnapshot(description: description, platform: platform) let reply = message.reply() return reply } @Sendable public func getSnapshot(_ message: XPCMessage) async throws -> XPCMessage { let descriptionData = message.dataNoCopy(key: .imageDescription) guard let descriptionData else { throw ContainerizationError( .invalidArgument, message: "missing image description" ) } let description = try JSONDecoder().decode(ImageDescription.self, from: descriptionData) let platformData = message.dataNoCopy(key: .ociPlatform) guard let platformData else { throw ContainerizationError( .invalidArgument, message: "missing OCI platform" ) } let platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData) let fs = try await self.service.getImageSnapshot(description: description, platform: platform) let fsData = try JSONEncoder().encode(fs) let reply = message.reply() reply.set(key: .filesystem, value: fsData) return reply } } ================================================ FILE: Sources/Services/ContainerImagesService/Server/SnapshotStore.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import ContainerPersistence import ContainerResource import Containerization import ContainerizationError import ContainerizationExtras import ContainerizationOCI import ContainerizationOS import Foundation import Logging import TerminalProgress public actor SnapshotStore { private static let snapshotFileName = "snapshot" private static let snapshotInfoFileName = "snapshot-info" private static let ingestDirName = "ingest" /// Return the Unpacker to use for a given image. /// If the given platform for the image cannot be unpacked return `nil`. public typealias UnpackStrategy = @Sendable (Containerization.Image, Platform) async throws -> Unpacker? public static let defaultUnpackStrategy: UnpackStrategy = { image, platform in guard platform.os == "linux" else { return nil } var minBlockSize = 512.gib() if image.reference == DefaultsStore.get(key: .defaultInitImage) { minBlockSize = 512.mib() } return EXT4Unpacker(blockSizeInBytes: minBlockSize) } let path: URL let fm = FileManager.default let ingestDir: URL let unpackStrategy: UnpackStrategy let log: Logger? public init(path: URL, unpackStrategy: @escaping UnpackStrategy, log: Logger?) throws { let root = path.appendingPathComponent("snapshots") self.path = root self.ingestDir = self.path.appendingPathComponent(Self.ingestDirName) self.unpackStrategy = unpackStrategy self.log = log try self.fm.createDirectory(at: root, withIntermediateDirectories: true) try self.fm.createDirectory(at: self.ingestDir, withIntermediateDirectories: true) } public func unpack(image: Containerization.Image, platform: Platform? = nil, progressUpdate: ProgressUpdateHandler?) async throws { var toUnpack: [Descriptor] = [] if let platform { let desc = try await image.descriptor(for: platform) toUnpack = [desc] } else { toUnpack = try await image.unpackableDescriptors() } let taskManager = ProgressTaskCoordinator() var taskUpdateProgress: ProgressUpdateHandler? for desc in toUnpack { try Task.checkCancellation() let snapshotDir = self.snapshotDir(desc) guard !self.fm.fileExists(atPath: snapshotDir.absolutePath()) else { // We have already unpacked this image + platform. Skip continue } guard let platform = desc.platform else { throw ContainerizationError(.internalError, message: "missing platform for descriptor \(desc.digest)") } guard let unpacker = try await self.unpackStrategy(image, platform) else { self.log?.warning("no unpacker configured, skipping unpack for \(image.reference) for platform \(platform.description)") continue } let currentSubTask = await taskManager.startTask() if let progressUpdate { let _taskUpdateProgress = ProgressTaskCoordinator.handler(for: currentSubTask, from: progressUpdate) await _taskUpdateProgress([ .setSubDescription("for platform \(platform.description)") ]) taskUpdateProgress = _taskUpdateProgress } let tempDir = try self.tempUnpackDir() let tempSnapshotPath = tempDir.appendingPathComponent(Self.snapshotFileName, isDirectory: false) let infoPath = tempDir.appendingPathComponent(Self.snapshotInfoFileName, isDirectory: false) do { let progress = ContainerizationProgressAdapter.handler(from: taskUpdateProgress) let mount = try await unpacker.unpack(image, for: platform, at: tempSnapshotPath, progress: progress) let fs = Filesystem.block( format: mount.type, source: self.snapshotPath(desc).absolutePath(), destination: mount.destination, options: mount.options ) let snapshotInfo = try JSONEncoder().encode(fs) self.fm.createFile(atPath: infoPath.absolutePath(), contents: snapshotInfo) } catch { try? self.fm.removeItem(at: tempDir) throw error } do { try fm.moveItem(at: tempDir, to: snapshotDir) } catch let err as NSError { guard err.code == NSFileWriteFileExistsError else { throw err } try? self.fm.removeItem(at: tempDir) } } await taskManager.finish() } public func delete(for image: Containerization.Image, platform: Platform? = nil) async throws { var toDelete: [Descriptor] = [] if let platform { let desc = try await image.descriptor(for: platform) toDelete.append(desc) } else { toDelete = try await image.unpackableDescriptors() } for desc in toDelete { let p = self.snapshotDir(desc) guard self.fm.fileExists(atPath: p.absolutePath()) else { continue } try self.fm.removeItem(at: p) } } public func get(for image: Containerization.Image, platform: Platform) async throws -> Filesystem { let desc = try await image.descriptor(for: platform) let infoPath = snapshotInfoPath(desc) let fsPath = snapshotPath(desc) guard self.fm.fileExists(atPath: infoPath.absolutePath()), self.fm.fileExists(atPath: fsPath.absolutePath()) else { throw ContainerizationError(.notFound, message: "image snapshot for \(image.reference) with platform \(platform.description)") } let decoder = JSONDecoder() let data = try Data(contentsOf: infoPath) let fs = try decoder.decode(Filesystem.self, from: data) return fs } public func clean(keepingSnapshotsFor images: [Containerization.Image] = []) async throws -> UInt64 { var toKeep: [String] = [Self.ingestDirName] for image in images { for manifest in try await image.index().manifests { guard let platform = manifest.platform else { continue } let desc = try await image.descriptor(for: platform) toKeep.append(desc.digest.trimmingDigestPrefix) } } let all = try self.fm.contentsOfDirectory(at: self.path, includingPropertiesForKeys: [.totalFileAllocatedSizeKey]).map { $0.lastPathComponent } let delete = Set(all).subtracting(Set(toKeep)) var deletedBytes: UInt64 = 0 for dir in delete { let unpackedPath = self.path.appending(path: dir, directoryHint: .isDirectory) guard self.fm.fileExists(atPath: unpackedPath.absolutePath()) else { continue } deletedBytes += (try? self.fm.directorySize(dir: unpackedPath)) ?? 0 try self.fm.removeItem(at: unpackedPath) } return deletedBytes } private func snapshotDir(_ desc: Descriptor) -> URL { let p = self.path.appendingPathComponent(desc.digest.trimmingDigestPrefix, isDirectory: true) return p } private func snapshotPath(_ desc: Descriptor) -> URL { let p = self.snapshotDir(desc) .appendingPathComponent(Self.snapshotFileName, isDirectory: false) return p } private func snapshotInfoPath(_ desc: Descriptor) -> URL { let p = self.snapshotDir(desc) .appendingPathComponent(Self.snapshotInfoFileName, isDirectory: false) return p } private func tempUnpackDir() throws -> URL { let uniqueDirectoryURL = ingestDir.appendingPathComponent(UUID().uuidString, isDirectory: true) try self.fm.createDirectory(at: uniqueDirectoryURL, withIntermediateDirectories: true, attributes: nil) return uniqueDirectoryURL } /// Get the disk size for a specific snapshot descriptor public func getSnapshotSize(descriptor: Descriptor) throws -> UInt64 { let snapshotPath = self.snapshotDir(descriptor) guard self.fm.fileExists(atPath: snapshotPath.path) else { return 0 } return try self.fm.directorySize(dir: snapshotPath) } } extension FileManager { fileprivate func directorySize(dir: URL) throws -> UInt64 { var size: UInt64 = 0 let resourceKeys: [URLResourceKey] = [.totalFileAllocatedSizeKey] guard let enumerator = self.enumerator( at: dir, includingPropertiesForKeys: resourceKeys, options: [.skipsHiddenFiles] ) else { return 0 } for case let fileURL as URL in enumerator { if let resourceValues = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey]), let fileSize = resourceValues.totalFileAllocatedSize { size += UInt64(fileSize) } } return size } } extension Containerization.Image { fileprivate func unpackableDescriptors() async throws -> [Descriptor] { let index = try await self.index() return index.manifests.filter { desc in guard desc.platform != nil else { return false } if let referenceType = desc.annotations?["vnd.docker.reference.type"], referenceType == "attestation-manifest" { return false } return true } } } ================================================ FILE: Sources/Services/ContainerNetworkService/Client/NetworkClient.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import ContainerXPC import ContainerizationError import ContainerizationExtras import Foundation /// A client for interacting with a single network. public struct NetworkClient: Sendable { static let label = "com.apple.container.network" public static func machServiceLabel(id: String, plugin: String) -> String { "\(Self.label).\(plugin).\(id)" } private var machServiceLabel: String { Self.machServiceLabel(id: id, plugin: plugin) } let id: String let plugin: String /// Create a client for a network. public init(id: String, plugin: String) { self.id = id self.plugin = plugin } } // Runtime Methods extension NetworkClient { public func state() async throws -> NetworkState { let request = XPCMessage(route: NetworkRoutes.state.rawValue) let client = createClient() let response = try await client.send(request) let state = try response.state() return state } public func allocate( hostname: String, macAddress: MACAddress? = nil ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { let request = XPCMessage(route: NetworkRoutes.allocate.rawValue) request.set(key: NetworkKeys.hostname.rawValue, value: hostname) if let macAddress = macAddress { request.set(key: NetworkKeys.macAddress.rawValue, value: macAddress.description) } let client = createClient() let response = try await client.send(request) let attachment = try response.attachment() let additionalData = response.additionalData() return (attachment, additionalData) } public func deallocate(hostname: String) async throws { let request = XPCMessage(route: NetworkRoutes.deallocate.rawValue) request.set(key: NetworkKeys.hostname.rawValue, value: hostname) let client = createClient() try await client.send(request) } public func lookup(hostname: String) async throws -> Attachment? { let request = XPCMessage(route: NetworkRoutes.lookup.rawValue) request.set(key: NetworkKeys.hostname.rawValue, value: hostname) let client = createClient() let response = try await client.send(request) return try response.dataNoCopy(key: NetworkKeys.attachment.rawValue).map { try JSONDecoder().decode(Attachment.self, from: $0) } } public func disableAllocator() async throws -> Bool { let request = XPCMessage(route: NetworkRoutes.disableAllocator.rawValue) let client = createClient() let response = try await client.send(request) return try response.allocatorDisabled() } private func createClient() -> XPCClient { XPCClient(service: machServiceLabel) } } extension XPCMessage { public func additionalData() -> XPCMessage? { guard let additionalData = xpc_dictionary_get_dictionary(self.underlying, NetworkKeys.additionalData.rawValue) else { return nil } return XPCMessage(object: additionalData) } public func allocatorDisabled() throws -> Bool { self.bool(key: NetworkKeys.allocatorDisabled.rawValue) } public func attachment() throws -> Attachment { let data = self.dataNoCopy(key: NetworkKeys.attachment.rawValue) guard let data else { throw ContainerizationError(.invalidArgument, message: "no network attachment snapshot data in message") } return try JSONDecoder().decode(Attachment.self, from: data) } public func hostname() throws -> String { let hostname = self.string(key: NetworkKeys.hostname.rawValue) guard let hostname else { throw ContainerizationError(.invalidArgument, message: "no hostname data in message") } return hostname } public func state() throws -> NetworkState { let data = self.dataNoCopy(key: NetworkKeys.state.rawValue) guard let data else { throw ContainerizationError(.invalidArgument, message: "no network snapshot data in message") } return try JSONDecoder().decode(NetworkState.self, from: data) } } ================================================ FILE: Sources/Services/ContainerNetworkService/Client/NetworkKeys.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// public enum NetworkKeys: String { case additionalData case allocatorDisabled case attachment case hostname case macAddress case network case state } ================================================ FILE: Sources/Services/ContainerNetworkService/Client/NetworkRoutes.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// public enum NetworkRoutes: String { /// Return the current state of the network. case state = "com.apple.container.network/state" /// Allocates parameters for attaching a sandbox to the network. case allocate = "com.apple.container.network/allocate" /// Deallocates parameters for attaching a sandbox to the network. case deallocate = "com.apple.container.network/deallocate" /// Disables the allocator if no sandboxes are attached. case disableAllocator = "com.apple.container.network/disableAllocator" /// Retrieves the allocation for a hostname. case lookup = "com.apple.container.network/lookup" } ================================================ FILE: Sources/Services/ContainerNetworkService/Server/AllocationOnlyVmnetNetwork.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerPersistence import ContainerResource import ContainerXPC import ContainerizationError import ContainerizationExtras import Foundation import Logging public actor AllocationOnlyVmnetNetwork: Network { private let log: Logger private var _state: NetworkState /// Configure a bridge network that allows external system access using /// network address translation. public init( configuration: NetworkConfiguration, log: Logger ) throws { guard configuration.mode == .nat else { throw ContainerizationError(.unsupported, message: "invalid network mode \(configuration.mode)") } guard configuration.ipv4Subnet == nil else { throw ContainerizationError(.unsupported, message: "IPv4 subnet assignment is not yet implemented") } self.log = log self._state = .created(configuration) } public var state: NetworkState { self._state } public nonisolated func withAdditionalData(_ handler: (XPCMessage?) throws -> Void) throws { try handler(nil) } public func start() async throws { guard case .created(let configuration) = _state else { throw ContainerizationError(.invalidState, message: "cannot start network \(_state.id) in \(_state.state) state") } log.info( "starting allocation-only network", metadata: [ "id": "\(configuration.id)", "mode": "\(NetworkMode.nat.rawValue)", ] ) let defaultIPv4Subnet = try CIDRv4(DefaultsStore.get(key: .defaultSubnet)) let ipv4Subnet = configuration.ipv4Subnet ?? defaultIPv4Subnet let gateway = IPv4Address(ipv4Subnet.lower.value + 1) let status = NetworkStatus( ipv4Subnet: ipv4Subnet, ipv4Gateway: gateway, ipv6Subnet: nil, ) self._state = .running(configuration, status) log.info( "started allocation-only network", metadata: [ "id": "\(configuration.id)", "mode": "\(configuration.mode)", "cidr": "\(ipv4Subnet)", ] ) } } ================================================ FILE: Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import ContainerizationExtras actor AttachmentAllocator { private let allocator: any AddressAllocator private var hostnames: [String: UInt32] = [:] init(lower: UInt32, size: Int) throws { allocator = try UInt32.rotatingAllocator( lower: lower, size: UInt32(size) ) } /// Allocate a network address for a host. func allocate(hostname: String) async throws -> UInt32 { // Client is responsible for ensuring two containers don't use same hostname, so provide existing IP if hostname exists if let index = hostnames[hostname] { return index } let index = try allocator.allocate() hostnames[hostname] = index return index } /// Free an allocated network address by hostname. @discardableResult func deallocate(hostname: String) async throws -> UInt32? { guard let index = hostnames.removeValue(forKey: hostname) else { return nil } try allocator.release(index) return index } /// If no addresses are allocated, prevent future allocations and return true. func disableAllocator() async -> Bool { allocator.disableAllocator() } /// Retrieve the allocator index for a hostname. func lookup(hostname: String) async throws -> UInt32? { hostnames[hostname] } } ================================================ FILE: Sources/Services/ContainerNetworkService/Server/Network.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import ContainerXPC /// Defines common characteristics and operations for a network. public protocol Network: Sendable { // Contains network attributes while the network is running var state: NetworkState { get async } // Use implementation-dependent network attributes nonisolated func withAdditionalData(_ handler: (XPCMessage?) throws -> Void) throws // Start the network func start() async throws } ================================================ FILE: Sources/Services/ContainerNetworkService/Server/NetworkService.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerNetworkServiceClient import ContainerResource import ContainerXPC import ContainerizationError import ContainerizationExtras import Foundation import Logging public actor NetworkService: Sendable { private let network: any Network private let log: Logger private var allocator: AttachmentAllocator private var macAddresses: [UInt32: MACAddress] /// Set up a network service for the specified network. public init( network: any Network, log: Logger ) async throws { let state = await network.state guard case .running(_, let status) = state else { throw ContainerizationError(.invalidState, message: "invalid network state - network \(state.id) must be running") } let subnet = status.ipv4Subnet let size = Int(subnet.upper.value - subnet.lower.value - 3) self.allocator = try AttachmentAllocator(lower: subnet.lower.value + 2, size: size) self.macAddresses = [:] self.network = network self.log = log } @Sendable public func state(_ message: XPCMessage) async throws -> XPCMessage { let reply = message.reply() let state = await network.state try reply.setState(state) return reply } @Sendable public func allocate(_ message: XPCMessage) async throws -> XPCMessage { log.debug("enter", metadata: ["func": "\(#function)"]) defer { log.debug("exit", metadata: ["func": "\(#function)"]) } let state = await network.state guard case .running(_, let status) = state else { throw ContainerizationError(.invalidState, message: "invalid network state - network \(state.id) must be running") } let hostname = try message.hostname() let macAddress = try message.string(key: NetworkKeys.macAddress.rawValue) .map { try MACAddress($0) } ?? MACAddress((UInt64.random(in: 0...UInt64.max) & 0x0cff_ffff_ffff) | 0xf200_0000_0000) let index = try await allocator.allocate(hostname: hostname) let ipv6Address = try status.ipv6Subnet .map { try CIDRv6(macAddress.ipv6Address(network: $0.lower), prefix: $0.prefix) } let ip = IPv4Address(index) let attachment = Attachment( network: state.id, hostname: hostname, ipv4Address: try CIDRv4(ip, prefix: status.ipv4Subnet.prefix), ipv4Gateway: status.ipv4Gateway, ipv6Address: ipv6Address, macAddress: macAddress ) log.info( "allocated attachment", metadata: [ "hostname": "\(hostname)", "ipv4Address": "\(attachment.ipv4Address)", "ipv4Gateway": "\(attachment.ipv4Gateway)", "ipv6Address": "\(attachment.ipv6Address?.description ?? "unavailable")", "macAddress": "\(attachment.macAddress?.description ?? "unspecified")", ]) let reply = message.reply() try reply.setAttachment(attachment) try network.withAdditionalData { if let additionalData = $0 { try reply.setAdditionalData(additionalData.underlying) } } macAddresses[index] = macAddress return reply } @Sendable public func deallocate(_ message: XPCMessage) async throws -> XPCMessage { log.debug("enter", metadata: ["func": "\(#function)"]) defer { log.debug("exit", metadata: ["func": "\(#function)"]) } let hostname = try message.hostname() if let index = try await allocator.deallocate(hostname: hostname) { macAddresses.removeValue(forKey: index) } log.info("released attachments", metadata: ["hostname": "\(hostname)"]) return message.reply() } @Sendable public func lookup(_ message: XPCMessage) async throws -> XPCMessage { log.debug("enter", metadata: ["func": "\(#function)"]) defer { log.debug("exit", metadata: ["func": "\(#function)"]) } let state = await network.state guard case .running(_, let status) = state else { throw ContainerizationError(.invalidState, message: "invalid network state - network \(state.id) must be running") } let hostname = try message.hostname() let index = try await allocator.lookup(hostname: hostname) let reply = message.reply() guard let index else { return reply } guard let macAddress = macAddresses[index] else { return reply } let address = IPv4Address(index) let subnet = status.ipv4Subnet let ipv4Address = try CIDRv4(address, prefix: subnet.prefix) let ipv6Address = try status.ipv6Subnet .map { try CIDRv6(macAddress.ipv6Address(network: $0.lower), prefix: $0.prefix) } let attachment = Attachment( network: state.id, hostname: hostname, ipv4Address: ipv4Address, ipv4Gateway: status.ipv4Gateway, ipv6Address: ipv6Address, macAddress: macAddress ) log.debug( "lookup attachment", metadata: [ "hostname": "\(hostname)", "address": "\(address)", ]) try reply.setAttachment(attachment) return reply } @Sendable public func disableAllocator(_ message: XPCMessage) async throws -> XPCMessage { log.debug("enter", metadata: ["func": "\(#function)"]) defer { log.debug("exit", metadata: ["func": "\(#function)"]) } let success = await allocator.disableAllocator() log.info("attempted allocator disable", metadata: ["success": "\(success)"]) let reply = message.reply() reply.setAllocatorDisabled(success) return reply } } extension XPCMessage { fileprivate func setAdditionalData(_ additionalData: xpc_object_t) throws { xpc_dictionary_set_value(self.underlying, NetworkKeys.additionalData.rawValue, additionalData) } fileprivate func setAllocatorDisabled(_ allocatorDisabled: Bool) { self.set(key: NetworkKeys.allocatorDisabled.rawValue, value: allocatorDisabled) } fileprivate func setAttachment(_ attachment: Attachment) throws { let data = try JSONEncoder().encode(attachment) self.set(key: NetworkKeys.attachment.rawValue, value: data) } fileprivate func setState(_ state: NetworkState) throws { let data = try JSONEncoder().encode(state) self.set(key: NetworkKeys.state.rawValue, value: data) } } ================================================ FILE: Sources/Services/ContainerNetworkService/Server/ReservedVmnetNetwork.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerPersistence import ContainerResource import ContainerXPC import Containerization import ContainerizationError import ContainerizationExtras import Dispatch import Foundation import Logging import Synchronization import SystemConfiguration import XPC import vmnet /// Creates a vmnet network with reservation APIs. @available(macOS 26, *) public final class ReservedVmnetNetwork: Network { private struct State { var networkState: NetworkState var network: vmnet_network_ref? } private struct NetworkInfo { let network: vmnet_network_ref let ipv4Subnet: CIDRv4 let ipv4Gateway: IPv4Address let ipv6Subnet: CIDRv6 } private let stateMutex: Mutex private let log: Logger /// Configure a bridge network that allows external system access using /// network address translation. public init( configuration: NetworkConfiguration, log: Logger ) throws { guard configuration.mode == .nat || configuration.mode == .hostOnly else { throw ContainerizationError(.unsupported, message: "invalid network mode \(configuration.mode)") } log.info("creating vmnet network") self.log = log let initialState = State(networkState: .created(configuration)) stateMutex = Mutex(initialState) log.info("created vmnet network") } public var state: NetworkState { stateMutex.withLock { $0.networkState } } public nonisolated func withAdditionalData(_ handler: (XPCMessage?) throws -> Void) throws { try stateMutex.withLock { state in try handler(state.network.map { try Self.serialize_network_ref(ref: $0) }) } } public func start() async throws { try stateMutex.withLock { state in guard case .created(let configuration) = state.networkState else { throw ContainerizationError(.invalidArgument, message: "cannot start network that is in \(state.networkState.state) state") } let networkInfo = try startNetwork(configuration: configuration, log: log) let networkStatus = NetworkStatus( ipv4Subnet: networkInfo.ipv4Subnet, ipv4Gateway: networkInfo.ipv4Gateway, ipv6Subnet: networkInfo.ipv6Subnet, ) state.networkState = NetworkState.running(configuration, networkStatus) state.network = networkInfo.network } } private static func serialize_network_ref(ref: vmnet_network_ref) throws -> XPCMessage { var status: vmnet_return_t = .VMNET_SUCCESS guard let refObject = vmnet_network_copy_serialization(ref, &status) else { throw ContainerizationError(.invalidArgument, message: "cannot serialize vmnet_network_ref to XPC object, status \(status)") } return XPCMessage(object: refObject) } private func startNetwork(configuration: NetworkConfiguration, log: Logger) throws -> NetworkInfo { log.info( "starting vmnet network", metadata: [ "id": "\(configuration.id)", "mode": "\(configuration.mode)", ] ) // set up the vmnet configuration var status: vmnet_return_t = .VMNET_SUCCESS let mode: vmnet.operating_modes_t = configuration.mode == .hostOnly ? .VMNET_HOST_MODE : .VMNET_SHARED_MODE guard let vmnetConfiguration = vmnet_network_configuration_create(mode, &status), status == .VMNET_SUCCESS else { throw ContainerizationError(.unsupported, message: "failed to create vmnet config with status \(status)") } vmnet_network_configuration_disable_dhcp(vmnetConfiguration) // subnet priority is CLI argument, UserDefault, auto let defaultIpv4Subnet = try DefaultsStore.getOptional(key: .defaultSubnet).map { try CIDRv4($0) } let ipv4Subnet = configuration.ipv4Subnet ?? defaultIpv4Subnet let defaultIpv6Subnet = try DefaultsStore.getOptional(key: .defaultIPv6Subnet).map { try CIDRv6($0) } let ipv6Subnet = configuration.ipv6Subnet ?? defaultIpv6Subnet // set the IPv4 subnet if the caller provided one if let ipv4Subnet { let gateway = IPv4Address(ipv4Subnet.lower.value + 1) var gatewayAddr = in_addr() inet_pton(AF_INET, gateway.description, &gatewayAddr) let mask = IPv4Address(ipv4Subnet.prefix.prefixMask32) var maskAddr = in_addr() inet_pton(AF_INET, mask.description, &maskAddr) log.info( "configuring vmnet IPv4 subnet", metadata: ["cidr": "\(ipv4Subnet)"] ) let status = vmnet_network_configuration_set_ipv4_subnet(vmnetConfiguration, &gatewayAddr, &maskAddr) guard status == .VMNET_SUCCESS else { throw ContainerizationError(.internalError, message: "failed to set subnet \(ipv4Subnet) for IPv4 network \(configuration.id)") } } // set the IPv6 network prefix if the caller provided one if let ipv6Subnet { let gateway = IPv6Address(ipv6Subnet.lower.value + 1) var gatewayAddr = in6_addr() inet_pton(AF_INET6, gateway.description, &gatewayAddr) log.info( "configuring vmnet IPv6 prefix", metadata: ["cidr": "\(ipv6Subnet)"] ) let status = vmnet_network_configuration_set_ipv6_prefix(vmnetConfiguration, &gatewayAddr, ipv6Subnet.prefix.length) guard status == .VMNET_SUCCESS else { throw ContainerizationError(.internalError, message: "failed to set prefix \(ipv6Subnet) for IPv6 network \(configuration.id)") } } // reserve the network guard let network = vmnet_network_create(vmnetConfiguration, &status), status == .VMNET_SUCCESS else { throw ContainerizationError(.unsupported, message: "failed to create vmnet network with status \(status)") } // retrieve the subnet since the caller may not have provided one var subnetAddr = in_addr() var maskAddr = in_addr() vmnet_network_get_ipv4_subnet(network, &subnetAddr, &maskAddr) let subnetValue = UInt32(bigEndian: subnetAddr.s_addr) let maskValue = UInt32(bigEndian: maskAddr.s_addr) let lower = IPv4Address(subnetValue & maskValue) let upper = IPv4Address(lower.value + ~maskValue) let runningSubnet = try CIDRv4(lower: lower, upper: upper) let runningGateway = IPv4Address(runningSubnet.lower.value + 1) var prefixAddr = in6_addr() var prefixLength = UInt8(0) vmnet_network_get_ipv6_prefix(network, &prefixAddr, &prefixLength) guard let prefix = Prefix(length: prefixLength) else { throw ContainerizationError(.internalError, message: "invalid IPv6 prefix length \(prefixLength) for network \(configuration.id)") } let prefixIpv6Bytes = withUnsafeBytes(of: prefixAddr.__u6_addr.__u6_addr8) { Array($0) } let prefixIpv6Addr = try IPv6Address(prefixIpv6Bytes) let runningV6Subnet = try CIDRv6(prefixIpv6Addr, prefix: prefix) log.info( "started vmnet network", metadata: [ "id": "\(configuration.id)", "mode": "\(configuration.mode)", "cidr": "\(runningSubnet)", "cidrv6": "\(runningV6Subnet)", ] ) return NetworkInfo( network: network, ipv4Subnet: runningSubnet, ipv4Gateway: runningGateway, ipv6Subnet: runningV6Subnet, ) } } ================================================ FILE: Sources/Services/ContainerSandboxService/Client/Bundle+Log.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import Foundation extension ContainerResource.Bundle { /// The pathname for the workload log file. public var containerLog: URL { path.appendingPathComponent("stdio.log") } } ================================================ FILE: Sources/Services/ContainerSandboxService/Client/ExitMonitor.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Containerization import ContainerizationError import ContainerizationExtras import Foundation import Logging /// Track when long running work exits, and notify the caller via a callback. public actor ExitMonitor { /// A callback that receives the client identifier and exit code. public typealias ExitCallback = @Sendable (String, ExitStatus) async throws -> Void /// A function that waits for work to complete, returning an exit code. public typealias WaitHandler = @Sendable () async throws -> ExitStatus /// Create a new monitor. /// /// - Parameters: /// - log: The destination for log messages. public init(log: Logger? = nil) { self.log = log } private var exitCallbacks: [String: ExitCallback] = [:] private var runningTasks: [String: Task] = [:] private let log: Logger? /// Remove tracked work from the monitor. /// /// - Parameters: /// - id: The client identifier for the tracked work. public func stopTracking(id: String) async { if let task = self.runningTasks[id] { task.cancel() } exitCallbacks.removeValue(forKey: id) runningTasks.removeValue(forKey: id) } /// Register long running work so that the monitor invokes /// a callback when the work completes. /// /// - Parameters: /// - id: The client identifier for the work. /// - onExit: The callback to invoke when the work completes. public func registerProcess(id: String, onExit: @escaping ExitCallback) async throws { guard self.exitCallbacks[id] == nil else { throw ContainerizationError(.invalidState, message: "ExitMonitor already setup for process \(id)") } self.exitCallbacks[id] = onExit } /// Await the completion of previously registered item of work. /// /// - Parameters: /// - id: The client identifier for the work. /// - waitingOn: A function that waits for the work to complete, /// and then returns an exit code. public func track(id: String, waitingOn: @escaping WaitHandler) async throws { guard let onExit = self.exitCallbacks[id] else { throw ContainerizationError(.invalidState, message: "ExitMonitor not setup for process \(id)") } guard self.runningTasks[id] == nil else { throw ContainerizationError(.invalidState, message: "already have a running task tracking process \(id)") } self.runningTasks[id] = Task { do { let exitStatus = try await waitingOn() try await onExit(id, exitStatus) } catch { self.log?.error("WaitHandler for \(id) threw error \(String(describing: error))") try? await onExit(id, ExitStatus(exitCode: -1)) } } } } ================================================ FILE: Sources/Services/ContainerSandboxService/Client/SandboxClient.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import ContainerResource import ContainerXPC import Containerization import ContainerizationError import ContainerizationOS import Foundation import TerminalProgress /// A client for interacting with a single sandbox. public struct SandboxClient: Sendable { static let label = "com.apple.container.runtime" public static func machServiceLabel(runtime: String, id: String) -> String { "\(Self.label).\(runtime).\(id)" } private var machServiceLabel: String { Self.machServiceLabel(runtime: runtime, id: id) } let id: String let runtime: String let client: XPCClient init(id: String, runtime: String, client: XPCClient) { self.id = id self.runtime = runtime self.client = client } /// Create a SandboxClient by ID and runtime string. The returned client is ready to be used /// without additional steps. public static func create(id: String, runtime: String, timeout: Duration = XPCClient.xpcRegistrationTimeout) async throws -> SandboxClient { let label = Self.machServiceLabel(runtime: runtime, id: id) let client = XPCClient(service: label) let request = XPCMessage(route: SandboxRoutes.createEndpoint.rawValue) let response: XPCMessage do { response = try await client.send(request, responseTimeout: timeout) } catch { throw ContainerizationError( .internalError, message: "failed to create container \(id)", cause: error ) } guard let endpoint = response.endpoint(key: SandboxKeys.sandboxServiceEndpoint.rawValue) else { throw ContainerizationError( .internalError, message: "failed to get endpoint for sandbox service" ) } let endpointConnection = xpc_connection_create_from_endpoint(endpoint) let xpcClient = XPCClient(connection: endpointConnection, label: label) return SandboxClient(id: id, runtime: runtime, client: xpcClient) } } // Runtime Methods extension SandboxClient { public func bootstrap(stdio: [FileHandle?], allocatedAttachments: [AllocatedAttachment]) async throws { let request = XPCMessage(route: SandboxRoutes.bootstrap.rawValue) for (i, h) in stdio.enumerated() { let key: SandboxKeys = try { switch i { case 0: .stdin case 1: .stdout case 2: .stderr default: throw ContainerizationError(.invalidArgument, message: "invalid fd \(i)") } }() if let h { request.set(key: key.rawValue, value: h) } } do { try request.setAllocatedAttachments(allocatedAttachments) try await self.client.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to bootstrap container \(self.id)", cause: error ) } } public func state() async throws -> SandboxSnapshot { let request = XPCMessage(route: SandboxRoutes.state.rawValue) let response: XPCMessage do { response = try await self.client.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to get state for container \(self.id)", cause: error ) } return try response.sandboxSnapshot() } public func createProcess(_ id: String, config: ProcessConfiguration, stdio: [FileHandle?]) async throws { let request = XPCMessage(route: SandboxRoutes.createProcess.rawValue) request.set(key: SandboxKeys.id.rawValue, value: id) let data = try JSONEncoder().encode(config) request.set(key: SandboxKeys.processConfig.rawValue, value: data) for (i, h) in stdio.enumerated() { let key: SandboxKeys = try { switch i { case 0: .stdin case 1: .stdout case 2: .stderr default: throw ContainerizationError(.invalidArgument, message: "invalid fd \(i)") } }() if let h { request.set(key: key.rawValue, value: h) } } do { try await self.client.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to create process \(id) in container \(self.id)", cause: error ) } } public func startProcess(_ id: String) async throws { let request = XPCMessage(route: SandboxRoutes.start.rawValue) request.set(key: SandboxKeys.id.rawValue, value: id) do { try await self.client.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to start process \(id) in container \(self.id)", cause: error ) } } public func stop(options: ContainerStopOptions) async throws { let request = XPCMessage(route: SandboxRoutes.stop.rawValue) let data = try JSONEncoder().encode(options) request.set(key: SandboxKeys.stopOptions.rawValue, value: data) let responseTimeout = Duration(.seconds(Int64(options.timeoutInSeconds + 1))) do { try await self.client.send(request, responseTimeout: responseTimeout) } catch { throw ContainerizationError( .internalError, message: "failed to stop container \(self.id)", cause: error ) } } public func kill(_ id: String, signal: Int64) async throws { let request = XPCMessage(route: SandboxRoutes.kill.rawValue) request.set(key: SandboxKeys.id.rawValue, value: id) request.set(key: SandboxKeys.signal.rawValue, value: signal) do { try await self.client.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to send signal \(signal) to process \(id) in container \(self.id)", cause: error ) } } public func resize(_ id: String, size: Terminal.Size) async throws { let request = XPCMessage(route: SandboxRoutes.resize.rawValue) request.set(key: SandboxKeys.id.rawValue, value: id) request.set(key: SandboxKeys.width.rawValue, value: UInt64(size.width)) request.set(key: SandboxKeys.height.rawValue, value: UInt64(size.height)) do { try await self.client.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to resize pty for process \(id) in container \(self.id)", cause: error ) } } public func wait(_ id: String) async throws -> ExitStatus { let request = XPCMessage(route: SandboxRoutes.wait.rawValue) request.set(key: SandboxKeys.id.rawValue, value: id) let response: XPCMessage do { response = try await self.client.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to wait for process \(id) in container \(self.id)", cause: error ) } let code = response.int64(key: SandboxKeys.exitCode.rawValue) let date = response.date(key: SandboxKeys.exitedAt.rawValue) return ExitStatus(exitCode: Int32(code), exitedAt: date) } public func dial(_ port: UInt32) async throws -> FileHandle { let request = XPCMessage(route: SandboxRoutes.dial.rawValue) request.set(key: SandboxKeys.port.rawValue, value: UInt64(port)) let response: XPCMessage do { response = try await self.client.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to dial \(port) on \(self.id)", cause: error ) } guard let fh = response.fileHandle(key: SandboxKeys.fd.rawValue) else { throw ContainerizationError( .internalError, message: "failed to get fd for vsock port \(port)" ) } return fh } public func shutdown() async throws { let request = XPCMessage(route: SandboxRoutes.shutdown.rawValue) do { _ = try await self.client.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to shutdown container \(self.id)", cause: error ) } } public func statistics() async throws -> ContainerStats { let request = XPCMessage(route: SandboxRoutes.statistics.rawValue) let response: XPCMessage do { response = try await self.client.send(request) } catch { throw ContainerizationError( .internalError, message: "failed to get statistics for container \(self.id)", cause: error ) } guard let data = response.dataNoCopy(key: SandboxKeys.statistics.rawValue) else { throw ContainerizationError( .internalError, message: "no statistics data returned" ) } return try JSONDecoder().decode(ContainerStats.self, from: data) } } extension XPCMessage { public func id() throws -> String { let id = self.string(key: SandboxKeys.id.rawValue) guard let id else { throw ContainerizationError( .invalidArgument, message: "no id" ) } return id } func sandboxSnapshot() throws -> SandboxSnapshot { let data = self.dataNoCopy(key: SandboxKeys.snapshot.rawValue) guard let data else { throw ContainerizationError( .invalidArgument, message: "no state data returned" ) } return try JSONDecoder().decode(SandboxSnapshot.self, from: data) } func setAllocatedAttachments(_ allocatedAttachments: [AllocatedAttachment]) throws { let encoder = JSONEncoder() let allocatedAttachmentsArray = xpc_array_create_empty() for allocatedAttach in allocatedAttachments { let xpcObject: xpc_object_t = xpc_dictionary_create_empty() let networkXPC = XPCMessage(object: xpcObject) let attachmentEncoded = try encoder.encode(allocatedAttach.attachment) networkXPC.set(key: SandboxKeys.networkAttachment.rawValue, value: attachmentEncoded) let pluginInfoEncoded = try encoder.encode(allocatedAttach.pluginInfo) networkXPC.set(key: SandboxKeys.networkPluginInfo.rawValue, value: pluginInfoEncoded) if let additionalData = allocatedAttach.additionalData { xpc_dictionary_set_value(networkXPC.underlying, SandboxKeys.networkAdditionalData.rawValue, additionalData.underlying) } xpc_array_append_value(allocatedAttachmentsArray, networkXPC.underlying) } self.set(key: SandboxKeys.allocatedAttachments.rawValue, value: allocatedAttachmentsArray) } } ================================================ FILE: Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// public enum SandboxKeys: String { /// ID key. case id /// Vsock port number key. case port /// Exit code for a process case exitCode /// Exit timestamp for a process case exitedAt /// FD to a container resource key. case fd /// Options for stopping a container key. case stopOptions /// An endpoint to talk to a sandbox service. case sandboxServiceEndpoint /// Process request keys. case signal case snapshot case stdin case stdout case stderr case width case height case processConfig /// Container statistics case statistics /// Network resource keys. case allocatedAttachments case networkAdditionalData case networkAttachment case networkPluginInfo } ================================================ FILE: Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// public enum SandboxRoutes: String { /// Create an xpc endpoint to the sandbox instance. case createEndpoint = "com.apple.container.sandbox/createEndpoint" /// Bootstrap the sandbox instance and create the init process. case bootstrap = "com.apple.container.sandbox/bootstrap" /// Create a process in the sandbox. case createProcess = "com.apple.container.sandbox/createProcess" /// Start a process in the sandbox. case start = "com.apple.container.sandbox/start" /// Stop the sandbox. case stop = "com.apple.container.sandbox/stop" /// Return the current state of the sandbox. case state = "com.apple.container.sandbox/state" /// Kill a process in the sandbox. case kill = "com.apple.container.sandbox/kill" /// Resize the pty of a process in the sandbox. case resize = "com.apple.container.sandbox/resize" /// Wait on a process in the sandbox. case wait = "com.apple.container.sandbox/wait" /// Execute a new process in the sandbox. case exec = "com.apple.container.sandbox/exec" /// Dial a vsock port in the sandbox. case dial = "com.apple.container.sandbox/dial" /// Shutdown the sandbox service process. case shutdown = "com.apple.container.sandbox/shutdown" /// Get statistics for the sandbox. case statistics = "com.apple.container.sandbox/statistics" } ================================================ FILE: Sources/Services/ContainerSandboxService/Client/SandboxRuntimeConfiguration.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import Containerization import ContainerizationError import Foundation public struct RuntimeConfiguration: Codable, Sendable { static let runtimeConfigurationFilename = "runtime-configuration.json" public let path: URL public let initialFilesystem: Filesystem public let kernel: Kernel public let containerConfiguration: ContainerConfiguration? public let containerRootFilesystem: Filesystem? public let options: ContainerCreateOptions? public init( path: URL, initialFilesystem: Filesystem, kernel: Kernel, containerConfiguration: ContainerConfiguration? = nil, containerRootFilesystem: Filesystem? = nil, options: ContainerCreateOptions? = nil ) { self.path = path self.initialFilesystem = initialFilesystem self.kernel = kernel self.containerConfiguration = containerConfiguration self.containerRootFilesystem = containerRootFilesystem self.options = options } public var runtimeConfigurationPath: URL { self.path.appendingPathComponent(Self.runtimeConfigurationFilename) } public func writeRuntimeConfiguration() throws { // Ensure the parent directory exists let directory = self.runtimeConfigurationPath.deletingLastPathComponent() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let data = try JSONEncoder().encode(self) try data.write(to: self.runtimeConfigurationPath) } public static func readRuntimeConfiguration(from runtimeConfigurationPath: URL) throws -> RuntimeConfiguration { let configurationPath = runtimeConfigurationPath.appendingPathComponent(RuntimeConfiguration.runtimeConfigurationFilename) guard FileManager.default.fileExists(atPath: configurationPath.path) else { throw ContainerizationError( .notFound, message: "runtime configuration file not found at path: \(configurationPath.path)" ) } let data = try Data(contentsOf: configurationPath) return try JSONDecoder().decode(RuntimeConfiguration.self, from: data) } } ================================================ FILE: Sources/Services/ContainerSandboxService/Client/SandboxSnapshot.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource /// A snapshot of a sandbox and its resources. public struct SandboxSnapshot: Codable, Sendable { /// The runtime status of the sandbox. public var status: RuntimeStatus /// Network attachments for the sandbox. public var networks: [Attachment] /// Containers placed in the sandbox. public var containers: [ContainerSnapshot] public init( status: RuntimeStatus, networks: [Attachment], containers: [ContainerSnapshot] ) { self.status = status self.networks = networks self.containers = containers } } ================================================ FILE: Sources/Services/ContainerSandboxService/Server/InterfaceStrategy.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import ContainerXPC import Containerization /// A strategy for mapping network attachment information to a network interface. public protocol InterfaceStrategy: Sendable { /// Map a client network attachment request to a network interface specification. /// /// - Parameters: /// - attachment: General attachment information that is common /// for all networks. /// - interfaceIndex: The zero-based index of the interface. /// - additionalData: If present, attachment information that is /// specific for the network to which the container will attach. /// /// - Returns: An XPC message with no parameters. func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) throws -> Interface } ================================================ FILE: Sources/Services/ContainerSandboxService/Server/SandboxService.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import ContainerOS import ContainerPersistence import ContainerResource import ContainerSandboxServiceClient import ContainerXPC import Containerization import ContainerizationError import ContainerizationExtras import ContainerizationOCI import ContainerizationOS import Foundation import Logging import NIO import NIOFoundationCompat import SocketForwarder import Synchronization import SystemPackage import struct ContainerizationOCI.Mount import struct ContainerizationOCI.Process /// An XPC service that manages the lifecycle of a single VM-backed container. public actor SandboxService { private let connection: xpc_connection_t private let root: URL private let interfaceStrategies: [NetworkPluginInfo: InterfaceStrategy] private var container: ContainerInfo? private let monitor: ExitMonitor private let eventLoopGroup: any EventLoopGroup private var waiters: [String: ExitWaiter] = [:] private let lock: AsyncLock = AsyncLock() private let log: Logging.Logger private var state: State = .created private var processes: [String: ProcessInfo] = [:] private var socketForwarders: [SocketForwarderResult] = [] private static let sshAuthSocketGuestPath = "/run/host-services/ssh-auth.sock" private static let sshAuthSocketEnvVar = "SSH_AUTH_SOCK" class ExitWaiter { public var exitCode: Int32? = nil public var continuations: [CheckedContinuation] = [] public func register(_ cc: CheckedContinuation) { continuations.append(cc) } public func doExit(code: Int32) { for cc in continuations { cc.resume(returning: ExitStatus(exitCode: code)) } exitCode = code } public func exited() -> Bool { exitCode != nil } } private static func sshAuthSocketHostUrl(config: ContainerConfiguration) -> URL? { if config.ssh, let sshSocket = Foundation.ProcessInfo.processInfo.environment[Self.sshAuthSocketEnvVar] { return URL(fileURLWithPath: sshSocket) } return nil } public init( root: URL, interfaceStrategies: [NetworkPluginInfo: InterfaceStrategy], eventLoopGroup: any EventLoopGroup, connection: xpc_connection_t, log: Logger ) { self.root = root self.interfaceStrategies = interfaceStrategies self.log = log self.monitor = ExitMonitor(log: log) self.eventLoopGroup = eventLoopGroup self.connection = connection } /// Returns an endpoint from an anonymous xpc connection. /// /// - Parameters: /// - message: An XPC message with no parameters. /// /// - Returns: An XPC message with the following parameters: /// - endpoint: An XPC endpoint that can be used to communicate /// with the sandbox service. @Sendable public func createEndpoint(_ message: XPCMessage) async throws -> XPCMessage { self.log.debug("enter", metadata: ["func": "\(#function)"]) defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } let endpoint = xpc_endpoint_create(self.connection) let reply = message.reply() reply.set(key: SandboxKeys.sandboxServiceEndpoint.rawValue, value: endpoint) return reply } /// Start the VM and the guest agent process for a container. /// /// - Parameters: /// - message: An XPC message with no parameters. /// /// - Returns: An XPC message with no parameters. @Sendable public func bootstrap(_ message: XPCMessage) async throws -> XPCMessage { self.log.debug("enter", metadata: ["func": "\(#function)"]) defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } // Create the bundle if it doesn't exist yet if !self.bundleExists(at: self.root) { try self.createBundle() } return try await self.lock.withLock { _ in guard await self.state == .created else { throw ContainerizationError( .invalidState, message: "container expected to be in created state, got: \(await self.state)" ) } let bundle = ContainerResource.Bundle(path: self.root) try bundle.createLogFile() var config = try bundle.configuration var kernel = try bundle.kernel kernel.commandLine.kernelArgs.append("oops=panic") kernel.commandLine.kernelArgs.append("lsm=lockdown,capability,landlock,yama,apparmor") let vmm = VZVirtualMachineManager( kernel: kernel, initialFilesystem: bundle.initialFilesystem.asMount, rosetta: config.rosetta, logger: self.log ) let allocatedAttachments = try message.getAllocatedAttachments() // Dynamically configure the DNS nameserver from a network if no explicit configuration if let dns = config.dns, dns.nameservers.isEmpty { let defaultNameservers = try await self.getDefaultNameservers(allocatedAttachments: allocatedAttachments) if !defaultNameservers.isEmpty { config.dns = ContainerConfiguration.DNSConfiguration( nameservers: defaultNameservers, domain: dns.domain, searchDomains: dns.searchDomains, options: dns.options ) } } var attachments: [Attachment] = [] var interfaces: [Interface] = [] for index in 0.. XPCMessage { self.log.debug("enter", metadata: ["func": "\(#function)"]) defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } return try await self.lock.withLock { lock in let id = try message.id() let containerInfo = try await self.getContainer() let containerId = containerInfo.container.id if id == containerId { try await self.startInitProcess(lock: lock) await self.setState(.running) } else { try await self.startExecProcess(processId: id, lock: lock) } return message.reply() } } /// Get statistics for the container. /// /// - Parameters: /// - message: An XPC message with the following parameters: /// - id: A client identifier for the process. /// - stdio: An array of file handles for standard input, output, and error. /// /// - Returns: An XPC message with the following parameters: /// - statistics: JSON serialization of the `ContainerStats`. @Sendable public func statistics(_ message: XPCMessage) async throws -> XPCMessage { self.log.debug("enter", metadata: ["func": "\(#function)"]) defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } return try await self.lock.withLock { lock in let containerInfo = try await self.getContainer() let stats = try await containerInfo.container.statistics() let containerStats = ContainerStats( id: stats.id, memoryUsageBytes: stats.memory?.usageBytes, memoryLimitBytes: stats.memory?.limitBytes, cpuUsageUsec: stats.cpu?.usageUsec, networkRxBytes: stats.networks?.reduce(0) { $0 + $1.receivedBytes }, networkTxBytes: stats.networks?.reduce(0) { $0 + $1.transmittedBytes }, blockReadBytes: stats.blockIO?.devices.reduce(0) { $0 + $1.readBytes }, blockWriteBytes: stats.blockIO?.devices.reduce(0) { $0 + $1.writeBytes }, numProcesses: stats.process?.current ) let reply = message.reply() let data = try JSONEncoder().encode(containerStats) reply.set(key: SandboxKeys.statistics.rawValue, value: data) return reply } } /// Shutdown the SandboxService. /// /// - Parameters: /// - message: An XPC message with no parameters. /// /// - Returns: An XPC message with no parameters. @Sendable public func shutdown(_ message: XPCMessage) async throws -> XPCMessage { self.log.debug("enter", metadata: ["func": "\(#function)"]) defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } return try await self.lock.withLock { _ in switch await self.state { case .created, .stopped, .stopping: await self.setState(.shuttingDown) default: throw ContainerizationError( .invalidState, message: "cannot shutdown: container is not stopped" ) } return message.reply() } } /// Create a process inside the virtual machine for the container. /// /// Use this procedure to run ad hoc processes in the virtual /// machine (`container exec`). /// /// - Parameters: /// - message: An XPC message with the following parameters: /// - id: A client identifier for the process. /// - processConfig: JSON serialization of the `ProcessConfiguration` /// containing the process attributes. /// /// - Returns: An XPC message with no parameters. @Sendable public func createProcess(_ message: XPCMessage) async throws -> XPCMessage { self.log.debug("enter", metadata: ["func": "\(#function)"]) defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } return try await self.lock.withLock { [self] _ in switch await self.state { case .running, .booted: let id = try message.id() let config = try message.processConfig() let stdio = message.stdio() try await self.addNewProcess(id, config, stdio) try await self.initializeWaiters(for: id) do { try await self.monitor.registerProcess( id: id, onExit: { id, exitStatus in await self.releaseWaiters(for: id, status: exitStatus) guard let process = await self.processes[id]?.process else { throw ContainerizationError( .invalidState, message: "ProcessInfo missing for process \(id)" ) } try await process.delete() try await self.setProcessState(id: id, state: .stopped) } ) } catch { await self.releaseWaiters(for: id, status: ExitStatus(exitCode: -1)) throw error } return message.reply() default: throw ContainerizationError( .invalidState, message: "cannot exec: container is not running" ) } } } /// Return the state for the sandbox and its containers. /// /// - Parameters: /// - message: An XPC message with no parameters. /// /// - Returns: An XPC message with the following parameters: /// - snapshot: The JSON serialization of the `SandboxSnapshot` /// that contains the state information. @Sendable public func state(_ message: XPCMessage) async throws -> XPCMessage { self.log.debug("enter", metadata: ["func": "\(#function)"]) defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } var status: RuntimeStatus = .unknown var networks: [Attachment] = [] var cs: ContainerSnapshot? switch state { case .created, .stopped, .booted, .shuttingDown: status = .stopped case .stopping: status = .stopping case .running: let ctr = try getContainer() status = .running networks = ctr.attachments cs = ContainerSnapshot( configuration: ctr.config, status: RuntimeStatus.running, networks: networks ) } let reply = message.reply() try reply.setState( .init( status: status, networks: networks, containers: cs != nil ? [cs!] : [] ) ) return reply } /// Stop the container workload, any ad hoc processes, and the underlying /// virtual machine. /// /// - Parameters: /// - message: An XPC message with the following parameters: /// - stopOptions: JSON serialization of `ContainerStopOptions` /// that modify stop behavior. /// /// - Returns: An XPC message with no parameters. @Sendable public func stop(_ message: XPCMessage) async throws -> XPCMessage { self.log.debug("enter", metadata: ["func": "\(#function)"]) defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } return try await self.lock.withLock { _ in switch await self.state { case .running, .booted: await self.setState(.stopping) let ctr = try await self.getContainer() let stopOptions = try message.stopOptions() let exitStatus = try await self.gracefulStopContainer( ctr.container, stopOpts: stopOptions ) do { if case .stopped = await self.state { return message.reply() } try await self.cleanUpContainer(containerInfo: ctr, exitStatus: exitStatus) } catch { self.log.error("failed to clean up container", metadata: ["error": "\(error)"]) } await self.setState(.stopped) default: break } return message.reply() } } /// Signal a process running in the virtual machine. /// /// - Parameters: /// - message: An XPC message with the following parameters: /// - id: The process identifier. /// - signal: The signal value. /// /// - Returns: An XPC message with no parameters. @Sendable public func kill(_ message: XPCMessage) async throws -> XPCMessage { self.log.debug("enter", metadata: ["func": "\(#function)"]) defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } return try await self.lock.withLock { [self] _ in switch await self.state { case .running: let ctr = try await getContainer() let id = try message.id() if id != ctr.container.id { guard let processInfo = await self.processes[id] else { throw ContainerizationError(.invalidState, message: "process \(id) does not exist") } guard let proc = processInfo.process else { throw ContainerizationError(.invalidState, message: "process \(id) not started") } try await proc.kill(Int32(try message.signal())) return message.reply() } // TODO: fix underlying signal value to int64 try await ctr.container.kill(Int32(try message.signal())) return message.reply() default: throw ContainerizationError( .invalidState, message: "cannot kill: container is not running" ) } } } /// Resize the terminal for a process. /// /// - Parameters: /// - message: An XPC message with the following parameters: /// - id: The process identifier. /// - width: The terminal width. /// - height: The terminal height. /// /// - Returns: An XPC message with no parameters. @Sendable public func resize(_ message: XPCMessage) async throws -> XPCMessage { self.log.trace("enter", metadata: ["func": "\(#function)"]) defer { self.log.trace("exit", metadata: ["func": "\(#function)"]) } switch self.state { case .running: let id = try message.id() let ctr = try getContainer() let width = message.uint64(key: SandboxKeys.width.rawValue) let height = message.uint64(key: SandboxKeys.height.rawValue) if id != ctr.container.id { guard let processInfo = self.processes[id] else { throw ContainerizationError( .invalidState, message: "process \(id) does not exist" ) } guard let proc = processInfo.process else { throw ContainerizationError( .invalidState, message: "process \(id) not started" ) } try await proc.resize( to: .init( width: UInt16(width), height: UInt16(height)) ) } else { try await ctr.container.resize( to: .init( width: UInt16(width), height: UInt16(height)) ) } return message.reply() default: throw ContainerizationError( .invalidState, message: "cannot resize: container is not running" ) } } /// Wait for a process. /// /// - Parameters: /// - message: An XPC message with the following parameters: /// - id: The process identifier. /// /// - Returns: An XPC message with the following parameters: /// - exitCode: The exit code for the process. @Sendable public func wait(_ message: XPCMessage) async throws -> XPCMessage { self.log.debug("enter", metadata: ["func": "\(#function)"]) defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } guard let id = message.string(key: SandboxKeys.id.rawValue) else { throw ContainerizationError(.invalidArgument, message: "missing id in wait xpc message") } let exitStatus = await withCheckedContinuation { cc in // Is this safe since we are in an actor? :( let (added, exitCode) = self.addWaiter(id: id, cont: cc) if !added { cc.resume(returning: ExitStatus(exitCode: exitCode ?? -1)) } } let reply = message.reply() reply.set(key: SandboxKeys.exitCode.rawValue, value: Int64(exitStatus.exitCode)) reply.set(key: SandboxKeys.exitedAt.rawValue, value: exitStatus.exitedAt) return reply } /// Dial a vsock port on the virtual machine. /// /// - Parameters: /// - message: An XPC message with the following parameters: /// - port: The port number. /// /// - Returns: An XPC message with the following parameters: /// - fd: The file descriptor for the vsock. @Sendable public func dial(_ message: XPCMessage) async throws -> XPCMessage { self.log.debug("enter", metadata: ["func": "\(#function)"]) defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } switch self.state { case .running, .booted: let port = message.uint64(key: SandboxKeys.port.rawValue) guard port > 0 else { throw ContainerizationError( .invalidArgument, message: "no vsock port supplied for dial" ) } let ctr = try getContainer() let fh = try await ctr.container.dialVsock(port: UInt32(port)) let reply = message.reply() reply.set(key: SandboxKeys.fd.rawValue, value: fh) return reply default: throw ContainerizationError( .invalidState, message: "cannot dial: container is not running" ) } } private func startInitProcess(lock: AsyncLock.Context) async throws { let info = try self.getContainer() let container = info.container let id = container.id guard self.state == .booted else { throw ContainerizationError( .invalidState, message: "container expected to be in booted state, got: \(self.state)" ) } do { let io = info.io try await container.start() let waitFunc: ExitMonitor.WaitHandler = { let code = try await container.wait() if let out = io.out { try out.close() } if let err = io.err { try err.close() } return code } try await self.monitor.track(id: id, waitingOn: waitFunc) } catch { try? await self.cleanUpContainer(containerInfo: info) self.setState(.stopped) throw error } } private func startExecProcess(processId id: String, 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 containerInfo = try self.getContainer() let czConfig = try self.configureProcessConfig( config: processInfo.config, stdio: processInfo.io, containerConfig: containerInfo.config ) let process = try await container.exec(id, configuration: czConfig) try self.setUnderlyingProcess(id, process) try await process.start() let waitFunc: ExitMonitor.WaitHandler = { let code = try await process.wait() if let out = processInfo.io[1] { try self.closeHandle(out.fileDescriptor) } if let err = processInfo.io[2] { try self.closeHandle(err.fileDescriptor) } return code } try await self.monitor.track(id: id, waitingOn: waitFunc) } private func startSocketForwarders(attachment: Attachment, publishedPorts: [PublishPort]) async throws { guard !publishedPorts.isEmpty else { return } LocalNetworkPrivacy.triggerLocalNetworkPrivacyAlert() var forwarders: [SocketForwarderResult] = [] guard !publishedPorts.hasOverlaps() else { throw ContainerizationError(.invalidArgument, message: "host ports for different publish port specs may not overlap") } try await withThrowingTaskGroup(of: SocketForwarderResult.self) { group in for publishedPort in publishedPorts { for index in 0.. [String] { for allocatedAttach in allocatedAttachments { let state = try await ClientNetwork.get(id: allocatedAttach.attachment.network) guard case .running(_, let status) = state else { continue } return [status.ipv4Gateway.description] } return [] } private static func configureInitialProcess( czConfig: inout LinuxContainer.Configuration, config: ContainerConfiguration ) throws { let process = config.initProcess czConfig.process.arguments = [process.executable] + process.arguments czConfig.process.environmentVariables = process.environment if Self.sshAuthSocketHostUrl(config: config) != nil { if !czConfig.process.environmentVariables.contains(where: { $0.starts(with: "\(Self.sshAuthSocketEnvVar)=") }) { czConfig.process.environmentVariables.append("\(Self.sshAuthSocketEnvVar)=\(Self.sshAuthSocketGuestPath)") } } czConfig.process.terminal = process.terminal czConfig.process.workingDirectory = process.workingDirectory try czConfig.process.rlimits = process.rlimits.map { LinuxRLimit( kind: try LinuxRLimit.Kind($0.limit), hard: $0.hard, soft: $0.soft ) } switch process.user { case .raw(let name): czConfig.process.user = .init( uid: 0, gid: 0, umask: nil, additionalGids: process.supplementalGroups, username: name ) case .id(let uid, let gid): czConfig.process.user = .init( uid: uid, gid: gid, umask: nil, additionalGids: process.supplementalGroups, username: "" ) } } private nonisolated func configureProcessConfig(config: ProcessConfiguration, stdio: [FileHandle?], containerConfig: ContainerConfiguration) throws -> LinuxProcessConfiguration { var proc = LinuxProcessConfiguration() proc.stdin = stdio[0] proc.stdout = stdio[1] proc.stderr = stdio[2] proc.arguments = [config.executable] + config.arguments proc.environmentVariables = config.environment if Self.sshAuthSocketHostUrl(config: containerConfig) != nil { if !proc.environmentVariables.contains(where: { $0.starts(with: "\(Self.sshAuthSocketEnvVar)=") }) { proc.environmentVariables.append("\(Self.sshAuthSocketEnvVar)=\(Self.sshAuthSocketGuestPath)") } } proc.terminal = config.terminal proc.workingDirectory = config.workingDirectory try proc.rlimits = config.rlimits.map { LinuxRLimit( kind: try LinuxRLimit.Kind($0.limit), hard: $0.hard, soft: $0.soft ) } switch config.user { case .raw(let name): proc.user = .init( uid: 0, gid: 0, umask: nil, additionalGids: config.supplementalGroups, username: name ) case .id(let uid, let gid): proc.user = .init( uid: uid, gid: gid, umask: nil, additionalGids: config.supplementalGroups, username: "" ) } 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 func getContainer() throws -> ContainerInfo { guard let container else { throw ContainerizationError( .invalidState, message: "no container found" ) } return container } private func gracefulStopContainer(_ lc: LinuxContainer, stopOpts: ContainerStopOptions) async throws -> ExitStatus { // Try and gracefully shut down the process. Even if this succeeds we need to power off // the vm, but we should try this first always. var code = ExitStatus(exitCode: 255) do { code = try await withThrowingTaskGroup(of: ExitStatus.self) { group in group.addTask { try await lc.wait() } group.addTask { try await lc.kill(stopOpts.signal) try await Task.sleep(for: .seconds(stopOpts.timeoutInSeconds)) try await lc.kill(SIGKILL) return ExitStatus(exitCode: 137) } guard let code = try await group.next() else { throw ContainerizationError( .internalError, message: "failed to get exit code from gracefully stopping container" ) } group.cancelAll() return code } } catch {} // Now actually bring down the vm. try await lc.stop() return code } private func cleanUpContainer(containerInfo: ContainerInfo, exitStatus: ExitStatus? = nil) async throws { let container = containerInfo.container let id = container.id do { try await container.stop() } catch { self.log.error("failed to stop container during cleanup", metadata: ["error": "\(error)"]) } await self.stopSocketForwarders() let status = exitStatus ?? ExitStatus(exitCode: 255) self.releaseWaiters(for: id, status: status) } } extension XPCMessage { fileprivate func signal() throws -> Int64 { self.int64(key: SandboxKeys.signal.rawValue) } fileprivate func stopOptions() throws -> ContainerStopOptions { guard let data = self.dataNoCopy(key: SandboxKeys.stopOptions.rawValue) else { throw ContainerizationError(.invalidArgument, message: "empty StopOptions") } return try JSONDecoder().decode(ContainerStopOptions.self, from: data) } fileprivate func setState(_ state: SandboxSnapshot) throws { let data = try JSONEncoder().encode(state) self.set(key: SandboxKeys.snapshot.rawValue, value: data) } fileprivate func stdio() -> [FileHandle?] { var handles = [FileHandle?](repeating: nil, count: 3) if let stdin = self.fileHandle(key: SandboxKeys.stdin.rawValue) { handles[0] = stdin } if let stdout = self.fileHandle(key: SandboxKeys.stdout.rawValue) { handles[1] = stdout } if let stderr = self.fileHandle(key: SandboxKeys.stderr.rawValue) { handles[2] = stderr } return handles } fileprivate func setFileHandle(_ handle: FileHandle) { self.set(key: SandboxKeys.fd.rawValue, value: handle) } fileprivate func processConfig() throws -> ProcessConfiguration { guard let data = self.dataNoCopy(key: SandboxKeys.processConfig.rawValue) else { throw ContainerizationError(.invalidArgument, message: "empty process configuration") } return try JSONDecoder().decode(ProcessConfiguration.self, from: data) } fileprivate func getAllocatedAttachments() throws -> [AllocatedAttachment] { guard let attachmentArray = xpc_dictionary_get_value(self.underlying, SandboxKeys.allocatedAttachments.rawValue) else { throw ContainerizationError(.invalidArgument, message: "missing allocatedAttachments array in message") } var results = [AllocatedAttachment]() let decoder = JSONDecoder() let arrayCount = xpc_array_get_count(attachmentArray) for i in 0.. 0 else { throw POSIXError(.init(rawValue: errno)!) } close(fd) } } extension Filesystem { var asMount: Containerization.Mount { switch self.type { case .tmpfs: return .any( type: "tmpfs", source: self.source, destination: self.destination, options: self.options ) case .virtiofs: return .share( source: self.source, destination: self.destination, options: self.options ) case .block(let format, let cacheMode, let syncMode): return .block( format: format, source: self.source, destination: self.destination, options: self.options, runtimeOptions: [ "\(Filesystem.CacheMode.vzRuntimeOptionKey)=\(cacheMode.asVZRuntimeOption)", "\(Filesystem.SyncMode.vzRuntimeOptionKey)=\(syncMode.asVZRuntimeOption)", ], ) case .volume(_, let format, let cacheMode, let syncMode): return .block( format: format, source: self.source, destination: self.destination, options: self.options, runtimeOptions: [ "\(Filesystem.CacheMode.vzRuntimeOptionKey)=\(cacheMode.asVZRuntimeOption)", "\(Filesystem.SyncMode.vzRuntimeOptionKey)=\(syncMode.asVZRuntimeOption)", ], ) } } func isSocket() throws -> Bool { if !self.isVirtiofs { return false } let info = try File.info(self.source) return info.isSocket } } extension Filesystem.CacheMode { static let vzRuntimeOptionKey = "vzDiskImageCachingMode" var asVZRuntimeOption: String { switch self { case .on: "cached" case .off: "uncached" case .auto: "automatic" } } } extension Filesystem.SyncMode { static let vzRuntimeOptionKey = "vzDiskImageSynchronizationMode" var asVZRuntimeOption: String { switch self { case .full: "full" case .fsync: "fsync" case .nosync: "none" } } } struct MultiWriter: Writer { let handles: [FileHandle] init(handles: [FileHandle]) { self.handles = handles } func close() throws { for handle in handles { try handle.close() } } func write(_ data: Data) throws { for handle in handles { try handle.write(contentsOf: data) } } } extension FileHandle: @retroactive ReaderStream, @retroactive Writer { public func write(_ data: Data) throws { try self.write(contentsOf: data) } public func stream() -> AsyncStream { .init { cont in self.readabilityHandler = { handle in let data = handle.availableData if data.isEmpty { self.readabilityHandler = nil cont.finish() return } cont.yield(data) } } } } // MARK: State handler and bundle creation helpers extension SandboxService { private func initializeWaiters(for id: String) throws { guard waiters[id] == nil else { throw ContainerizationError(.invalidState, message: "waiter for \(id) already initialized") } waiters[id] = ExitWaiter() } private func addWaiter(id: String, cont: CheckedContinuation) -> (Bool, Int32?) { guard let current = waiters[id] else { // No waiter initialized at all return (false, nil) } if current.exited() { // Waiter initialzed but already exited return (false, current.exitCode) } // Waiter initialized and not exited. Guaranteed to exit later. current.register(cont) return (true, nil) } private func releaseWaiters(for id: String, status: ExitStatus) { waiters[id]?.doExit(code: status.exitCode) } private func setUnderlyingProcess(_ id: String, _ process: LinuxProcess) throws { guard var info = self.processes[id] else { throw ContainerizationError(.invalidState, message: "process \(id) not found") } info.process = process self.processes[id] = info } private func setProcessState(id: String, state: State) throws { guard var info = self.processes[id] else { throw ContainerizationError(.invalidState, message: "process \(id) not found") } info.state = state self.processes[id] = info } private func setContainer(_ info: ContainerInfo) { self.container = info } private func addNewProcess(_ id: String, _ config: ProcessConfiguration, _ io: [FileHandle?]) throws { guard self.processes[id] == nil else { throw ContainerizationError(.invalidArgument, message: "process \(id) already exists") } self.processes[id] = ProcessInfo(config: config, process: nil, state: .created, io: io) } private struct ProcessInfo { let config: ProcessConfiguration var process: LinuxProcess? var state: State let io: [FileHandle?] } private struct ContainerInfo { let container: LinuxContainer let config: ContainerConfiguration let attachments: [Attachment] let bundle: ContainerResource.Bundle let io: (in: FileHandle?, out: MultiWriter?, err: MultiWriter?) } /// States the underlying sandbox can be in. public enum State: Sendable, Equatable { /// Sandbox is created. This should be what the service starts the sandbox in. case created /// Bootstrap will transition a .created state to .booted. case booted /// startProcess on the init process will transition .booted to .running. case running /// At the beginning of stop() .running will be transitioned to .stopping. case stopping /// Once a stop is successful, .stopping will transition to .stopped. case stopped /// .shuttingDown will be the last state the sandbox service will ever be in. Shortly /// afterwards the process will exit. case shuttingDown } func setState(_ new: State) { self.state = new } /// Check if a bundle exists at the given path private func bundleExists(at path: URL) -> Bool { guard FileManager.default.fileExists(atPath: path.path) else { return false } let bundle = ContainerResource.Bundle(path: path) do { _ = try bundle.configuration return true } catch { return false } } /// Create bundle from RuntimeConfiguration private func createBundle() throws { do { let runtimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: self.root) _ = try ContainerResource.Bundle.create( path: runtimeConfig.path, initialFilesystem: runtimeConfig.initialFilesystem, kernel: runtimeConfig.kernel, containerConfiguration: runtimeConfig.containerConfiguration, containerRootFilesystem: runtimeConfig.containerRootFilesystem, options: runtimeConfig.options ) self.log.info("created bundle", metadata: ["configPath": "\(runtimeConfig.path)"]) } catch { self.log.error("failed to create bundle", metadata: ["error": "\(error)"]) throw error } } } ================================================ FILE: Sources/SocketForwarder/ConnectHandler.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Logging import NIOCore import NIOPosix final class ConnectHandler { private var pendingBytes: [NIOAny] private let serverAddress: SocketAddress private var log: Logger? = nil init(serverAddress: SocketAddress, log: Logger?) { self.pendingBytes = [] self.serverAddress = serverAddress self.log = log } } extension ConnectHandler: ChannelInboundHandler { typealias InboundIn = ByteBuffer typealias OutboundOut = ByteBuffer func channelRead(context: ChannelHandlerContext, data: NIOAny) { self.pendingBytes.append(data) } func handlerAdded(context: ChannelHandlerContext) { // Add logger metadata. self.log?[metadataKey: "proxy"] = "\(context.channel.localAddress?.description ?? "none")" self.log?[metadataKey: "server"] = "\(context.channel.remoteAddress?.description ?? "none")" } func channelActive(context: ChannelHandlerContext) { self.log?.trace("frontend - channel active, connecting to backend") self.connectToServer(context: context) context.fireChannelActive() } } extension ConnectHandler: RemovableChannelHandler { func removeHandler(context: ChannelHandlerContext, removalToken: ChannelHandlerContext.RemovalToken) { var didRead = false // We are being removed, and need to deliver any pending bytes we may have if we're upgrading. while self.pendingBytes.count > 0 { let data = self.pendingBytes.removeFirst() context.fireChannelRead(data) didRead = true } if didRead { context.fireChannelReadComplete() } self.log?.trace("backend - removing connect handler from pipeline") context.leavePipeline(removalToken: removalToken) } } extension ConnectHandler { private func connectToServer(context: ChannelHandlerContext) { self.log?.trace("backend - connecting") ClientBootstrap(group: context.eventLoop) .connect(to: serverAddress) .assumeIsolatedUnsafeUnchecked() .whenComplete { result in switch result { case .success(let channel): guard context.channel.isActive else { self.log?.trace("backend - frontend channel closed, closing backend connection") context.channel.close(promise: nil) return } self.log?.trace("backend - connected") self.glue(channel, context: context) case .failure(let error): self.log?.error("backend - connect failed: \(error)") context.close(promise: nil) context.fireErrorCaught(error) } } } private func glue(_ peerChannel: Channel, context: ChannelHandlerContext) { self.log?.trace("backend - gluing channels") // Now we need to glue our channel and the peer channel together. let (localGlue, peerGlue) = GlueHandler.matchedPair() do { try context.channel.pipeline.syncOperations.addHandler(localGlue) try peerChannel.pipeline.syncOperations.addHandler(peerGlue) context.pipeline.syncOperations.removeHandler(self, promise: nil) } catch { // Close connected peer channel before closing our channel. peerChannel.close(mode: .all, promise: nil) context.close(promise: nil) } } } ================================================ FILE: Sources/SocketForwarder/GlueHandler.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import NIOCore final class GlueHandler { private var partner: GlueHandler? private var context: ChannelHandlerContext? private var pendingRead: Bool = false private init() {} } extension GlueHandler { static func matchedPair() -> (GlueHandler, GlueHandler) { let first = GlueHandler() let second = GlueHandler() first.partner = second second.partner = first return (first, second) } } extension GlueHandler { private func partnerWrite(_ data: NIOAny) { self.context?.write(data, promise: nil) } private func partnerFlush() { self.context?.flush() } private func partnerWriteEOF() { self.context?.close(mode: .output, promise: nil) } private func partnerCloseFull() { self.context?.close(promise: nil) } private func partnerBecameWritable() { if self.pendingRead { self.pendingRead = false self.context?.read() } } private var partnerWritable: Bool { self.context?.channel.isWritable ?? false } } extension GlueHandler: ChannelDuplexHandler { typealias InboundIn = NIOAny typealias OutboundIn = NIOAny typealias OutboundOut = NIOAny func handlerAdded(context: ChannelHandlerContext) { self.context = context } func handlerRemoved(context: ChannelHandlerContext) { self.context = nil self.partner = nil } func channelRead(context: ChannelHandlerContext, data: NIOAny) { self.partner?.partnerWrite(data) } func channelReadComplete(context: ChannelHandlerContext) { self.partner?.partnerFlush() } func channelInactive(context: ChannelHandlerContext) { self.partner?.partnerCloseFull() } func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { if let event = event as? ChannelEvent, case .inputClosed = event { // We have read EOF. self.partner?.partnerWriteEOF() } } func errorCaught(context: ChannelHandlerContext, error: Error) { self.partner?.partnerCloseFull() } func channelWritabilityChanged(context: ChannelHandlerContext) { if context.channel.isWritable { self.partner?.partnerBecameWritable() } } func read(context: ChannelHandlerContext) { if let partner = self.partner, partner.partnerWritable { context.read() } else { self.pendingRead = true } } } ================================================ FILE: Sources/SocketForwarder/LRUCache.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// struct KeyExistsError: Error {} class LRUCache { private class Node { fileprivate var prev: Node? fileprivate var next: Node? fileprivate let key: K fileprivate let value: V init(key: K, value: V) { self.prev = nil self.next = nil self.key = key self.value = value } } private let size: UInt private var head: Node? private var tail: Node? private var members: [K: Node] init(size: UInt) { self.size = size self.head = nil self.tail = nil self.members = [:] } var count: Int { members.count } func get(_ key: K) -> V? { guard let node = members[key] else { return nil } listRemove(node: node) listInsert(node: node, after: tail) return node.value } func put(key: K, value: V) -> (K, V)? { let node = Node(key: key, value: value) var evicted: (K, V)? = nil if let existingNode = members[key] { // evict the replaced node listRemove(node: existingNode) evicted = (existingNode.key, existingNode.value) } else if self.count >= self.size { // evict the least recently used node evicted = evict() } // insert the new node and return any evicted node members[key] = node listInsert(node: node, after: tail) return evicted } private func evict() -> (K, V)? { guard let head else { return nil } let ret = (head.key, head.value) listRemove(node: head) members.removeValue(forKey: head.key) return ret } private func listRemove(node: Node) { if let prev = node.prev { prev.next = node.next } else { head = node.next } if let next = node.next { next.prev = node.prev } else { tail = node.prev } } private func listInsert(node: Node, after: Node?) { let before: Node? if let after { before = after.next after.next = node } else { before = head head = node } if let before { before.prev = node } else { tail = node } node.prev = after node.next = before } } ================================================ FILE: Sources/SocketForwarder/SocketForwarder.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import NIO public protocol SocketForwarder: Sendable { func run() throws -> EventLoopFuture } ================================================ FILE: Sources/SocketForwarder/SocketForwarderResult.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import NIO public struct SocketForwarderResult: Sendable { private let channel: any Channel public init(channel: Channel) { self.channel = channel } public var proxyAddress: SocketAddress? { self.channel.localAddress } public func close() { self.channel.eventLoop.execute { _ = channel.close() } } public func wait() async throws { try await self.channel.closeFuture.get() } } ================================================ FILE: Sources/SocketForwarder/TCPForwarder.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 Logging import NIO import NIOFoundationCompat public struct TCPForwarder: SocketForwarder { private let proxyAddress: SocketAddress private let serverAddress: SocketAddress private let eventLoopGroup: any EventLoopGroup private let log: Logger? public init( proxyAddress: SocketAddress, serverAddress: SocketAddress, eventLoopGroup: any EventLoopGroup, log: Logger? = nil ) throws { self.proxyAddress = proxyAddress self.serverAddress = serverAddress self.eventLoopGroup = eventLoopGroup self.log = log } public func run() throws -> EventLoopFuture { self.log?.trace("frontend - creating listener") let bootstrap = ServerBootstrap(group: self.eventLoopGroup) .serverChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1) .childChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1) .childChannelInitializer { channel in channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler( ConnectHandler(serverAddress: self.serverAddress, log: log) ) } } return bootstrap .bind(to: self.proxyAddress) .map { SocketForwarderResult(channel: $0) } } } ================================================ FILE: Sources/SocketForwarder/UDPForwarder.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Collections import Foundation import Logging import NIO import NIOFoundationCompat import Synchronization // Proxy backend for a single client address (clientIP, clientPort). private final class UDPProxyBackend: ChannelInboundHandler { typealias InboundIn = AddressedEnvelope typealias OutboundOut = AddressedEnvelope private struct State { var queuedPayloads: Deque var channel: (any Channel)? } private let clientAddress: SocketAddress private let serverAddress: SocketAddress private let frontendChannel: any Channel private let log: Logger? private var state: State init(clientAddress: SocketAddress, serverAddress: SocketAddress, frontendChannel: any Channel, log: Logger? = nil) { self.clientAddress = clientAddress self.serverAddress = serverAddress self.frontendChannel = frontendChannel self.log = log let initialState = State(queuedPayloads: Deque(), channel: nil) self.state = initialState } func channelRead(context: ChannelHandlerContext, data: NIOAny) { // relay data from server to client. let inbound = self.unwrapInboundIn(data) let outbound = OutboundOut(remoteAddress: self.clientAddress, data: inbound.data) self.log?.trace("backend - writing datagram to client") self.frontendChannel.writeAndFlush(outbound, promise: nil) } func channelActive(context: ChannelHandlerContext) { if !state.queuedPayloads.isEmpty { self.log?.trace("backend - writing \(state.queuedPayloads.count) queued datagrams to server") while let queuedData = state.queuedPayloads.popFirst() { let outbound: UDPProxyBackend.OutboundOut = OutboundOut(remoteAddress: self.serverAddress, data: queuedData) context.channel.writeAndFlush(outbound, promise: nil) } } state.channel = context.channel } func write(data: ByteBuffer) { // change package remote address from proxy server to real server if let channel = state.channel { // channel has been initialized, so relay any queued packets, along with this one to outbound self.log?.trace("backend - writing datagram to server") let outbound: UDPProxyBackend.OutboundOut = OutboundOut(remoteAddress: self.serverAddress, data: data) channel.writeAndFlush(outbound, promise: nil) } else { // channel is initializing, queue self.log?.trace("backend - queuing datagram") state.queuedPayloads.append(data) } } func close() { guard let channel = state.channel else { self.log?.warning("backend - close on inactive channel") return } _ = channel.close() } } private struct ProxyContext { public let proxy: UDPProxyBackend public let closeFuture: EventLoopFuture } private final class UDPProxyFrontend: ChannelInboundHandler { typealias InboundIn = AddressedEnvelope typealias OutboundOut = AddressedEnvelope private let maxProxies = UInt(256) private let proxyAddress: SocketAddress private let serverAddress: SocketAddress private let log: Logger? private var proxies: LRUCache init(proxyAddress: SocketAddress, serverAddress: SocketAddress, log: Logger? = nil) { self.proxyAddress = proxyAddress self.serverAddress = serverAddress self.proxies = LRUCache(size: maxProxies) self.log = log } func channelRead(context: ChannelHandlerContext, data: NIOAny) { let inbound = self.unwrapInboundIn(data) guard let clientIP = inbound.remoteAddress.ipAddress else { log?.error("frontend - no client IP address in inbound payload") return } guard let clientPort = inbound.remoteAddress.port else { log?.error("frontend - no client port in inbound payload") return } let key = "\(clientIP):\(clientPort)" do { if let context = proxies.get(key) { context.proxy.write(data: inbound.data) } else { self.log?.trace("frontend - creating backend") let proxy = UDPProxyBackend( clientAddress: inbound.remoteAddress, serverAddress: self.serverAddress, frontendChannel: context.channel, log: log ) let proxyAddress = try SocketAddress(ipAddress: "0.0.0.0", port: 0) let loopBoundProxy = NIOLoopBound(proxy, eventLoop: context.eventLoop) let proxyToServerFuture = DatagramBootstrap(group: context.eventLoop) .channelInitializer { [log] channel in log?.trace("frontend - initializing backend") return channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler(loopBoundProxy.value) } } .bind(to: proxyAddress) .flatMap { $0.closeFuture } let context = ProxyContext(proxy: proxy, closeFuture: proxyToServerFuture) if let (_, evictedContext) = proxies.put(key: key, value: context) { self.log?.trace("frontend - closing evicted backend") evictedContext.proxy.close() } proxy.write(data: inbound.data) } } catch { log?.error("server handler - backend channel creation failed with error: \(error)") return } } } public struct UDPForwarder: SocketForwarder { private let proxyAddress: SocketAddress private let serverAddress: SocketAddress private let eventLoopGroup: any EventLoopGroup private let log: Logger? public init( proxyAddress: SocketAddress, serverAddress: SocketAddress, eventLoopGroup: any EventLoopGroup, log: Logger? = nil ) throws { self.proxyAddress = proxyAddress self.serverAddress = serverAddress self.eventLoopGroup = eventLoopGroup self.log = log } public func run() throws -> EventLoopFuture { self.log?.trace("frontend - creating channel") let bootstrap = DatagramBootstrap(group: self.eventLoopGroup) .channelInitializer { serverChannel in self.log?.trace("frontend - initializing channel") let proxyToServerHandler = UDPProxyFrontend( proxyAddress: proxyAddress, serverAddress: serverAddress, log: log ) return serverChannel.eventLoop.makeCompletedFuture { try serverChannel.pipeline.syncOperations.addHandler(proxyToServerHandler) } } return bootstrap .bind(to: proxyAddress) .map { SocketForwarderResult(channel: $0) } } } ================================================ FILE: Sources/TerminalProgress/Int+Formatted.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Foundation extension Int { func formattedTime() -> String { let secondsInMinute = 60 let secondsInHour = secondsInMinute * 60 let secondsInDay = secondsInHour * 24 let days = self / secondsInDay let hours = (self % secondsInDay) / secondsInHour let minutes = (self % secondsInHour) / secondsInMinute let seconds = self % secondsInMinute var components = [String]() if days > 0 { components.append("\(days)d") } if hours > 0 || days > 0 { components.append("\(hours)h") } if minutes > 0 || hours > 0 || days > 0 { components.append("\(minutes)m") } components.append("\(seconds)s") return components.joined(separator: " ") } func formattedNumber() -> String { let formatter = NumberFormatter() formatter.numberStyle = .decimal guard let formattedNumber = formatter.string(from: NSNumber(value: self)) else { return "" } return formattedNumber } } ================================================ FILE: Sources/TerminalProgress/Int64+Formatted.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Foundation extension Int64 { func formattedSize() -> String { let formattedSize = ByteCountFormatter.string(fromByteCount: self, countStyle: .binary) return formattedSize } func formattedSizeSpeed(from startTime: DispatchTime) -> String { let elapsedTimeNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds let elapsedTimeSeconds = Double(elapsedTimeNanoseconds) / 1_000_000_000 guard elapsedTimeSeconds > 0 else { return "0 B/s" } let speed = Double(self) / elapsedTimeSeconds let formattedSpeed = ByteCountFormatter.string(fromByteCount: Int64(speed), countStyle: .binary) return "\(formattedSpeed)/s" } } ================================================ FILE: Sources/TerminalProgress/ProgressBar+Add.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Foundation extension ProgressBar { /// A handler function to update the progress bar. /// - Parameter events: The events to handle. public func handler(_ events: [ProgressUpdateEvent]) { for event in events { switch event { case .setDescription(let description): set(description: description) case .setSubDescription(let subDescription): set(subDescription: subDescription) case .setItemsName(let itemsName): set(itemsName: itemsName) case .addTasks(let tasks): add(tasks: tasks) case .setTasks(let tasks): set(tasks: tasks) case .addTotalTasks(let totalTasks): add(totalTasks: totalTasks) case .setTotalTasks(let totalTasks): set(totalTasks: totalTasks) case .addSize(let size): add(size: size) case .setSize(let size): set(size: size) case .addTotalSize(let totalSize): add(totalSize: totalSize) case .setTotalSize(let totalSize): set(totalSize: totalSize) case .addItems(let items): add(items: items) case .setItems(let items): set(items: items) case .addTotalItems(let totalItems): add(totalItems: totalItems) case .setTotalItems(let totalItems): set(totalItems: totalItems) case .custom: // Custom events are handled by the client. break } } } /// Performs a check to see if the progress bar should be finished. public func checkIfFinished() { let state = self.state.withLock { $0 } 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, totalItems > 0 { finished = finished && state.items == totalItems defined = true } if let totalSize = state.totalSize, totalSize > 0 { finished = finished && state.size == totalSize defined = true } if defined && finished { finish() } } /// Sets the current tasks. /// - 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.withLock { $0.tasks = newTasks } if render { self.render() } checkIfFinished() } /// Performs an addition to the current tasks. /// - 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 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.withLock { $0.totalTasks = newTotalTasks } if render { self.render() } } /// Performs an addition to the total tasks. /// - 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 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.withLock { $0.itemsName = newItemsName } if render { self.render() } } /// Sets the current items. /// - Parameter newItems: The current items to set. public func set(items newItems: Int, render: Bool = true) { state.withLock { $0.items = newItems } if render { self.render() } } /// Performs an addition to the current items. /// - 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 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.withLock { $0.totalItems = newTotalItems } if render { self.render() } } /// Performs an addition to the total items. /// - 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 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.withLock { $0.size = newSize } if render { self.render() } } /// Performs an addition to the current size. /// - 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 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.withLock { $0.totalSize = newTotalSize } if render { self.render() } } /// Performs an addition to the total size. /// - 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() } } } ================================================ FILE: Sources/TerminalProgress/ProgressBar+State.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Foundation extension ProgressBar { /// State for the progress bar. struct State { /// A flag indicating whether the progress bar is finished. var finished = false var iteration = 0 private let speedInterval: DispatchTimeInterval = .seconds(1) var description: String var subDescription: String var itemsName: String var tasks: Int var totalTasks: Int? var items: Int var totalItems: Int? private var sizeUpdateTime: DispatchTime? private var sizeUpdateValue: Int64 = 0 var size: Int64 { didSet { calculateSizeSpeed() } } var totalSize: Int64? private var sizeUpdateSpeed: String? var sizeSpeed: String? { guard sizeUpdateTime == nil || sizeUpdateTime! > .now() - speedInterval - speedInterval else { return Int64(0).formattedSizeSpeed(from: startTime) } return sizeUpdateSpeed } var averageSizeSpeed: String { size.formattedSizeSpeed(from: startTime) } var percent: String { var value = 0 if let totalSize, totalSize > 0 { value = Int(size * 100 / totalSize) } else if let totalItems, totalItems > 0 { value = Int(items * 100 / totalItems) } value = min(value, 100) return "\(value)%" } var startTime: DispatchTime var output = "" var renderTask: Task? init( description: String = "", subDescription: String = "", itemsName: String = "", tasks: Int = 0, totalTasks: Int? = nil, items: Int = 0, totalItems: Int? = nil, size: Int64 = 0, totalSize: Int64? = nil, startTime: DispatchTime = .now() ) { self.description = description self.subDescription = subDescription self.itemsName = itemsName self.tasks = tasks self.totalTasks = totalTasks self.items = items self.totalItems = totalItems self.size = size self.totalSize = totalSize self.startTime = startTime } private mutating func calculateSizeSpeed() { if sizeUpdateTime == nil || sizeUpdateTime! < .now() - speedInterval { let partSize = size - sizeUpdateValue let partStartTime = sizeUpdateTime ?? startTime let partSizeSpeed = partSize.formattedSizeSpeed(from: partStartTime) self.sizeUpdateSpeed = partSizeSpeed sizeUpdateTime = .now() sizeUpdateValue = size } } } } ================================================ FILE: Sources/TerminalProgress/ProgressBar+Terminal.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationOS import Foundation enum EscapeSequence { static let hideCursor = "\u{001B}[?25l" static let showCursor = "\u{001B}[?25h" static let moveUp = "\u{001B}[1A" static let clearToEndOfLine = "\u{001B}[K" } extension ProgressBar { var termWidth: Int { guard let terminalHandle = term, let terminal = try? Terminal(descriptor: terminalHandle.fileDescriptor) else { return 0 } return (try? Int(terminal.size.width)) ?? 0 } /// Clears the progress bar and resets the cursor. public func clearAndResetCursor() { state.withLock { s in clear(state: &s) resetCursor() } } /// Clears the progress bar. public func clear() { state.withLock { s in clear(state: &s) } } /// Clears the progress bar (caller must hold state lock). func clear(state: inout State) { displayText("", state: &state) } /// Resets the cursor. public func resetCursor() { display(EscapeSequence.showCursor) } func display(_ text: String) { guard let term else { return } termQueue.sync { try? term.write(contentsOf: Data(text.utf8)) try? term.synchronize() } } func displayText(_ text: String, terminating: String = "\r") { state.withLock { s in displayText(text, state: &s, terminating: terminating) } } func displayText(_ text: String, state: inout State, terminating: String = "\r") { state.output = text // Clears previously printed lines. var lines = "" if terminating.hasSuffix("\r") && termWidth > 0 { let lineCount = (text.count - 1) / termWidth for _ in 0.. let term: FileHandle? let termQueue = DispatchQueue(label: "com.apple.container.ProgressBar") /// Returns `true` if the progress bar has finished. public var isFinished: Bool { state.withLock { $0.finished } } /// Creates a new progress bar. /// - Parameter config: The configuration for the progress bar. public init(config: ProgressConfig) { self.config = config term = isatty(config.terminal.fileDescriptor) == 1 ? config.terminal : nil let state = State( description: config.initialDescription, itemsName: config.initialItemsName, totalTasks: config.initialTotalTasks, totalItems: config.initialTotalItems, totalSize: config.initialTotalSize) self.state = Mutex(state) display(EscapeSequence.hideCursor) } /// Allows resetting the progress state. public func reset() { state.withLock { $0 = State(description: config.initialDescription) } } /// Allows resetting the progress state of the current task. public func resetCurrentTask() { state.withLock { $0 = State(description: $0.description, itemsName: $0.itemsName, tasks: $0.tasks, totalTasks: $0.totalTasks, startTime: $0.startTime) } } /// Updates the description of the progress bar and increments the tasks by one. /// - Parameter description: The description of the action being performed. public func set(description: String) { resetCurrentTask() state.withLock { $0.description = description $0.subDescription = "" $0.tasks += 1 } } /// Updates the additional description of the progress bar. /// - Parameter subDescription: The additional description of the action being performed. public func set(subDescription: String) { resetCurrentTask() state.withLock { $0.subDescription = subDescription } } private func start(intervalSeconds: TimeInterval) async { while true { let done = state.withLock { s -> Bool in guard !s.finished else { return true } render(state: &s) s.iteration += 1 return false } if done { return } let intervalNanoseconds = UInt64(intervalSeconds * 1_000_000_000) guard (try? await Task.sleep(nanoseconds: intervalNanoseconds)) != nil else { return } } } /// Starts an animation of the progress bar. /// - Parameter intervalSeconds: The time interval between updates in seconds. public func start(intervalSeconds: TimeInterval = 0.04) { state.withLock { if $0.renderTask != nil { return } $0.renderTask = Task(priority: .utility) { await start(intervalSeconds: intervalSeconds) } } } /// Finishes the progress bar. /// - Parameter clearScreen: If true, clears the progress bar from the screen. public func finish(clearScreen: Bool = false) { state.withLock { s in guard !s.finished else { return } s.finished = true s.renderTask?.cancel() let shouldClear = clearScreen || config.clearOnFinish if !config.disableProgressUpdates && !shouldClear { let output = draw(state: s) displayText(output, state: &s, terminating: "\n") } if shouldClear { clear(state: &s) } resetCursor() } } } extension ProgressBar { private func secondsSinceStart(from startTime: DispatchTime) -> Int { let timeDifferenceNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds let timeDifferenceSeconds = Int(floor(Double(timeDifferenceNanoseconds) / 1_000_000_000)) return timeDifferenceSeconds } func render(force: Bool = false) { guard term != nil && !config.disableProgressUpdates else { return } state.withLock { s in render(state: &s, force: force) } } func render(state: inout State, force: Bool = false) { guard term != nil && !config.disableProgressUpdates else { return } guard force || !state.finished else { return } let output = draw(state: state) displayText(output, state: &state) } /// Detail levels for progressive truncation. enum DetailLevel: Int, CaseIterable { case full = 0 // Everything shown case noSpeed // Drop speed from parens case noSize // Drop size from parens case noParens // Drop parens entirely (items, size, speed) case noTime // Drop time case noDescription // Drop description/subdescription case minimal // Just spinner, tasks, percent } func draw(state: State) -> String { let width = termWidth // If no terminal or width unknown, use full detail guard width > 0 else { return draw(state: state, detail: .full) } // Add a small buffer to prevent wrapping issues during resize let bufferChars = 4 let targetWidth = max(1, width - bufferChars) for detail in DetailLevel.allCases { let output = draw(state: state, detail: detail) if output.count <= targetWidth { return output } } return draw(state: state, detail: .minimal) } func draw(state: State, detail: DetailLevel) -> String { var components = [String]() // Spinner - always shown if configured (unless using progress bar) if config.showSpinner && !config.showProgressBar { if !state.finished { let spinnerIcon = config.theme.getSpinnerIcon(state.iteration) components.append("\(spinnerIcon)") } else { components.append("\(config.theme.done)") } } // Tasks [x/y] - always shown if configured if config.showTasks, let totalTasks = state.totalTasks { let tasks = min(state.tasks, totalTasks) components.append("[\(tasks)/\(totalTasks)]") } // Description - dropped at noDescription level if detail.rawValue < DetailLevel.noDescription.rawValue { if config.showDescription && !state.description.isEmpty { components.append("\(state.description)") if !state.subDescription.isEmpty { components.append("\(state.subDescription)") } } } let allowProgress = !config.ignoreSmallSize || state.totalSize == nil || state.totalSize! > Int64(1024 * 1024) let value = state.totalSize != nil ? state.size : Int64(state.items) let total = state.totalSize ?? Int64(state.totalItems ?? 0) // Percent - always shown if configured if config.showPercent && total > 0 && allowProgress { components.append("\(state.finished ? "100%" : state.percent)") } // Progress bar - always shown if configured if config.showProgressBar, total > 0, allowProgress { let usedWidth = components.joined(separator: " ").count + 45 let remainingWidth = max(config.width - usedWidth, 1) 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)|") } // Additional components in parens - progressively dropped if detail.rawValue < DetailLevel.noParens.rawValue { var additionalComponents = [String]() // Items - dropped at noParens level if config.showItems, state.items > 0 { var itemsName = "" if !state.itemsName.isEmpty { itemsName = " \(state.itemsName)" } if state.finished { if let totalItems = state.totalItems { additionalComponents.append("\(totalItems.formattedNumber())\(itemsName)") } } else { if let totalItems = state.totalItems { additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)") } else { additionalComponents.append("\(state.items.formattedNumber())\(itemsName)") } } } // Size and speed - progressively dropped if state.size > 0 && allowProgress { if state.finished { // Size - dropped at noSize level if detail.rawValue < DetailLevel.noSize.rawValue { if config.showSize { if let totalSize = state.totalSize { var formattedTotalSize = totalSize.formattedSize() formattedTotalSize = adjustFormattedSize(formattedTotalSize) additionalComponents.append(formattedTotalSize) } } } } else { // Size - dropped at noSize level var formattedCombinedSize = "" if detail.rawValue < DetailLevel.noSize.rawValue && 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 } } // Speed - dropped at noSpeed level var formattedSpeed = "" if detail.rawValue < DetailLevel.noSpeed.rawValue && config.showSpeed { formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)" formattedSpeed = adjustFormattedSize(formattedSpeed) } if !formattedCombinedSize.isEmpty && !formattedSpeed.isEmpty { additionalComponents.append(formattedCombinedSize) additionalComponents.append(formattedSpeed) } else if !formattedCombinedSize.isEmpty { additionalComponents.append(formattedCombinedSize) } else if !formattedSpeed.isEmpty { additionalComponents.append(formattedSpeed) } } } if additionalComponents.count > 0 { let joinedAdditionalComponents = additionalComponents.joined(separator: ", ") components.append("(\(joinedAdditionalComponents))") } } // Time - dropped at noTime level if detail.rawValue < DetailLevel.noTime.rawValue && config.showTime { let timeDifferenceSeconds = secondsSinceStart(from: state.startTime) let formattedTime = timeDifferenceSeconds.formattedTime() components.append("[\(formattedTime)]") } return components.joined(separator: " ") } private func adjustFormattedSize(_ size: String) -> String { // Ensure we always have one digit after the decimal point to prevent flickering. let zero = Int64(0).formattedSize() let decimalSep = Locale.current.decimalSeparator ?? "." guard !size.contains(decimalSep), let first = size.first, first.isNumber || !size.contains(zero) else { return size } var size = size for unit in ["MB", "GB", "TB"] { size = size.replacingOccurrences(of: " \(unit)", with: "\(decimalSep)0 \(unit)") } return size } private func combineSize(size: String, totalSize: String) -> String { let sizeComponents = size.split(separator: " ", maxSplits: 1) let totalSizeComponents = totalSize.split(separator: " ", maxSplits: 1) guard sizeComponents.count == 2, totalSizeComponents.count == 2 else { return "\(size)/\(totalSize)" } let sizeNumber = sizeComponents[0] let sizeUnit = sizeComponents[1] let totalSizeNumber = totalSizeComponents[0] let totalSizeUnit = totalSizeComponents[1] guard sizeUnit == totalSizeUnit else { return "\(size)/\(totalSize)" } return "\(sizeNumber)/\(totalSizeNumber) \(totalSizeUnit)" } func draw() -> String { state.withLock { draw(state: $0) } } } ================================================ FILE: Sources/TerminalProgress/ProgressConfig.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// A configuration for displaying a progress bar. public struct ProgressConfig: Sendable { /// The file handle for progress updates. let terminal: FileHandle /// The initial description of the progress bar. let initialDescription: String /// The initial additional description of the progress bar. let initialSubDescription: String /// 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 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 /// A flag indicating whether to show the description (e.g., "Downloading..."). public let showDescription: Bool /// A flag indicating whether to show a percentage (e.g., "100%"). /// The percentage is hidden when no total size and total items are set. public let showPercent: Bool /// A flag indicating whether to show a progress bar (e.g., "|███ |"). /// The progress bar is hidden when no total size and total items are set. public let showProgressBar: Bool /// A flag indicating whether to show items and total items (e.g., "(22 it)" or "(22/22 it)"). public let showItems: Bool /// A flag indicating whether to show a size and a total size (e.g., "(22 MB)" or "(22/22 MB)"). public let showSize: Bool /// A flag indicating whether to show a speed (e.g., "(4.834 MB/s)"). /// The speed is combined with the size and total size (e.g., "(22/22 MB, 4.834 MB/s)"). /// The speed is hidden when no total size is set. public let showSpeed: Bool /// A flag indicating whether to show the elapsed time (e.g., "[4s]"). public let showTime: Bool /// The flag indicating whether to ignore small size values (less than 1 MB). For example, this may help to avoid reaching 100% after downloading metadata before downloading content. public let ignoreSmallSize: Bool /// The initial total tasks of the progress bar. let initialTotalTasks: Int? /// The initial total size of the progress bar. let initialTotalSize: Int64? /// The initial total items of the progress bar. let initialTotalItems: Int? /// The width of the progress bar in characters. public let width: Int /// The theme of the progress bar. public let theme: ProgressTheme /// 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 /// Creates a new instance of `ProgressConfig`. /// - Parameters: /// - terminal: The file handle for progress updates. The default value is `FileHandle.standardError`. /// - description: The initial description of the progress bar. The default value is `""`. /// - subDescription: The initial additional description of the progress bar. The default value is `""`. /// - itemsName: The initial items name. The default value is `"it"`. /// - showSpinner: A flag indicating whether to show a spinner. The default value is `true`. /// - showTasks: A flag indicating whether to show tasks and total tasks. The default value is `false`. /// - showDescription: A flag indicating whether to show the description. The default value is `true`. /// - showPercent: A flag indicating whether to show a percentage. The default value is `true`. /// - showProgressBar: A flag indicating whether to show a progress bar. The default value is `false`. /// - showItems: A flag indicating whether to show items and a total items. The default value is `false`. /// - showSize: A flag indicating whether to show a size and a total size. The default value is `true`. /// - showSpeed: A flag indicating whether to show a speed. The default value is `true`. /// - showTime: A flag indicating whether to show the elapsed time. The default value is `true`. /// - ignoreSmallSize: A flag indicating whether to ignore small size values. The default value is `false`. /// - totalTasks: The initial total tasks of the progress bar. The default value is `nil`. /// - totalItems: The initial total items of the progress bar. The default value is `nil`. /// - 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 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, description: String = "", subDescription: String = "", itemsName: String = "it", showSpinner: Bool = true, showTasks: Bool = false, showDescription: Bool = true, showPercent: Bool = true, showProgressBar: Bool = false, showItems: Bool = false, showSize: Bool = true, showSpeed: Bool = true, showTime: Bool = true, ignoreSmallSize: Bool = false, totalTasks: Int? = nil, totalItems: Int? = nil, totalSize: Int64? = nil, width: Int = 120, theme: ProgressTheme? = nil, clearOnFinish: Bool = true, disableProgressUpdates: Bool = false ) throws { if let totalTasks { guard totalTasks > 0 else { throw Error.invalid("totalTasks must be greater than zero") } } if let totalItems { guard totalItems > 0 else { throw Error.invalid("totalItems must be greater than zero") } } if let totalSize { guard totalSize > 0 else { throw Error.invalid("totalSize must be greater than zero") } } self.terminal = terminal self.initialDescription = description self.initialSubDescription = subDescription self.initialItemsName = itemsName self.showSpinner = showSpinner self.showTasks = showTasks self.showDescription = showDescription self.showPercent = showPercent self.showProgressBar = showProgressBar self.showItems = showItems self.showSize = showSize self.showSpeed = showSpeed self.showTime = showTime self.ignoreSmallSize = ignoreSmallSize self.initialTotalTasks = totalTasks self.initialTotalItems = totalItems self.initialTotalSize = totalSize self.width = width self.theme = theme ?? DefaultProgressTheme() self.clearOnFinish = clearOnFinish self.disableProgressUpdates = disableProgressUpdates } } extension ProgressConfig { /// An enumeration of errors that can occur when creating a `ProgressConfig`. public enum Error: Swift.Error, CustomStringConvertible { case invalid(String) /// The description of the error. public var description: String { switch self { case .invalid(let reason): return "failed to validate config (\(reason))" } } } } ================================================ FILE: Sources/TerminalProgress/ProgressTaskCoordinator.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// A type that represents a task whose progress is being monitored. public struct ProgressTask: Sendable, Equatable { private var id = UUID() private var coordinator: ProgressTaskCoordinator init(manager: ProgressTaskCoordinator) { self.coordinator = manager } static public func == (lhs: ProgressTask, rhs: ProgressTask) -> Bool { lhs.id == rhs.id } /// Returns `true` if this task is the currently active task, `false` otherwise. public func isCurrent() async -> Bool { guard let currentTask = await coordinator.currentTask else { return false } return currentTask == self } } /// A type that coordinates progress tasks to ignore updates from completed tasks. public actor ProgressTaskCoordinator { var currentTask: ProgressTask? /// Creates an instance of `ProgressTaskCoordinator`. public init() {} /// Returns a new task that should be monitored for progress updates. public func startTask() -> ProgressTask { let newTask = ProgressTask(manager: self) currentTask = newTask return newTask } /// Performs cleanup when the monitored tasks complete. public func finish() { currentTask = nil } /// Returns a handler that updates the progress of a given task. /// - Parameters: /// - task: The task whose progress is being updated. /// - progressUpdate: The handler to invoke when progress updates are received. public static func handler(for task: ProgressTask, from progressUpdate: @escaping ProgressUpdateHandler) -> ProgressUpdateHandler { { events in // Ignore updates from completed tasks. if await task.isCurrent() { await progressUpdate(events) } } } } ================================================ FILE: Sources/TerminalProgress/ProgressTheme.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// /// A theme for progress bar. public protocol ProgressTheme: Sendable { /// The icons used to represent a spinner. var spinner: [String] { get } /// 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 { func getSpinnerIcon(_ iteration: Int) -> String { spinner[iteration % spinner.count] } } ================================================ FILE: Sources/TerminalProgress/ProgressUpdate.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// public enum ProgressUpdateEvent: Sendable { case setDescription(String) case setSubDescription(String) case setItemsName(String) case addTasks(Int) case setTasks(Int) case addTotalTasks(Int) case setTotalTasks(Int) case addItems(Int) case setItems(Int) case addTotalItems(Int) case setTotalItems(Int) case addSize(Int64) case setSize(Int64) case addTotalSize(Int64) case setTotalSize(Int64) case custom(String) } public typealias ProgressUpdateHandler = @Sendable (_ events: [ProgressUpdateEvent]) async -> Void public protocol ProgressAdapter { associatedtype T static func handler(from progressUpdate: ProgressUpdateHandler?) -> (@Sendable ([T]) async -> Void)? } ================================================ FILE: Sources/TerminalProgress/StandardError.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 struct StandardError { func write(_ string: String) { if let data = string.data(using: .utf8) { FileHandle.standardError.write(data) } } } ================================================ FILE: Tests/CLITests/Subcommands/Build/CLIBuildBase.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 @testable import ContainerBuild /* CLIBuildBase is the base class used for creating builder tests. Subtests classes // for these tests are nested in extensions of CLIBuildBase so that we can set // the serialized parallelization attribute across all builder tests. */ @Suite(.serialized) class TestCLIBuildBase: CLITest { override init() throws { try super.init() try? builderDelete(force: true) try builderStart() try waitForBuilderRunning() } deinit { try? builderDelete(force: true) } func waitForBuilderRunning() throws { let buildkitName = "buildkit" try waitForContainerRunning(buildkitName, 10) // exec into buildkit and check if builder-shim is running var attempt = 3 while attempt > 0 { attempt -= 1 do { let response = try doExec(name: buildkitName, cmd: ["pidof", "-s", "container-builder-shim"]) if !response.isEmpty { // found the init process running return } } catch { print("container-builder-shim check failed with \(error)") } sleep(1) } throw CLIError.executionFailed("failed to wait for container-builder-shim process on \(buildkitName)") } func createTempDir() throws -> URL { let tempDir = testDir.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) return tempDir } func createTempFile(suffix: String, contents: Data) throws -> URL { let tempFile = testDir.appendingPathComponent(UUID().uuidString + suffix) try contents.write(to: tempFile, options: .atomic) return tempFile } func createContext(tempDir: URL, dockerfile: String, context: [FileSystemEntry]? = nil) throws { let dockerfileBytes = dockerfile.data(using: .utf8)! try dockerfileBytes.write(to: tempDir.appendingPathComponent("Dockerfile"), options: .atomic) let contextDir: URL = tempDir.appendingPathComponent("context").absoluteURL try FileManager.default.createDirectory(at: contextDir, withIntermediateDirectories: true, attributes: nil) if let context { for entry in context { try createEntry(entry, contextDir) } } } @discardableResult func build( tag: String, tempDir: URL, buildArgs: [String] = [], otherArgs: [String] = [] ) throws -> String { try buildWithPaths( tags: [tag], tempContext: tempDir, tempDockerfileContext: tempDir, buildArgs: buildArgs, otherArgs: otherArgs ) } @discardableResult func build( tags: [String], tempDir: URL, buildArgs: [String] = [], otherArgs: [String] = [] ) throws -> String { try buildWithPaths( tags: tags, tempContext: tempDir, tempDockerfileContext: tempDir, buildArgs: buildArgs, otherArgs: otherArgs ) } // buildWithPaths is a helper function for calling build with different paths for the build context and // the dockerfile path. If both paths are the same, use `build` func above. @discardableResult func buildWithPaths( tags: [String], tempContext: URL, tempDockerfileContext: URL, buildArgs: [String] = [], otherArgs: [String] = [] ) throws -> String { let contextDir: URL = tempContext.appendingPathComponent("context") let contextDirPath = contextDir.absoluteURL.path var args = [ "build", "-f", tempDockerfileContext.appendingPathComponent("Dockerfile").path, ] for tag in tags { args.append("-t") args.append(tag) } for arg in buildArgs { args.append("--build-arg") args.append(arg) } args.append(contextDirPath) args.append(contentsOf: otherArgs) let response = try run(arguments: args) if response.status != 0 { throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") } return response.output } @discardableResult func buildWithStdin( tags: [String], tempContext: URL, dockerfileContents: String, buildArgs: [String] = [], otherArgs: [String] = [] ) throws -> String { let contextDir: URL = tempContext.appendingPathComponent("context") let contextDirPath = contextDir.absoluteURL.path var args = [ "build", "-f", "-", ] for tag in tags { args.append("-t") args.append(tag) } for arg in buildArgs { args.append("--build-arg") args.append(arg) } args.append(contextDirPath) args.append(contentsOf: otherArgs) let stdinData = Data(dockerfileContents.utf8) let response = try run(arguments: args, stdin: stdinData) if response.status != 0 { throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") } return response.output } enum FileSystemEntry { case file( _ path: String, content: FileEntryContent, permissions: FilePermissions = [.r, .w, .gr, .gw, .or, .ow], uid: uid_t = 0, gid: gid_t = 0 ) case directory( _ path: String, permissions: FilePermissions = [.r, .w, .x, .gr, .gw, .gx, .or, .ow, .ox], uid: uid_t = 0, gid: gid_t = 0 ) case symbolicLink( _ path: String, target: String, uid: uid_t = 0, gid: gid_t = 0 ) } func createEntry(_ entry: FileSystemEntry, _ contextDir: URL) throws { switch entry { // last 2 params are uid and gid case .file(let path, let content, let permissions, _, _): let fullPath = contextDir.appending(path: path) // not using .absoluteURL deletes the last component from fullPath let directory: URL = fullPath.absoluteURL.deletingLastPathComponent() let contentPath = fullPath.path try FileManager.default.createDirectory( atPath: directory.path, withIntermediateDirectories: true, attributes: nil ) switch content { case .data(let data): try data.write(to: fullPath) case .zeroFilled(let size): let fd = open(contentPath, O_CREAT | O_WRONLY, permissions.rawValue) if fd == -1 { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } defer { close(fd) } ftruncate(fd, off_t(size)) } // TODO: figure out why this block fails // try FileManager.default.setAttributes( // [ // .posixPermissions: Int(permissions.rawValue), // .ownerAccountID: uid, // .groupOwnerAccountID: gid, // ], // ofItemAtPath: fullPath.absoluteURL.absoluteString // ) case .directory(let path, let permissions, let uid, let gid): let fullPath = contextDir.appendingPathComponent(path).absoluteURL try FileManager.default.createDirectory( atPath: fullPath.path, withIntermediateDirectories: true, attributes: [ .posixPermissions: Int(permissions.rawValue), .ownerAccountID: uid, .groupOwnerAccountID: gid, ] ) case .symbolicLink(let path, let target, let uid, let gid): let fullPath = contextDir.appendingPathComponent(path).absoluteURL let directory: URL = fullPath.deletingLastPathComponent() try FileManager.default.createDirectory( atPath: directory.path, withIntermediateDirectories: true, attributes: nil ) let targetURL = contextDir.appendingPathComponent(target) try FileManager.default.createSymbolicLink( atPath: fullPath.path, withDestinationPath: targetURL.relativePathFrom(from: fullPath) ) lchown(fullPath.path, uid, gid) } } struct FilePermissions: OptionSet { let rawValue: UInt16 static let r = FilePermissions(rawValue: 0o400) static let w = FilePermissions(rawValue: 0o200) static let x = FilePermissions(rawValue: 0o100) static let gr = FilePermissions(rawValue: 0o040) static let gw = FilePermissions(rawValue: 0o020) static let gx = FilePermissions(rawValue: 0o010) static let or = FilePermissions(rawValue: 0o004) static let ow = FilePermissions(rawValue: 0o002) static let ox = FilePermissions(rawValue: 0o001) } enum FileEntryContent { case zeroFilled(size: Int64) case data(Data) } func builderStart(cpus: Int64 = 2, memoryInGBs: Int64 = 2) throws { let (_, _, error, status) = try run(arguments: [ "builder", "start", "-c", "\(cpus)", "-m", "\(memoryInGBs)GB", ]) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } func builderStop() throws { let (_, _, error, status) = try run(arguments: [ "builder", "stop", ]) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } func builderDelete(force: Bool = false) throws { let (_, _, error, status) = try run( arguments: [ "builder", "delete", force ? "--force" : nil, ].compactMap { $0 }) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } } ================================================ FILE: Tests/CLITests/Subcommands/Build/CLIBuilderEnvOnlyTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 extension TestCLIBuildBase { class CLIBuilderEnvOnlyTest: TestCLIBuildBase { override init() throws { try super.init() } deinit { try? builderDelete(force: true) } @Test func testBuildEnvironmentOnlyImageFromScratch() throws { let tempDir: URL = try createTempDir() let dockerfile = """ FROM scratch ARG BUILD_DATE ARG VERSION=1.0.0 ENV TERM=xterm \\ BUILD_DATE=${BUILD_DATE} \\ APP_VERSION=${VERSION} \\ PATH=/usr/local/bin:/usr/bin:/bin LABEL maintainer="test@example.com" \\ version="${VERSION}" """ try createContext(tempDir: tempDir, dockerfile: dockerfile) let imageName = "test-env-only:\(UUID().uuidString)" try self.build(tag: imageName, tempDir: tempDir, buildArgs: ["BUILD_DATE=2025-01-01", "VERSION=2.0.0"]) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @Test func testBuildEnvironmentOnlyImageFromAlpine() throws { let tempDir: URL = try createTempDir() let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 ENV APP_NAME=myapp \\ APP_VERSION=1.0.0 \\ APP_ENV=production LABEL maintainer="test@example.com" \\ version="1.0.0" \\ description="Test environment-only image" """ try createContext(tempDir: tempDir, dockerfile: dockerfile) let imageName = "test-alpine-env:\(UUID().uuidString)" try self.build(tag: imageName, tempDir: tempDir) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @Test func testMultiStageBuildWithEnvOnlyBase() throws { let tempDir: URL = try createTempDir() let baseImageName = "test-env-base:\(UUID().uuidString)" // First, create an environment-only base image let baseDockerfile = """ FROM scratch ARG JOBS=6 ARG ARCH=amd64 ENV MAKEOPTS="-j${JOBS}" \\ ARCH="${ARCH}" \\ PATH=/usr/local/bin:/usr/bin """ try createContext(tempDir: tempDir, dockerfile: baseDockerfile) try self.build(tag: baseImageName, tempDir: tempDir, buildArgs: ["JOBS=8", "ARCH=arm64"]) #expect(try self.inspectImage(baseImageName) == baseImageName, "expected base image to build successfully") // Now create a downstream image that uses it let downstreamTempDir: URL = try createTempDir() let downstreamDockerfile = """ FROM \(baseImageName) # Verify environment is inherited - note: can't use RUN with scratch base LABEL test="env-inherited" """ try createContext(tempDir: downstreamTempDir, dockerfile: downstreamDockerfile) let downstreamImageName = "test-env-child:\(UUID().uuidString)" try self.build(tag: downstreamImageName, tempDir: downstreamTempDir) #expect( try self.inspectImage(downstreamImageName) == downstreamImageName, "expected downstream image to build successfully" ) } @Test func testComplexArgAndEnvCombinations() throws { let tempDir: URL = try createTempDir() let dockerfile = """ FROM scratch ARG JOBS=6 ARG MAXLOAD=7.00 ARG ARCH=amd64 ARG PROFILE_PATH=23.0/split-usr/no-multilib ARG CHOST=x86_64-pc-linux-gnu ARG CFLAGS=-O2 -pipe ENV JOBS="${JOBS}" \\ MAXLOAD="${MAXLOAD}" \\ GENTOO_PROFILE="default/linux/${ARCH}/${PROFILE_PATH}" \\ CHOST="${CHOST}" \\ MAKEOPTS="-j${JOBS}" \\ CFLAGS="${CFLAGS}" \\ CXXFLAGS="${CFLAGS}" LABEL maintainer="test@example.com" """ try createContext(tempDir: tempDir, dockerfile: dockerfile) let imageName = "test-complex-env:\(UUID().uuidString)" try self.build(tag: imageName, tempDir: tempDir, buildArgs: ["JOBS=12", "ARCH=arm64"]) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @Test func testLabelOnlyDockerfile() throws { let tempDir: URL = try createTempDir() let dockerfile = """ FROM scratch LABEL maintainer="test@example.com" \\ version="1.0.0" \\ description="Test image with only labels" \\ org.opencontainers.image.title="Test Image" """ try createContext(tempDir: tempDir, dockerfile: dockerfile) let imageName = "test-label-only:\(UUID().uuidString)" try self.build(tag: imageName, tempDir: tempDir) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } } } ================================================ FILE: Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 extension TestCLIBuildBase { class CLIBuilderLifecycleTest: TestCLIBuildBase { override init() throws {} @Test func testBuilderStartStopCommand() throws { #expect(throws: Never.self) { 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 self.builderStop() let status = try self.getContainerStatus("buildkit") #expect(status == "stopped", "BuildKit container is not stopped") } } @Test func testBuilderEnvironmentColors() throws { let testColors = "run=green:warning=yellow:error=red:cancel=cyan" let testNoColor = "true" let originalColors = ProcessInfo.processInfo.environment["BUILDKIT_COLORS"] let originalNoColor = ProcessInfo.processInfo.environment["NO_COLOR"] defer { if let originalColors { setenv("BUILDKIT_COLORS", originalColors, 1) } else { unsetenv("BUILDKIT_COLORS") } if let originalNoColor { setenv("NO_COLOR", originalNoColor, 1) } else { unsetenv("NO_COLOR") } try? builderStop() try? builderDelete(force: true) } setenv("BUILDKIT_COLORS", testColors, 1) setenv("NO_COLOR", testNoColor, 1) try? builderStop() try? builderDelete(force: true) let (_, _, err, status) = try run(arguments: ["builder", "start"]) try #require(status == 0, "builder start failed: \(err)") try waitForBuilderRunning() let container = try inspectContainer("buildkit") let envVars = container.configuration.initProcess.environment #expect( envVars.contains("BUILDKIT_COLORS=\(testColors)"), "Expected BUILDKIT_COLORS to be passed to container, but it was missing from env: \(envVars)" ) #expect( envVars.contains("NO_COLOR=\(testNoColor)"), "Expected NO_COLOR to be passed to container, but it was missing from env: \(envVars)" ) } } } ================================================ FILE: Tests/CLITests/Subcommands/Build/CLIBuilderLocalOutputTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 extension TestCLIBuildBase { class CLIBuilderLocalOutputTest: TestCLIBuildBase { override init() throws { try super.init() } deinit { try? builderDelete(force: true) } @Test func testBuildLocalOutputHappyPath() throws { let tempDir: URL = try createTempDir() // Test comprehensive multi-stage build with context and build arguments let dockerfile: String = """ ARG MESSAGE=default FROM scratch AS builder ADD build.txt /build.txt ADD testfile.txt /hello.txt FROM scratch COPY --from=builder /build.txt /final.txt COPY --from=builder /hello.txt /app/hello.txt ADD message.txt /message.txt """ let context: [FileSystemEntry] = [ .file("build.txt", content: .data("Building stage\n".data(using: .utf8)!)), .file("testfile.txt", content: .data("Hello from local build\n".data(using: .utf8)!)), .file("message.txt", content: .data("Hello from build args\n".data(using: .utf8)!)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let outputDir = tempDir.appendingPathComponent("comprehensive-local-output") let imageName = "local-comprehensive-test:\(UUID().uuidString)" let response = try buildWithLocalOutput( tag: imageName, tempDir: tempDir, outputDir: outputDir, args: ["MESSAGE=Hello from build args"] ) // Verify the build succeeded #expect(response.contains("Successfully exported to"), "Expected successful local export message") // Verify the output directory was created #expect(FileManager.default.fileExists(atPath: outputDir.path), "Expected local output directory to exist") // Verify the output contains expected structure let contents = try FileManager.default.contentsOfDirectory(atPath: outputDir.path) #expect(!contents.isEmpty, "Expected local output directory to contain files") // Test basic functionality - verify basic local output works let basicTempDir: URL = try createTempDir() let basicDockerfile: String = """ FROM scratch ADD testfile.txt /hello.txt """ let basicContext: [FileSystemEntry] = [ .file("testfile.txt", content: .data("Hello from basic build\n".data(using: .utf8)!)) ] try createContext(tempDir: basicTempDir, dockerfile: basicDockerfile, context: basicContext) let basicOutputDir = basicTempDir.appendingPathComponent("basic-local-output") let basicImageName = "local-basic-test:\(UUID().uuidString)" let basicResponse = try buildWithLocalOutput(tag: basicImageName, tempDir: basicTempDir, outputDir: basicOutputDir) // Verify basic build succeeded #expect(basicResponse.contains("Successfully exported to"), "Expected successful basic local export message") #expect(FileManager.default.fileExists(atPath: basicOutputDir.path), "Expected basic local output directory to exist") // Test context functionality - verify COPY works with context let contextTempDir: URL = try createTempDir() let contextDockerfile: String = """ FROM scratch COPY testfile.txt /app/testfile.txt """ let contextContext: [FileSystemEntry] = [ .file("testfile.txt", content: .data("Test content for context build\n".data(using: .utf8)!)) ] try createContext(tempDir: contextTempDir, dockerfile: contextDockerfile, context: contextContext) let contextOutputDir = contextTempDir.appendingPathComponent("context-local-output") let contextImageName = "local-context-test:\(UUID().uuidString)" let contextResponse = try buildWithLocalOutput(tag: contextImageName, tempDir: contextTempDir, outputDir: contextOutputDir) // Verify context build succeeded #expect(contextResponse.contains("Successfully exported to"), "Expected successful context local export message") #expect(FileManager.default.fileExists(atPath: contextOutputDir.path), "Expected context local output directory to exist") } @Test func testBuildLocalOutputEdgeCases() throws { // Test building with different context paths let dockerfileCtxDir: URL = try createTempDir() let dockerfile: String = """ FROM scratch COPY . /app """ let dockerfileCtx: [FileSystemEntry] = [ .file("dockerfile-context.txt", content: .data("Dockerfile context file\n".data(using: .utf8)!)) ] try createContext(tempDir: dockerfileCtxDir, dockerfile: dockerfile, context: dockerfileCtx) let buildContextDir: URL = try createTempDir() let buildContext: [FileSystemEntry] = [ .file("build-context.txt", content: .data("Build context file\n".data(using: .utf8)!)) ] try createContext(tempDir: buildContextDir, dockerfile: "", context: buildContext) let outputDir = dockerfileCtxDir.appendingPathComponent("diffpaths-local-output") let imageName = "local-diffpaths-test:\(UUID().uuidString)" let response = try buildWithPathsAndLocalOutput( tag: imageName, tempContext: buildContextDir, tempDockerfileContext: dockerfileCtxDir, outputDir: outputDir ) // Verify the build succeeded #expect(response.contains("Successfully exported to"), "Expected successful local export message") // Verify the output directory exists #expect(FileManager.default.fileExists(atPath: outputDir.path), "Expected local output directory to exist") // Test building to existing output directory let existingTempDir: URL = try createTempDir() let existingDockerfile: String = """ FROM scratch ADD newfile.txt /newfile.txt """ let existingContext: [FileSystemEntry] = [ .file("newfile.txt", content: .data("New content from build\n".data(using: .utf8)!)) ] try createContext(tempDir: existingTempDir, dockerfile: existingDockerfile, context: existingContext) let existingOutputDir = existingTempDir.appendingPathComponent("existing-output") // Create the output directory and add some existing files try FileManager.default.createDirectory(at: existingOutputDir, withIntermediateDirectories: true) let existingFile = existingOutputDir.appendingPathComponent("existing.txt") try "Existing file content\n".data(using: .utf8)!.write(to: existingFile) let existingImageName = "local-existing-test:\(UUID().uuidString)" let existingResponse = try buildWithLocalOutput(tag: existingImageName, tempDir: existingTempDir, outputDir: existingOutputDir) // Verify the build succeeded #expect(existingResponse.contains("Successfully exported to"), "Expected successful local export message") // Verify the output directory exists #expect(FileManager.default.fileExists(atPath: existingOutputDir.path), "Expected local output directory to exist") // Verify the existing file is still there (local output should merge/overwrite) let contents = try FileManager.default.contentsOfDirectory(atPath: existingOutputDir.path) #expect(!contents.isEmpty, "Expected local output directory to contain files") // The behavior may vary - local output might overwrite the directory or merge contents // This test verifies that the operation completes successfully with an existing directory } @Test func testBuildLocalOutputFailure() throws { let tempDir: URL = try createTempDir() let dockerfile: String = """ FROM scratch ADD test.txt /test.txt """ let context: [FileSystemEntry] = [ .file("test.txt", content: .data("test\n".data(using: .utf8)!)) ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) // Use a path that doesn't exist and can't be created (invalid parent) let invalidOutputDir = URL(fileURLWithPath: "/nonexistent/invalid/path") let imageName = "local-invalid-test:\(UUID().uuidString)" #expect(throws: CLIError.self) { try buildWithLocalOutput(tag: imageName, tempDir: tempDir, outputDir: invalidOutputDir) } } // Helper function to build with local output @discardableResult func buildWithLocalOutput(tag: String, tempDir: URL, outputDir: URL, args: [String]? = nil) throws -> String { try buildWithPathsAndLocalOutput( tag: tag, tempContext: tempDir, tempDockerfileContext: tempDir, outputDir: outputDir, args: args ) } // Helper function to build with different paths and local output @discardableResult func buildWithPathsAndLocalOutput( tag: String, tempContext: URL, tempDockerfileContext: URL, outputDir: URL, args: [String]? = nil ) throws -> String { let contextDir: URL = tempContext.appendingPathComponent("context") let contextDirPath = contextDir.absoluteURL.path var buildArgs = [ "build", "-f", tempDockerfileContext.appendingPathComponent("Dockerfile").path, "-t", tag, "--output", "type=local,dest=\(outputDir.path)", ] if let args = args { for arg in args { buildArgs.append("--build-arg") buildArgs.append(arg) } } buildArgs.append(contextDirPath) let response = try run(arguments: buildArgs) if response.status != 0 { throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") } return response.output } } } ================================================ FILE: Tests/CLITests/Subcommands/Build/CLIBuilderTarExportTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 extension TestCLIBuildBase { class CLIBuilderTarExportTest: TestCLIBuildBase { override init() throws { try super.init() } deinit { try? builderDelete(force: true) } @Test func testBuildExportTar() throws { let tempDir: URL = try createTempDir() let dockerfile: String = """ FROM scratch ADD emptyFile / """ let context: [FileSystemEntry] = [ .file("emptyFile", content: .zeroFilled(size: 1)) ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let exportPath = tempDir.appendingPathComponent("export.tar") let response = try run(arguments: [ "build", "-f", tempDir.appendingPathComponent("Dockerfile").path, "-o", "type=tar,dest=\(exportPath.path)", tempDir.appendingPathComponent("context").path, ]) #expect(response.status == 0, "build with tar export should succeed") #expect(FileManager.default.fileExists(atPath: exportPath.path), "tar file should exist at \(exportPath.path)") #expect(response.output.contains("Successfully exported to \(exportPath.path)"), "should show export success message") let attributes = try FileManager.default.attributesOfItem(atPath: exportPath.path) let fileSize = attributes[.size] as? Int ?? 0 #expect(fileSize > 0, "exported tar file should not be empty") } @Test func testBuildExportTarToDirectory() throws { let tempDir: URL = try createTempDir() let dockerfile: String = """ FROM ghcr.io/linuxcontainers/alpine:3.20 RUN echo "test content" > /test.txt """ try createContext(tempDir: tempDir, dockerfile: dockerfile) let exportDir = tempDir.appendingPathComponent("exports") try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true) let response = try run(arguments: [ "build", "-f", tempDir.appendingPathComponent("Dockerfile").path, "-o", "type=tar,dest=\(exportDir.path)", tempDir.appendingPathComponent("context").path, ]) #expect(response.status == 0, "build with tar export to directory should succeed") let expectedTar = exportDir.appendingPathComponent("out.tar") #expect(FileManager.default.fileExists(atPath: expectedTar.path), "tar file should exist at \(expectedTar.path)") #expect(response.output.contains("Successfully exported to \(expectedTar.path)"), "should show export success message") } @Test func testBuildExportTarMultipleRuns() throws { let tempDir: URL = try createTempDir() let dockerfile: String = """ FROM scratch ADD testFile / """ let context: [FileSystemEntry] = [ .file("testFile", content: .data("test data".data(using: .utf8)!)) ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let exportDir = tempDir.appendingPathComponent("exports") try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true) // First build var response = try run(arguments: [ "build", "-f", tempDir.appendingPathComponent("Dockerfile").path, "-o", "type=tar,dest=\(exportDir.path)", tempDir.appendingPathComponent("context").path, ]) #expect(response.status == 0, "first build should succeed") let firstTar = exportDir.appendingPathComponent("out.tar") #expect(FileManager.default.fileExists(atPath: firstTar.path), "first tar should exist") // Second build - should create out.tar.1 response = try run(arguments: [ "build", "-f", tempDir.appendingPathComponent("Dockerfile").path, "-o", "type=tar,dest=\(exportDir.path)", tempDir.appendingPathComponent("context").path, ]) #expect(response.status == 0, "second build should succeed") let secondTar = exportDir.appendingPathComponent("out.tar.1") #expect(FileManager.default.fileExists(atPath: secondTar.path), "second tar should exist at out.tar.1") } @Test func testBuildExportTarInvalidDest() throws { let tempDir: URL = try createTempDir() let dockerfile: String = """ FROM scratch """ try createContext(tempDir: tempDir, dockerfile: dockerfile) let response = try run(arguments: [ "build", "-f", tempDir.appendingPathComponent("Dockerfile").path, "-o", "type=tar", // Missing dest parameter tempDir.appendingPathComponent("context").path, ]) #expect(response.status != 0, "build without dest should fail") #expect(response.error.contains("dest field is required"), "error should mention missing dest") } } } ================================================ FILE: Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationOCI import Foundation import Testing extension TestCLIBuildBase { class CLIBuilderTest: TestCLIBuildBase { override init() throws { try super.init() } deinit { try? builderDelete(force: true) } @Test func testBuildDotFileSucceeds() throws { let tempDir: URL = try createTempDir() let dockerfile: String = """ FROM scratch ADD emptyFile / """ let context: [FileSystemEntry] = [ .file("emptyFile", content: .zeroFilled(size: 1)), .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let imageName = "registry.local/dot-file:\(UUID().uuidString)" try self.build(tag: imageName, tempDir: tempDir) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @Test func testBuildFromPreviousStage() throws { let tempDir: URL = try createTempDir() let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 AS layer1 RUN sh -c "echo 'layer1' > /layer1.txt" FROM layer1 CMD ["cat", "/layer1.txt"] """ try createContext(tempDir: tempDir, dockerfile: dockerfile) let imageName = "registry.local/from-previous-layer:\(UUID().uuidString)" try self.build(tag: imageName, tempDir: tempDir) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully build \(imageName)") } @Test func testBuildFromLocalImage() throws { let tempDir: URL = try createTempDir() let dockerfile: String = """ FROM scratch ADD emptyFile / """ let context: [FileSystemEntry] = [ .file("emptyFile", content: .zeroFilled(size: 0)), .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let imageName = "local-only:\(UUID().uuidString)" try self.build(tag: imageName, tempDir: tempDir) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") let newTempDir: URL = try createTempDir() let newDockerfile: String = """ FROM \(imageName) """ let newContext: [FileSystemEntry] = [] try createContext(tempDir: newTempDir, dockerfile: newDockerfile, context: newContext) let newImageName = "from-local:\(UUID().uuidString)" try self.build(tag: newImageName, tempDir: newTempDir) #expect(try self.inspectImage(newImageName) == newImageName, "expected to have successfully built \(newImageName)") } @Test func testBuildAddFromSpecialDirs() throws { let tempDir = URL(filePath: "/tmp/container/.clitests/\(testSuite)/\(testName)") try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try! FileManager.default.removeItem(at: tempDir) } let dockerfile: String = """ FROM scratch ADD emptyFile / """ let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let imageName = "registry.local/scratch-add-special-dir:\(UUID().uuidString)" try self.build(tag: imageName, tempDir: tempDir) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @Test func testBuildScratchAdd() throws { let tempDir: URL = try createTempDir() let dockerfile: String = """ FROM scratch ADD emptyFile / """ let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) 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)") } @Test func testBuildAddAll() throws { let tempDir: URL = try createTempDir() let dockerfile: String = """ FROM ghcr.io/linuxcontainers/alpine:3.20 ADD . . RUN cat emptyFile RUN cat Test/testempty """ let context: [FileSystemEntry] = [ .directory("Test"), .file("Test/testempty", content: .zeroFilled(size: 1)), .file("emptyFile", content: .zeroFilled(size: 1)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) 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, buildArgs: ["TAG=3.20"]) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @Test func testBuildSecret() throws { let tempDir: URL = try createTempDir() let dockerfile: String = """ FROM ghcr.io/linuxcontainers/alpine:3.20 RUN --mount=type=secret,id=ENV1 \ --mount=type=secret,id=env2 \ --mount=type=secret,id=env3 \ test xyyzzz = "`cat /run/secrets/ENV1 /run/secrets/env2 /run/secrets/env3`" RUN --mount=type=secret,id=file \ awk 'BEGIN {for(i=0; i<17; i++) for(c=0; c<256; c++) printf("%c", c)}' > /tmp/foo && \ cmp /tmp/foo /run/secrets/file && \ rm /tmp/foo RUN --mount=type=secret,id=empty \ test \\! -e /run/secrets/file && \ test -e /run/secrets/empty && \ cmp /dev/null /run/secrets/empty """ try createContext(tempDir: tempDir, dockerfile: dockerfile) setenv("ENV1", "x", 1) setenv("ENV_VAR", "yy", 1) setenv("env3", "zzz", 1) let testData = Data((0..<17).flatMap { _ in Array(0...255) }) let tempFile: URL = try createTempFile(suffix: " _f,i=l.e+ ", contents: testData) let tempFile2: URL = try createTempFile(suffix: "file2", contents: Data()) let imageName: String = "registry.local/secrets:\(UUID().uuidString)" try self.build( tag: imageName, tempDir: tempDir, otherArgs: [ "--secret", "id=ENV1", "--secret", "id=env2,env=ENV_VAR", "--secret", "id=env3,env=env3", "--secret", "id=file,src=" + tempFile.path, "--secret", "id=empty,src=" + tempFile2.path, ]) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @Test func testBuildNetworkAccess() throws { let tempDir: URL = try createTempDir() let dockerfile: String = """ FROM ghcr.io/linuxcontainers/alpine:3.20 ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY ARG http_proxy ARG https_proxy ARG no_proxy RUN apk add --no-cache curl """ try createContext(tempDir: tempDir, dockerfile: dockerfile) let imageName = "registry.local/build-network-access:\(UUID().uuidString)" var buildArgs: [String] = [] for key in ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] { if let value = ProcessInfo.processInfo.environment[key] { buildArgs.append("\(key)=\(value)") } } try self.build(tag: imageName, tempDir: tempDir, buildArgs: buildArgs) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @Test func testBuildDockerfileKeywords() throws { let tempDir: URL = try createTempDir() let dockerfile = """ # stage 1 Meta ARG ARG TAG=3.20 FROM ghcr.io/linuxcontainers/alpine:${TAG} # stage 2 RUN FROM ghcr.io/linuxcontainers/alpine:3.20 RUN echo "Hello, World!" > /hello.txt # stage 3 - RUN [] FROM ghcr.io/linuxcontainers/alpine:3.20 RUN ["sh", "-c", "echo 'Exec form' > /exec.txt"] # stage 4 - CMD FROM ghcr.io/linuxcontainers/alpine:3.20 CMD ["echo", "Exec default"] # stage 5 - CMD [] FROM ghcr.io/linuxcontainers/alpine:3.20 CMD ["echo", "Exec'ing"] #stage 6 - LABEL FROM ghcr.io/linuxcontainers/alpine:3.20 LABEL version="1.0" description="Test image" # stage 7 - EXPOSE FROM ghcr.io/linuxcontainers/alpine:3.20 EXPOSE 8080 # stage 8 - ENV FROM ghcr.io/linuxcontainers/alpine:3.20 ENV MY_ENV=hello RUN echo $MY_ENV > /env.txt # stage 9 - ADD FROM ghcr.io/linuxcontainers/alpine:3.20 ADD emptyFile / # stage 10 - COPY FROM ghcr.io/linuxcontainers/alpine:3.20 COPY toCopy /toCopy # stage 11 - ENTRYPOINT FROM ghcr.io/linuxcontainers/alpine:3.20 ENTRYPOINT ["echo", "entrypoint!"] # stage 12 - VOLUME FROM ghcr.io/linuxcontainers/alpine:3.20 VOLUME /data # stage 13 - USER FROM ghcr.io/linuxcontainers/alpine:3.20 RUN adduser -D myuser USER myuser CMD whoami # stage 14 - WORKDIR FROM ghcr.io/linuxcontainers/alpine:3.20 WORKDIR /app RUN pwd > /pwd.out # stage 15 - ARG FROM ghcr.io/linuxcontainers/alpine:3.20 ARG MY_VAR=default RUN echo $MY_VAR > /var.out # stage 16 - ONBUILD # FROM ghcr.io/linuxcontainers/alpine:3.20 # ONBUILD RUN echo "onbuild triggered" > /onbuild.out # stage 17 - STOPSIGNAL # FROM ghcr.io/linuxcontainers/alpine:3.20 # STOPSIGNAL SIGTERM # stage 18 - HEALTHCHECK # FROM ghcr.io/linuxcontainers/alpine:3.20 # HEALTHCHECK CMD echo "healthy" || exit 1 # stage 19 - SHELL # FROM ghcr.io/linuxcontainers/alpine:3.20 # SHELL ["/bin/sh", "-c"] # RUN echo $0 > /shell.txt """ let context: [FileSystemEntry] = [ .file("emptyFile", content: .zeroFilled(size: 1)), .file("toCopy", content: .zeroFilled(size: 1)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) 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)") } @Test func testBuildSymlink() throws { let tempDir: URL = try createTempDir() let dockerfile: String = """ # Test 1: Test basic symlinking FROM ghcr.io/linuxcontainers/alpine:3.20 ADD Test1Source Test1Source ADD Test1Source2 Test1Source2 RUN cat Test1Source2/test.yaml # Test2: Test symlinks in nested directories FROM ghcr.io/linuxcontainers/alpine:3.20 ADD Test2Source Test2Source ADD Test2Source2 Test2Source2 RUN cat Test2Source2/Test/test.txt # Test 3: Test symlinks to directories work FROM ghcr.io/linuxcontainers/alpine:3.20 ADD Test3Source Test3Source ADD Test3Source2 Test3Source2 RUN cat Test3Source2/Dest/test.txt """ let context: [FileSystemEntry] = [ // test 1 .directory("Test1Source"), .directory("Test1Source2"), .file("Test1Source/test.yaml", content: .zeroFilled(size: 1)), .symbolicLink("Test1Source2/test.yaml", target: "Test1Source/test.yaml"), // test 2 .directory("Test2Source"), .directory("Test2Source2"), .file("Test2Source/Test/Test/test.yaml", content: .zeroFilled(size: 1)), .symbolicLink("Test2Source2/Test/test.yaml", target: "Test2Source/Test/Test/test.yaml"), // test 3 .directory("Test3Source/Source"), .directory("Test3Source2"), .file("Test3Source/Source/test.txt", content: .zeroFilled(size: 1)), .symbolicLink("Test3Source2/Dest", target: "Test3Source/Source"), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let imageName = "registry.local/build-symlinks:\(UUID().uuidString)" #expect(throws: Never.self) { try self.build(tag: imageName, tempDir: tempDir) } #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @Test func testBuildAndRun() throws { let name: String = "test-build-and-run" let tempDir: URL = try createTempDir() let dockerfile: String = """ FROM ghcr.io/linuxcontainers/alpine:3.20 RUN echo "foobar" > /file """ let context: [FileSystemEntry] = [] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let imageName = "\(name):latest" let containerName = "\(name)-container" try self.build(tag: imageName, tempDir: tempDir) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") // Check if the image we built is actually in the image store, and can be used. try self.doLongRun(name: containerName, image: imageName) defer { try? self.doStop(name: containerName) } var output = try doExec(name: containerName, cmd: ["cat", "/file"]) output = output.trimmingCharacters(in: .whitespacesAndNewlines) let expected = "foobar" try self.doStop(name: containerName) #expect(output == expected, "expected file contents to be \(expected), instead got \(output)") } @Test func testBuildDifferentPaths() throws { let buildContextDir: URL = try createTempDir() let dockerfile: String = """ FROM ghcr.io/linuxcontainers/alpine:3.20 RUN ls ./ COPY . /root RUN cat /root/Test/test.txt """ let buildContext: [FileSystemEntry] = [ .directory(".git"), .file(".git/FETCH", content: .zeroFilled(size: 1)), .directory("Test"), .file("Test/test.txt", content: .zeroFilled(size: 1)), ] try createContext(tempDir: buildContextDir, dockerfile: dockerfile, context: buildContext) let imageName = "registry.local/build-diff-context:\(UUID().uuidString)" #expect(throws: Never.self) { try self.build(tags: [imageName], tempDir: buildContextDir) } #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @Test func testBuildMultiArch() throws { let tempDir: URL = try createTempDir() let dockerfile: String = """ FROM ghcr.io/linuxcontainers/alpine:3.20 ADD . . RUN cat emptyFile RUN cat Test/testempty """ let context: [FileSystemEntry] = [ .directory("Test"), .file("Test/testempty", content: .zeroFilled(size: 1)), .file("emptyFile", content: .zeroFilled(size: 1)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let imageName: String = "registry.local/multi-arch:\(UUID().uuidString)" try self.build(tag: imageName, tempDir: tempDir, otherArgs: ["--arch", "amd64,arm64"]) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") let output = try doInspectImages(image: imageName) #expect(output.count == 1, "expected a single image inspect output, got \(output)") let expected = Set([ Platform(arch: "amd64", os: "linux", variant: nil), Platform(arch: "arm64", os: "linux", variant: nil), ]) let actual = Set( output[0].variants.map { v in Platform(arch: v.platform.architecture, os: v.platform.os, variant: nil) }) #expect( actual == expected, "expected platforms \(expected), got \(actual)" ) } @Test func testBuildMultipleTags() throws { let tempDir: URL = try createTempDir() let dockerfile: String = """ FROM scratch ADD emptyFile / """ let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let uuid = UUID().uuidString let tag1 = "registry.local/multi-tag-test:\(uuid)" let tag2 = "registry.local/multi-tag-test:latest" let tag3 = "registry.local/multi-tag-test:v1.0.0" try self.build(tags: [tag1, tag2, tag3], tempDir: tempDir) // Verify all three tags exist and point to the same image #expect(try self.inspectImage(tag1) == tag1, "expected to have successfully built \(tag1)") #expect(try self.inspectImage(tag2) == tag2, "expected to have successfully built \(tag2)") #expect(try self.inspectImage(tag3) == tag3, "expected to have successfully built \(tag3)") } @Test func testBuildAfterContextChange() throws { let name = "test-build-context-change" let tempDir: URL = try createTempDir() // Create initial context with file "foo" containing "initial" let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 COPY foo /foo COPY bar /bar """ let initialContent = "initial".data(using: .utf8)! let context: [FileSystemEntry] = [ .file("foo", content: .data(Data((0..<4 * 1024 * 1024).map { UInt8($0 % 256) }))), .file("bar", content: .data(initialContent)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) // Build first image let imageName1 = "\(name):v1" let containerName1 = "\(name)-container-v1" try self.build(tag: imageName1, tempDir: tempDir) #expect(try self.inspectImage(imageName1) == imageName1, "expected to have successfully built \(imageName1)") // Run container and verify content is "initial" try self.doLongRun(name: containerName1, image: imageName1) defer { try? self.doStop(name: containerName1) } var output = try doExec(name: containerName1, cmd: ["cat", "/bar"]) #expect(output == "initial", "expected file contents to be 'initial', instead got '\(output)'") // Update the file "foo" to contain "updated" let updatedContent = "updated".data(using: .utf8)! let contextDir = tempDir.appendingPathComponent("context") let barPath = contextDir.appendingPathComponent("bar") try updatedContent.write(to: barPath, options: .atomic) // Build second image let imageName2 = "\(name):v2" let containerName2 = "\(name)-container-v2" try self.build(tag: imageName2, tempDir: tempDir) #expect(try self.inspectImage(imageName2) == imageName2, "expected to have successfully built \(imageName2)") // Run container and verify content is "updated" try self.doLongRun(name: containerName2, image: imageName2) defer { try? self.doStop(name: containerName2) } output = try doExec(name: containerName2, cmd: ["cat", "/bar"]) #expect(output == "updated", "expected file contents to be 'updated', instead got '\(output)'") } @Test func testBuildWithDockerfileFromStdin() throws { let tempDir: URL = try createTempDir() let dockerfile = """ FROM scratch ADD emptyFile / """ let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))] try createContext(tempDir: tempDir, dockerfile: "", context: context) let imageName = "registry.local/stdin-file:\(UUID().uuidString)" try buildWithStdin(tags: [imageName], tempContext: tempDir, dockerfileContents: dockerfile) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @Test func testLowercaseDockerfile() throws { // Test 1: COPY with uppercase let tempDir1: URL = try createTempDir() let dockerfile1 = """ FROM ghcr.io/linuxcontainers/alpine:3.20 COPY . /app RUN test -f /app/testfile.txt """ let context1: [FileSystemEntry] = [ .file("testfile.txt", content: .data("test".data(using: .utf8)!)) ] try createContext(tempDir: tempDir1, dockerfile: dockerfile1, context: context1) let imageName1 = "registry.local/copy-uppercase:\(UUID().uuidString)" try self.build(tag: imageName1, tempDir: tempDir1) #expect(try self.inspectImage(imageName1) == imageName1, "expected COPY to work") // Test 2: copy with lowercase let tempDir2: URL = try createTempDir() let dockerfile2 = """ FROM ghcr.io/linuxcontainers/alpine:3.20 copy . /app RUN test -f /app/testfile.txt """ let context2: [FileSystemEntry] = [ .file("testfile.txt", content: .data("test".data(using: .utf8)!)) ] try createContext(tempDir: tempDir2, dockerfile: dockerfile2, context: context2) let imageName2 = "registry.local/copy-lowercase:\(UUID().uuidString)" try self.build(tag: imageName2, tempDir: tempDir2) #expect(try self.inspectImage(imageName2) == imageName2, "expected copy to work") // Test 3: ADD with uppercase let tempDir3: URL = try createTempDir() let dockerfile3 = """ FROM ghcr.io/linuxcontainers/alpine:3.20 ADD . /app RUN test -f /app/testfile.txt """ let context3: [FileSystemEntry] = [ .file("testfile.txt", content: .data("test".data(using: .utf8)!)) ] try createContext(tempDir: tempDir3, dockerfile: dockerfile3, context: context3) let imageName3 = "registry.local/add-uppercase:\(UUID().uuidString)" try self.build(tag: imageName3, tempDir: tempDir3) #expect(try self.inspectImage(imageName3) == imageName3, "expected ADD to work") // Test 4: add with lowercase let tempDir4: URL = try createTempDir() let dockerfile4 = """ FROM ghcr.io/linuxcontainers/alpine:3.20 add . /app RUN test -f /app/testfile.txt """ let context4: [FileSystemEntry] = [ .file("testfile.txt", content: .data("test".data(using: .utf8)!)) ] try createContext(tempDir: tempDir4, dockerfile: dockerfile4, context: context4) let imageName4 = "registry.local/add-lowercase:\(UUID().uuidString)" try self.build(tag: imageName4, tempDir: tempDir4) #expect(try self.inspectImage(imageName4) == imageName4, "expected add to work") } @Test func testRunWithBindMount() throws { let tempDir: URL = try createTempDir() let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 # Use bind mount to access build context during RUN RUN --mount=type=bind,source=.,target=/mnt/context \ set -e; \ echo "Checking files in bind mount..."; \ ls -la /mnt/context/; \ \ echo "Verifying files are accessible in mount..."; \ if [ ! -f /mnt/context/app.py ]; then \ echo "ERROR: app.py should be in bind mount!"; \ exit 1; \ fi; \ if [ ! -f /mnt/context/config.yaml ]; then \ echo "ERROR: config.yaml should be in bind mount!"; \ exit 1; \ fi; \ \ echo "RUN --mount bind check passed!"; \ cp /mnt/context/app.py /app.py RUN cat /app.py """ let context: [FileSystemEntry] = [ .file("app.py", content: .data("print('Hello from bind mount')".data(using: .utf8)!)), .file("config.yaml", content: .data("key: value".data(using: .utf8)!)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let imageName = "registry.local/bind-mount-test:\(UUID().uuidString)" try self.build(tag: imageName, tempDir: tempDir) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @Test func testBuildDockerIgnore() throws { let tempDir: URL = try createTempDir() let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 # Copy all files - should respect .dockerignore COPY . /app # Verify specific files are excluded RUN set -e; \ echo "Checking specific file exclusion..."; \ if [ -f /app/secret.txt ]; then \ echo "ERROR: secret.txt should be excluded!"; \ exit 1; \ fi # Verify wildcard *.log files are excluded RUN set -e; \ echo "Checking *.log exclusion..."; \ if [ -f /app/debug.log ]; then \ echo "ERROR: debug.log should be excluded by *.log pattern!"; \ exit 1; \ fi; \ if ls /app/logs/*.log 2>/dev/null; then \ echo "ERROR: logs/*.log files should be excluded!"; \ exit 1; \ fi # Verify exception pattern (!important.log) works RUN set -e; \ echo "Checking exception pattern..."; \ if [ ! -f /app/important.log ]; then \ echo "ERROR: important.log should be included (exception with !)"; \ exit 1; \ fi # Verify *.tmp files are excluded RUN set -e; \ echo "Checking *.tmp exclusion..."; \ if find /app -name "*.tmp" | grep .; then \ echo "ERROR: .tmp files should be excluded!"; \ exit 1; \ fi # Verify directories are excluded RUN set -e; \ echo "Checking directory exclusion..."; \ if [ -d /app/temp ]; then \ echo "ERROR: temp/ directory should be excluded!"; \ exit 1; \ fi; \ if [ -d /app/node_modules ]; then \ echo "ERROR: node_modules/ should be excluded!"; \ exit 1; \ fi # Verify included files ARE present RUN set -e; \ echo "Checking included files..."; \ if [ ! -f /app/main.go ]; then \ echo "ERROR: main.go should be included!"; \ exit 1; \ fi; \ if [ ! -f /app/README.md ]; then \ echo "ERROR: README.md should be included!"; \ exit 1; \ fi; \ if [ ! -f /app/src/app.go ]; then \ echo "ERROR: src/app.go should be included!"; \ exit 1; \ fi; \ echo "All .dockerignore checks passed!" """ let dockerignore = """ # Exclude specific files secret.txt # Exclude all log files *.log **/*.log # But make an exception for important.log !important.log # Exclude all temporary files *.tmp **/*.tmp # Exclude directories temp/ node_modules/ """ let context: [FileSystemEntry] = [ .file(".dockerignore", content: .data(dockerignore.data(using: .utf8)!)), .file("secret.txt", content: .data("secret content".data(using: .utf8)!)), .file("debug.log", content: .data("debug log content".data(using: .utf8)!)), .file("important.log", content: .data("important log content".data(using: .utf8)!)), .file("cache.tmp", content: .data("cache".data(using: .utf8)!)), .file("main.go", content: .data("package main".data(using: .utf8)!)), .file("README.md", content: .data("# README".data(using: .utf8)!)), .directory("temp"), .file("temp/cache.tmp", content: .data("temp cache".data(using: .utf8)!)), .directory("logs"), .file("logs/app.log", content: .data("app log".data(using: .utf8)!)), .directory("node_modules"), .file("node_modules/package.json", content: .data("{}".data(using: .utf8)!)), .directory("src"), .file("src/app.go", content: .data("package src".data(using: .utf8)!)), .file("src/test.tmp", content: .data("temp".data(using: .utf8)!)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let imageName = "registry.local/dockerignore-test:\(UUID().uuidString)" try self.build(tag: imageName, tempDir: tempDir) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } // Test 1: Basic .dockerignore @Test func testDockerIgnoreBasic() throws { let tempDir: URL = try createTempDir() defer { try! FileManager.default.removeItem(at: tempDir) } let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 WORKDIR /app COPY . . """ let context: [FileSystemEntry] = [ .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), .file("included.txt", content: .data("This file should be included in the build context.\n".data(using: .utf8)!)), .file("ignored.txt", content: .data("This file should be ignored by .dockerignore.\n".data(using: .utf8)!)), .file(".dockerignore", content: .data("ignored.txt\n".data(using: .utf8)!)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let contextDir = tempDir.appendingPathComponent("context") let dockerfilePath = contextDir.appendingPathComponent("Dockerfile") let imageName = "registry.local/dockerignore-basic:\(UUID().uuidString)" let args = ["build", "-f", dockerfilePath.path, "-t", imageName, contextDir.path] let response = try run(arguments: args) if response.status != 0 { throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") } let containerName = "dockerignore-basic-\(UUID().uuidString)" try self.doLongRun(name: containerName, image: imageName) defer { try? self.doStop(name: containerName) } let includedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/included.txt"]) #expect(includedResult.status == 0, "included.txt should be present in the image") let ignoredResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/ignored.txt"]) #expect(ignoredResult.status != 0, "ignored.txt should NOT be present in the image") } // Test 2: Dockerfile-specific ignore file (Dockerfile.dockerignore takes precedence over .dockerignore) @Test func testDockerIgnoreDockerfileSpecific() throws { let tempDir: URL = try createTempDir() defer { try! FileManager.default.removeItem(at: tempDir) } let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 WORKDIR /app COPY . . """ // .dockerignore ignores general.txt; Dockerfile.dockerignore ignores specific.txt. // When both exist, Dockerfile.dockerignore takes precedence, so general.txt is included. // Dockerfile and its .dockerignore must be co-located; here both live in the context root. let context: [FileSystemEntry] = [ .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), .file("Dockerfile.dockerignore", content: .data("specific.txt\n".data(using: .utf8)!)), .file("general.txt", content: .data("This file should be included (Dockerfile.dockerignore takes precedence over .dockerignore).\n".data(using: .utf8)!)), .file("specific.txt", content: .data("This file should be ignored by Dockerfile.dockerignore.\n".data(using: .utf8)!)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let contextDir = tempDir.appendingPathComponent("context") let dockerfilePath = contextDir.appendingPathComponent("Dockerfile") let imageName = "registry.local/dockerignore-specific:\(UUID().uuidString)" let args = ["build", "-f", dockerfilePath.path, "-t", imageName, contextDir.path] let response = try run(arguments: args) if response.status != 0 { throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") } let containerName = "dockerignore-specific-\(UUID().uuidString)" try self.doLongRun(name: containerName, image: imageName) defer { try? self.doStop(name: containerName) } let specificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/specific.txt"]) #expect(specificResult.status != 0, "specific.txt should NOT be present (ignored by Dockerfile.dockerignore)") let generalResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/general.txt"]) #expect(generalResult.status == 0, "general.txt should be present (only in .dockerignore, not Dockerfile.dockerignore)") let listResult = try run(arguments: ["exec", containerName, "ls", "-a"]) let listFiles = listResult.output.components(separatedBy: "\n").filter { !$0.isEmpty && $0 != "." && $0 != ".." } #expect(Set(listFiles) == Set(["Dockerfile", ".dockerignore", "Dockerfile.dockerignore", "general.txt"]), "temporary directory must not be detected") } @Test func testDockerIgnoreOutsideContext() throws { let tempDir: URL = try createTempDir() defer { try! FileManager.default.removeItem(at: tempDir) } let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 WORKDIR /app COPY . . """ // .dockerignore ignores general.txt; Dockerfile.dockerignore ignores specific.txt. // When both exist, Dockerfile.dockerignore takes precedence, so general.txt is included. // Dockerfile and its .dockerignore must be co-located; here both live in the context root. let context: [FileSystemEntry] = [ .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), .file("general.txt", content: .data("This file should be included (Dockerfile.dockerignore takes precedence over .dockerignore).\n".data(using: .utf8)!)), .file("specific.txt", content: .data("This file should be ignored by Dockerfile.dockerignore.\n".data(using: .utf8)!)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let dockerignore = "specific.txt\n".data(using: .utf8)! try dockerignore.write(to: tempDir.appendingPathComponent("Dockerfile.dockerignore"), options: .atomic) let contextDir = tempDir.appendingPathComponent("context") let dockerfilePath = tempDir.appendingPathComponent("Dockerfile") let imageName = "registry.local/dockerignore-specific:\(UUID().uuidString)" let args = ["build", "-f", dockerfilePath.path, "-t", imageName, contextDir.path] let response = try run(arguments: args) if response.status != 0 { throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") } let containerName = "dockerignore-specific-\(UUID().uuidString)" try self.doLongRun(name: containerName, image: imageName) defer { try? self.doStop(name: containerName) } let specificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/specific.txt"]) #expect(specificResult.status != 0, "specific.txt should NOT be present (ignored by Dockerfile.dockerignore)") let generalResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/general.txt"]) #expect(generalResult.status == 0, "general.txt should be present (only in .dockerignore, not Dockerfile.dockerignore)") } // Test 5: Build succeeds when Dockerfile is listed in .dockerignore @Test func testDockerIgnoreIgnoredDockerfile() async throws { let tempDir: URL = try createTempDir() defer { try! FileManager.default.removeItem(at: tempDir) } let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 WORKDIR /app COPY . . """ // Dockerfile is listed in .dockerignore but build must still succeed. // Dockerfile lives in the context root so the ignore rule applies to it. let context: [FileSystemEntry] = [ .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), .file(".dockerignore", content: .data("Dockerfile\n.dockerignore\n".data(using: .utf8)!)), .file("test.txt", content: .data("This file should be included even though Dockerfile is ignored.\n".data(using: .utf8)!)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let contextDir = tempDir.appendingPathComponent("context") let dockerfilePath = contextDir.appendingPathComponent("Dockerfile") let imageName = "registry.local/dockerignore-ignored-dockerfile:\(UUID().uuidString)" let args = ["build", "-f", dockerfilePath.path, "-t", imageName, contextDir.path] let response = try run(arguments: args) if response.status != 0 { throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") } let containerName = "dockerignore-ignored-dockerfile" try self.doLongRun(name: containerName, image: imageName) defer { try? self.doStop(name: containerName) } let dockerfileResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/Dockerfile"]) #expect(dockerfileResult.status != 0, "Dockerfile should NOT be present in the image") let dockerignoreResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/.dockerignore"]) #expect(dockerignoreResult.status != 0, ".dockerignore should NOT be present in the image") let testFileResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/test.txt"]) #expect(testFileResult.status == 0, "test.txt should be present in the image") } // Test 8: Dockerfile in nested subdirectory; Dockerfile.dockerignore next to it takes precedence over root .dockerignore @Test func testDockerIgnoreSubdirDockerfile() throws { let tempDir: URL = try createTempDir() defer { try! FileManager.default.removeItem(at: tempDir) } let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 WORKDIR /app COPY . . """ // Root .dockerignore ignores included.txt; nested Dockerfile.dockerignore ignores secret.txt // When Dockerfile is in nested/project/, Dockerfile.dockerignore next to it takes precedence let context: [FileSystemEntry] = [ .file(".dockerignore", content: .data("included.txt\n".data(using: .utf8)!)), .file("included.txt", content: .data("This file should be included (Dockerfile.dockerignore takes precedence).\n".data(using: .utf8)!)), .file("secret.txt", content: .data("This file should be ignored by Dockerfile.dockerignore.\n".data(using: .utf8)!)), .file("nested/secret.txt", content: .data("This file should be ignored by Dockerfile.dockerignore.\n".data(using: .utf8)!)), .file("nested/project/Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), .file("nested/project/Dockerfile.dockerignore", content: .data("secret.txt\n**/secret.txt\n".data(using: .utf8)!)), .file("nested/project/config.txt", content: .data("This config file should be included.\n".data(using: .utf8)!)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let contextDir = tempDir.appendingPathComponent("context") let nestedDockerfile = contextDir.appendingPathComponent("nested/project/Dockerfile") let imageName = "registry.local/dockerignore-subdir:\(UUID().uuidString)" let args = ["build", "-f", nestedDockerfile.path, "-t", imageName, contextDir.path] let response = try run(arguments: args) if response.status != 0 { throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") } let containerName = "dockerignore-subdir-\(UUID().uuidString)" try self.doLongRun(name: containerName, image: imageName) defer { try? self.doStop(name: containerName) } let includedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/included.txt"]) #expect(includedResult.status == 0, "included.txt should be present (Dockerfile.dockerignore takes precedence over .dockerignore)") let secretResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/secret.txt"]) #expect(secretResult.status != 0, "secret.txt should NOT be present (ignored by Dockerfile.dockerignore)") let nestedSecretResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/nested/secret.txt"]) #expect(nestedSecretResult.status != 0, "nested/secret.txt should NOT be present (ignored by Dockerfile.dockerignore)") let configResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/nested/project/config.txt"]) #expect(configResult.status == 0, "nested/project/config.txt should be present") } // Test 9: Custom-named Dockerfile (app1.Dockerfile) uses app1.Dockerfile.dockerignore @Test func testDockerIgnoreCustomDockerfileName() throws { let tempDir: URL = try createTempDir() defer { try! FileManager.default.removeItem(at: tempDir) } let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 WORKDIR /app COPY . . """ // .dockerignore ignores generic.txt; app1.Dockerfile.dockerignore ignores app1-specific.txt // When building with -f app1.Dockerfile, app1.Dockerfile.dockerignore takes precedence let context: [FileSystemEntry] = [ .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), .file(".dockerignore", content: .data("generic.txt\n".data(using: .utf8)!)), .file("app1.Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), .file("app1.Dockerfile.dockerignore", content: .data("app1-specific.txt\n".data(using: .utf8)!)), .file("app1-specific.txt", content: .data("This file should be ignored by app1.Dockerfile.dockerignore.\n".data(using: .utf8)!)), .file("generic.txt", content: .data("This file should be included (only in .dockerignore, not app1.Dockerfile.dockerignore).\n".data(using: .utf8)!)), .file("included.txt", content: .data("This file should always be included.\n".data(using: .utf8)!)), ] try createContext(tempDir: tempDir, dockerfile: "", context: context) let contextDir = tempDir.appendingPathComponent("context") let customDockerfile = contextDir.appendingPathComponent("app1.Dockerfile") let imageName = "registry.local/dockerignore-custom-name:\(UUID().uuidString)" let args = ["build", "-f", customDockerfile.path, "-t", imageName, contextDir.path] let response = try run(arguments: args) if response.status != 0 { throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") } let containerName = "dockerignore-custom-name-\(UUID().uuidString)" try self.doLongRun(name: containerName, image: imageName) defer { try? self.doStop(name: containerName) } let app1SpecificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/app1-specific.txt"]) #expect(app1SpecificResult.status != 0, "app1-specific.txt should NOT be present (ignored by app1.Dockerfile.dockerignore)") let genericResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/generic.txt"]) #expect(genericResult.status == 0, "generic.txt should be present (only in .dockerignore, not app1.Dockerfile.dockerignore)") let includedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/included.txt"]) #expect(includedResult.status == 0, "included.txt should be present") } // Test 10: Custom-named Dockerfile in subdirectory uses its co-located .dockerignore @Test func testDockerIgnoreCustomNameSubdir() throws { let tempDir: URL = try createTempDir() defer { try! FileManager.default.removeItem(at: tempDir) } let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 WORKDIR /app COPY . . """ // Root .dockerignore ignores from-root-ignore.txt // nested/project/app2.Dockerfile.dockerignore ignores from-app2-ignore.txt // When building with -f nested/project/app2.Dockerfile, the nested ignore takes precedence let context: [FileSystemEntry] = [ .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), .file(".dockerignore", content: .data("from-root-ignore.txt\n".data(using: .utf8)!)), .file("from-root-ignore.txt", content: .data("This file should be included (only in .dockerignore, not app2.Dockerfile.dockerignore).\n".data(using: .utf8)!)), .file("from-app2-ignore.txt", content: .data("This file should be ignored by app2.Dockerfile.dockerignore.\n".data(using: .utf8)!)), .file("always-included.txt", content: .data("This file should always be included.\n".data(using: .utf8)!)), .file("nested/project/app2.Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), .file("nested/project/app2.Dockerfile.dockerignore", content: .data("from-app2-ignore.txt\n".data(using: .utf8)!)), .file("nested/project/config.yaml", content: .data("Config file in project directory.\n".data(using: .utf8)!)), ] try createContext(tempDir: tempDir, dockerfile: "", context: context) let contextDir = tempDir.appendingPathComponent("context") let customDockerfile = contextDir.appendingPathComponent("nested/project/app2.Dockerfile") let imageName = "registry.local/dockerignore-custom-subdir:\(UUID().uuidString)" let args = ["build", "-f", customDockerfile.path, "-t", imageName, contextDir.path] let response = try run(arguments: args) if response.status != 0 { throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") } let containerName = "dockerignore-custom-subdir-\(UUID().uuidString)" try self.doLongRun(name: containerName, image: imageName) defer { try? self.doStop(name: containerName) } let app2IgnoreResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/from-app2-ignore.txt"]) #expect(app2IgnoreResult.status != 0, "from-app2-ignore.txt should NOT be present (ignored by app2.Dockerfile.dockerignore)") let rootIgnoreResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/from-root-ignore.txt"]) #expect(rootIgnoreResult.status == 0, "from-root-ignore.txt should be present (only in .dockerignore, not app2.Dockerfile.dockerignore)") let alwaysIncludedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/always-included.txt"]) #expect(alwaysIncludedResult.status == 0, "always-included.txt should be present") let configResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/nested/project/config.yaml"]) #expect(configResult.status == 0, "nested/project/config.yaml should be present") } // Test 11: app.Dockerfile coexists with Dockerfile; app.Dockerfile.dockerignore is used, not Dockerfile.dockerignore @Test func testDockerIgnoreCoexistingDockerfiles() throws { let tempDir: URL = try createTempDir() defer { try! FileManager.default.removeItem(at: tempDir) } let appDockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 WORKDIR /app COPY . . """ let context: [FileSystemEntry] = [ .file("Dockerfile", content: .data("FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . .\n".data(using: .utf8)!)), .file("Dockerfile.dockerignore", content: .data("dockerfile-specific.txt\n".data(using: .utf8)!)), .file("app.Dockerfile", content: .data(appDockerfile.data(using: .utf8)!)), .file("app.Dockerfile.dockerignore", content: .data("app-specific.txt\n".data(using: .utf8)!)), .file( "dockerfile-specific.txt", content: .data("This file should NOT be copied when using Dockerfile, but SHOULD when using app.Dockerfile.\n".data(using: .utf8)!)), .file("app-specific.txt", content: .data("This file should NOT be copied (ignored by app.Dockerfile.dockerignore).\n".data(using: .utf8)!)), .file("included.txt", content: .data("This file should be copied.\n".data(using: .utf8)!)), ] try createContext(tempDir: tempDir, dockerfile: "", context: context) let contextDir = tempDir.appendingPathComponent("context") let appDockerfilePath = contextDir.appendingPathComponent("app.Dockerfile") let imageName = "registry.local/dockerignore-coexisting:\(UUID().uuidString)" let args = ["build", "-f", appDockerfilePath.path, "-t", imageName, contextDir.path] let response = try run(arguments: args) if response.status != 0 { throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") } let containerName = "dockerignore-coexisting-\(UUID().uuidString)" try self.doLongRun(name: containerName, image: imageName) defer { try? self.doStop(name: containerName) } let appSpecificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/app-specific.txt"]) #expect(appSpecificResult.status != 0, "app-specific.txt should NOT be present (ignored by app.Dockerfile.dockerignore)") let dockerfileSpecificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/dockerfile-specific.txt"]) #expect(dockerfileSpecificResult.status == 0, "dockerfile-specific.txt should be present (Dockerfile.dockerignore was not used)") let includedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/included.txt"]) #expect(includedResult.status == 0, "included.txt should be present") } @Test func testNonExistingDockerfile() throws { let tempDir: URL = try createTempDir() defer { try! FileManager.default.removeItem(at: tempDir) } let imageName = "registry.local/non-existing-dockerfile:\(UUID().uuidString)" var args = ["build", "-f", "non-existing-path", "-t", imageName, tempDir.path] var response = try run(arguments: args) #expect(response.status != 0) args = ["build", "-t", imageName, tempDir.path] response = try run(arguments: args) #expect(response.status != 0) } @Test func testBuildNoCachePullLatestImage() throws { let tempDir: URL = try createTempDir() defer { try! FileManager.default.removeItem(at: tempDir) } let dockerfile = """ FROM \(alpine) ADD emptyFile / """ let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) let imageName = "registry.local/no-cache-pull:\(UUID().uuidString)" try self.build( tags: [imageName], tempDir: tempDir, otherArgs: ["--pull", "--no-cache"] ) #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } } } ================================================ FILE: Tests/CLITests/Subcommands/Build/CLIRunBase.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationOS import Foundation import Testing class TestCLIRunBase: CLITest { var terminal: Terminal! var containerName: String = UUID().uuidString var containerImage: String { fatalError("Subclasses must override this property") } var interactive: Bool { false } var tty: Bool { false } var entrypoint: String? { nil } var command: [String]? { nil } var progress: String { "ansi" } override init() throws { try super.init() do { terminal = try containerStart(self.containerName) try waitForContainerRunning(self.containerName) } catch { throw CLIError.containerRunFailed("failed to setup container \(error)") } } func containerRun(stdin: [String], findMessage: String) async throws -> Bool { let stdout = FileHandle(fileDescriptor: terminal.handle.fileDescriptor, closeOnDealloc: false) let stdoutListenTask = Task { for try await line in stdout.bytes.lines { if line.contains(findMessage) && !line.contains("echo") { return true } } return false } let timeoutTask = Task { try await Task.sleep(nanoseconds: 5 * 1_000_000_000) stdoutListenTask.cancel() } do { try self.exec(commands: stdin) let found = try await stdoutListenTask.value timeoutTask.cancel() return found } catch is CancellationError { throw CLIError.executionFailed("timeout hit") } catch { throw error } } func exec(commands: [String]) throws { let stdin = FileHandle(fileDescriptor: terminal.handle.fileDescriptor, closeOnDealloc: false) try commands.forEach { cmd in let cmdLine = cmd.appending("\n") guard let cmdNormalized = cmdLine.data(using: .ascii) else { throw CLIError.invalidInput("shell command \(cmd) is invalid") } try stdin.write(contentsOf: cmdNormalized) } try stdin.synchronize() } func containerStart(_ name: String) throws -> Terminal { if name.count == 0 { throw CLIError.invalidInput("container name cannot be empty") } var arguments = [ "run", "--rm", "--name", name, ] if interactive && tty { arguments.append("-it") } else { if interactive { arguments.append("-i") } if tty { arguments.append("-t") } } arguments.append("--progress") arguments.append(progress) if let entrypoint = entrypoint { arguments += ["--entrypoint", entrypoint] } arguments.append(containerImage) if let command = command { arguments += command } return try runInteractive(arguments: arguments) } } ================================================ FILE: Tests/CLITests/Subcommands/Build/TestCLITermIO.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationOS import Foundation import Testing extension TestCLIRunBase { class TestCLITermIO: TestCLIRunBase { override var containerImage: String { "ghcr.io/linuxcontainers/alpine:3.20" } override var interactive: Bool { true } override var tty: Bool { true } override var command: [String]? { ["/bin/sh"] } override var progress: String { "none" } @Test func testTermIODoesNotPanic() async throws { let uniqMessage = UUID().uuidString let stdin: [String] = [ "echo \(uniqMessage)", "exit", ] do { guard case let statusBefore = try getContainerStatus(containerName), statusBefore == "running" else { Issue.record("test container is not running") return } let found = try await containerRun(stdin: stdin, findMessage: uniqMessage) if !found { Issue.record("did not find stdout line") return } } catch { Issue.record( "failed to start test container \(error)" ) return } } } } ================================================ FILE: Tests/CLITests/Subcommands/Containers/TestCLICreate.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationExtras import Foundation import Testing class TestCLICreateCommand: CLITest { private func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } @Test func testCreateArgsPassthrough() throws { let name = getTestName() #expect(throws: Never.self, "expected container create to succeed") { try doCreate(name: name, args: ["echo", "-n", "hello", "world"]) try doRemove(name: name) } } @Test func testCreateWithMACAddress() throws { let name = getTestName() let expectedMAC = try MACAddress("02:42:ac:11:00:03") #expect(throws: Never.self, "expected container create with MAC address to succeed") { try doCreate(name: name, networks: ["default,mac=\(expectedMAC)"]) try doStart(name: name) defer { try? doStop(name: name) try? doRemove(name: name) } try waitForContainerRunning(name) let inspectResp = try inspectContainer(name) #expect(inspectResp.networks.count > 0, "expected at least one network attachment") let actualMAC = inspectResp.networks[0].macAddress?.description ?? "nil" #expect( actualMAC == expectedMAC.description, "expected MAC address \(expectedMAC), got \(actualMAC)" ) } } @Test func testPublishPortParserMaxPorts() throws { let name = getTestName() var args: [String] = ["create", "--name", name] let portCount = 64 for i in 0.. String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } @Test func testCreateExecCommand() throws { do { let name = getTestName() try doCreate(name: name) defer { try? doStop(name: name) } try doStart(name: name) var unameActual = try doExec(name: name, cmd: ["uname"]) unameActual = unameActual.trimmingCharacters(in: .whitespacesAndNewlines) #expect(unameActual == "Linux", "expected OS to be Linux, instead got \(unameActual)") try doStop(name: name) } catch { Issue.record("failed to exec in container \(error)") return } } @Test func testExecDetach() throws { do { let name = getTestName() try doCreate(name: name) defer { try? doStop(name: name) } try doStart(name: name) // Run a long-running process in detached mode let output = try doExec(name: name, cmd: ["sh", "-c", "touch /tmp/detach_test_marker"], detach: true) let containerIdOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) try #require(containerIdOutput == name, "exec --detach should print the container ID") // Verify the detached process is running by checking if we can still exec commands var lsActual = try doExec(name: name, cmd: ["ls", "/"]) lsActual = lsActual.trimmingCharacters(in: .whitespacesAndNewlines) try #require(lsActual.contains("tmp"), "container should still be running and accepting exec commands") // Retry loop to check if the marker file was created by the detached process var markerFound = false for _ in 0..<3 { let (_, _, _, status) = try run(arguments: [ "exec", name, "test", "-f", "/tmp/detach_test_marker", ]) if status == 0 { markerFound = true break } sleep(1) } try #require(markerFound, "marker file should be created by detached process within 3 seconds") try doStop(name: name) } catch { Issue.record("failed to exec with detach in container \(error)") return } } @Test func testExecDetachProcessRunning() throws { do { let name = getTestName() try doCreate(name: name) defer { try? doStop(name: name) } try doStart(name: name) // Run a long-running process in detached mode let output = try doExec(name: name, cmd: ["sleep", "10"], detach: true) let containerIdOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) try #require(containerIdOutput == name, "exec --detach should print the container ID") // Immediately check if the process is running using ps var psOutput = try doExec(name: name, cmd: ["ps", "aux"]) psOutput = psOutput.trimmingCharacters(in: .whitespacesAndNewlines) try #require(psOutput.contains("sleep 10"), "detached process 'sleep 10' should be visible in ps output") try doStop(name: name) } catch { Issue.record("failed to verify detached process is running \(error)") return } } @Test func testExecOnExitingContainer() throws { do { let name = getTestName() try doLongRun(name: name, containerArgs: ["sh"], autoRemove: false) defer { try? doRemove(name: name) } // Give time for container process to exit due to no stdin sleep(1) try doStart(name: name) do { _ = try doExec(name: name, cmd: ["sleep", "infinity"]) } catch CLIError.executionFailed(let message) { // There's no nice way to check fail reason here #expect( message.contains("is not running") || message.contains("failed to create process"), "expected container is not running if exec failed" ) } // Give time for the exec (or start) error handling settles down sleep(1) #expect(throws: Never.self, "expected the container remains") { try getContainerStatus(name) } } } } ================================================ FILE: Tests/CLITests/Subcommands/Containers/TestCLIExport.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationArchive import Foundation import Testing class TestCLIExportCommand: CLITest { private func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } @Test func testExportCommand() throws { let name = getTestName() try doLongRun(name: name, autoRemove: false) defer { try? doStop(name: name) try? doRemove(name: name) } let mustBeInImage = "must-be-in-image" _ = try doExec(name: name, cmd: ["sh", "-c", "echo \(mustBeInImage) > /foo"]) _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /parent/child"]) let hardlinkMustRemain = "hardlink-must-remain" _ = try doExec(name: name, cmd: ["sh", "-c", "echo \(hardlinkMustRemain) > /parent/child/bar"]) _ = try doExec(name: name, cmd: ["sh", "-c", "ln /parent/child/bar /bar"]) let symlinkMustRemain = "symlink-must-remain" _ = try doExec(name: name, cmd: ["sh", "-c", "echo \(symlinkMustRemain) > /parent/child/baz"]) _ = try doExec(name: name, cmd: ["sh", "-c", "ln /parent/child/baz /baz"]) try doStop(name: name) let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let tempFile = tempDir.appendingPathComponent(UUID().uuidString) try doExport(name: name, filepath: tempFile.path()) let attrs = try FileManager.default.attributesOfItem(atPath: tempFile.path()) let fileSize = attrs[.size] as! UInt64 #expect(fileSize > 0) // TODO: verify foo bar baz are in tar file. let reader = try ArchiveReader(file: tempFile) let (foo, fooData) = try reader.extractFile(path: "/foo") #expect(foo.fileType == .regular) #expect(String(data: fooData, encoding: .utf8)?.starts(with: mustBeInImage) ?? false) } } ================================================ FILE: Tests/CLITests/Subcommands/Containers/TestCLIPrune.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 @Suite(.serialized) class TestCLIPruneCommand: CLITest { private func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } @Test func testContainerPruneNoContainers() throws { let (_, output, error, status) = try run(arguments: ["prune"]) if status != 0 { throw CLIError.executionFailed("container prune failed: \(error)") } #expect(output.contains("Reclaimed Zero KB in disk space"), "should show no containers message") } @Test func testContainerPruneStoppedContainers() throws { let testName = getTestName() let npcName = "\(testName)_wont_be_pruned" let pc0Name = "\(testName)_pruned_0" let pc1Name = "\(testName)_pruned_1" try doLongRun(name: npcName, containerArgs: ["sleep", "3600"], autoRemove: true) try doLongRun(name: pc0Name, containerArgs: ["sleep", "3600"], autoRemove: false) try doLongRun(name: pc1Name, containerArgs: ["sleep", "3600"], autoRemove: false) defer { try? doStop(name: npcName) try? doStop(name: pc0Name) try? doStop(name: pc1Name) try? doRemove(name: npcName) try? doRemove(name: pc0Name) try? doRemove(name: pc1Name) } try waitForContainerRunning(npcName) try waitForContainerRunning(pc0Name) try waitForContainerRunning(pc1Name) try doStop(name: pc0Name) try doStop(name: pc1Name) let pc0Id = try getContainerId(pc0Name) let pc1Id = try getContainerId(pc1Name) // Poll status until both containers are stopped, with interval checks and a timeout to avoid infinite loop let start = Date() let timeout: TimeInterval = 30 // seconds while true { let s0 = try getContainerStatus(pc0Name) let s1 = try getContainerStatus(pc1Name) if s0 == "stopped" && s1 == "stopped" { break } if Date().timeIntervalSince(start) > timeout { throw CLIError.executionFailed("Timeout waiting for containers to stop: pc0=\(s0), pc1=\(s1)") } Thread.sleep(forTimeInterval: 0.2) } let (_, output, error, status) = try run(arguments: ["prune"]) if status != 0 { throw CLIError.executionFailed("container prune failed: \(error)") } #expect(output.contains(pc0Id) && output.contains(pc1Id), "should show the stopped containers id") #expect(!output.contains("Reclaimed Zero KB in disk space"), "reclaimed spaces should not Zero KB") let checkStatus = try getContainerStatus(npcName) #expect(checkStatus == "running", "not pruned container should still be running") } } ================================================ FILE: Tests/CLITests/Subcommands/Containers/TestCLIRmRace.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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) } private func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } @Test func testStopRmRace() async throws { let name = getTestName() 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 } } } ================================================ FILE: Tests/CLITests/Subcommands/Containers/TestCLIStats.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import Foundation import Testing class TestCLIStatsCommand: CLITest { private func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } @Test func testStatsNoStreamJSONFormat() throws { let name = getTestName() #expect(throws: Never.self, "expected stats command to succeed") { try doLongRun(name: name) defer { try? doStop(name: name) try? doRemove(name: name) } try waitForContainerRunning(name) let (data, _, error, status) = try run(arguments: [ "stats", "--format", "json", "--no-stream", name, ]) try #require(status == 0, "stats command should succeed, error: \(error)") let decoder = JSONDecoder() let stats = try decoder.decode([ContainerStats].self, from: data) #expect(stats.count == 1, "expected stats for one container") #expect(stats[0].id == name, "container ID should match") let memoryUsageBytes = try #require(stats[0].memoryUsageBytes) let numProcesses = try #require(stats[0].numProcesses) #expect(memoryUsageBytes > 0, "memory usage should be non-zero") #expect(numProcesses >= 1, "should have at least one process") } } @Test func testStatsIdleCPUPercentage() throws { let name = getTestName() #expect(throws: Never.self, "expected stats to show low CPU for idle container") { try doLongRun(name: name, containerArgs: ["sleep", "3600"]) defer { try? doStop(name: name) try? doRemove(name: name) } try waitForContainerRunning(name) // Get stats in table format let (_, output, _, status) = try run(arguments: [ "stats", "--no-stream", name, ]) try #require(status == 0, "stats command should succeed") // Parse the table output let lines = output.components(separatedBy: .newlines) #expect(lines.count >= 2, "should have at least header and one data row") // Find the data row (not the header) let dataLine = lines.first { $0.contains(name) } try #require(dataLine != nil, "should find container data row") // Extract CPU percentage - it should be in the second column let columns = dataLine!.split(separator: " ").filter { !$0.isEmpty } #expect(columns.count >= 2, "should have at least 2 columns") // Second column is CPU% let cpuString = String(columns[1]) #expect(cpuString.hasSuffix("%"), "CPU column should end with %") // Parse the percentage let cpuValue = Double(cpuString.dropLast()) try #require(cpuValue != nil, "should be able to parse CPU percentage") // Idle container should use very little CPU (less than 5%) #expect(cpuValue! < 5.0, "idle container CPU should be < 5%, got \(cpuValue!)%") } } @Test func testStatsHighCPUPercentage() throws { let name = getTestName() #expect(throws: Never.self, "expected stats to show high CPU for busy container") { // Run a container with a busy loop try doLongRun(name: name, containerArgs: ["sh", "-c", "while true; do :; done"]) defer { try? doStop(name: name) try? doRemove(name: name) } try waitForContainerRunning(name) // Get stats in table format let (_, output, _, status) = try run(arguments: [ "stats", "--no-stream", name, ]) try #require(status == 0, "stats command should succeed") // Parse the table output let lines = output.components(separatedBy: .newlines) #expect(lines.count >= 2, "should have at least header and one data row") // Find the data row (not the header) let dataLine = lines.first { $0.contains(name) } try #require(dataLine != nil, "should find container data row") // Extract CPU percentage - it should be in the second column // Format is like: "container_id 95.23% ..." let columns = dataLine!.split(separator: " ").filter { !$0.isEmpty } #expect(columns.count >= 2, "should have at least 2 columns") // Second column is CPU% let cpuString = String(columns[1]) #expect(cpuString.hasSuffix("%"), "CPU column should end with %") // Parse the percentage let cpuValue = Double(cpuString.dropLast()) try #require(cpuValue != nil, "should be able to parse CPU percentage") // Busy loop should use significant CPU (at least 50% of one core) #expect(cpuValue! > 50.0, "busy container CPU should be > 50%, got \(cpuValue!)%") // Should not exceed reasonable limits (one core doing while loop = ~100%) #expect(cpuValue! < 150.0, "single busy loop should not exceed 150%, got \(cpuValue!)%") } } @Test func testStatsTableFormat() throws { let name = getTestName() #expect(throws: Never.self, "expected stats table format to work") { try doLongRun(name: name) defer { try? doStop(name: name) try? doRemove(name: name) } try waitForContainerRunning(name) // Get stats in table format let (_, output, error, status) = try run(arguments: [ "stats", "--no-stream", name, ]) try #require(status == 0, "stats command should succeed, error: \(error)") #expect(output.contains("Container ID"), "output should contain table header") #expect(output.contains("Cpu %"), "output should contain CPU column") #expect(output.contains("Memory Usage"), "output should contain Memory column") #expect(output.contains(name), "output should contain container name") } } @Test func testStatsAllContainers() throws { let name1 = getTestName() + "-1" let name2 = getTestName() + "-2" #expect(throws: Never.self, "expected stats for all containers") { try doLongRun(name: name1) try doLongRun(name: name2) defer { try? doStop(name: name1) try? doStop(name: name2) try? doRemove(name: name1) try? doRemove(name: name2) } try waitForContainerRunning(name1) try waitForContainerRunning(name2) // Get stats for all containers (no name specified) let (data, _, error, status) = try run(arguments: [ "stats", "--format", "json", "--no-stream", ]) try #require(status == 0, "stats command should succeed, error: \(error)") let stats = try JSONDecoder().decode([ContainerStats].self, from: data) // Should have stats for both containers try #require(stats.count >= 2, "should have stats for at least 2 containers") let containerIds = stats.map { $0.id } #expect(containerIds.contains(name1), "should include first container") #expect(containerIds.contains(name2), "should include second container") } } @Test func testStatsNonExistentContainer() throws { #expect(throws: Never.self, "expected stats to fail for non-existent container") { let (_, _, _, status) = try run(arguments: [ "stats", "--no-stream", "nonexistent-container-xyz", ]) #expect(status != 0, "stats command should fail for non-existent container") } } } ================================================ FILE: Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import ContainerizationArchive import ContainerizationOCI import Foundation import Testing class TestCLIImagesCommand: CLITest { @Test func testPull() throws { do { try doPull(imageName: alpine) let imagePresent = try isImagePresent(targetImage: alpine) #expect(imagePresent, "expected to see \(alpine) pulled") } catch { Issue.record("failed to pull alpine image \(error)") return } } @Test func testPullMulti() throws { do { try doPull(imageName: alpine) try doPull(imageName: busybox) let alpinePresent = try isImagePresent(targetImage: alpine) #expect(alpinePresent, "expected to see \(alpine) pulled") let busyPresent = try isImagePresent(targetImage: busybox) #expect(busyPresent, "expected to see \(busybox) pulled") } catch { Issue.record("failed to pull images \(error)") return } } @Test func testPullPlatform() throws { do { let os = "linux" let arch = "amd64" let pullArgs = [ "--platform", "\(os)/\(arch)", ] try doPull(imageName: alpine, args: pullArgs) let output = try doInspectImages(image: alpine) #expect(output.count == 1, "expected a single image inspect output, got \(output)") var found = false for v in output[0].variants { if v.platform.os == os && v.platform.architecture == arch { found = true } } #expect(found, "expected to find image with os \(os) and architecture \(arch), instead got \(output[0])") } catch { Issue.record("failed to pull and inspect image \(error)") return } } @Test func testPullOsArch() throws { do { let os = "linux" let arch = "amd64" let pullArgs = [ "--os", os, "--arch", arch, ] try doPull(imageName: alpine318, args: pullArgs) let output = try doInspectImages(image: alpine318) #expect(output.count == 1, "expected a single image inspect output, got \(output)") var found = false for v in output[0].variants { if v.platform.os == os && v.platform.architecture == arch { found = true } } #expect(found, "expected to find image with os \(os) and architecture \(arch), instead got \(output[0])") } catch { Issue.record("failed to pull and inspect image \(error)") return } } @Test func testPullOs() throws { do { let os = "linux" let arch = Arch.hostArchitecture().rawValue let pullArgs = [ "--os", os, ] try doPull(imageName: alpine318, args: pullArgs) let output = try doInspectImages(image: alpine318) #expect(output.count == 1, "expected a single image inspect output, got \(output)") var found = false for v in output[0].variants { if v.platform.os == os && v.platform.architecture == arch { found = true } } #expect(found, "expected to find image with os \(os) and architecture \(arch), instead got \(output[0])") } catch { Issue.record("failed to pull and inspect image \(error)") return } } @Test func testPullArch() throws { do { let os = "linux" let arch = "amd64" let pullArgs = [ "--arch", arch, ] try doPull(imageName: alpine318, args: pullArgs) let output = try doInspectImages(image: alpine318) #expect(output.count == 1, "expected a single image inspect output, got \(output)") var found = false for v in output[0].variants { if v.platform.os == os && v.platform.architecture == arch { found = true } } #expect(found, "expected to find image with os \(os) and architecture \(arch), instead got \(output[0])") } catch { Issue.record("failed to pull and inspect image \(error)") return } } @Test func testPullRemoveSingle() throws { do { try doPull(imageName: alpine) let imagePulled = try isImagePresent(targetImage: alpine) #expect(imagePulled, "expected to see image \(alpine) pulled") // tag image so we can safely remove later let alpineRef: Reference = try Reference.parse(alpine) let alpineTagged = "\(alpineRef.name):testPullRemoveSingle" try doImageTag(image: alpine, newName: alpineTagged) let taggedImagePresent = try isImagePresent(targetImage: alpineTagged) #expect(taggedImagePresent, "expected to see image \(alpineTagged) tagged") try doRemoveImages(images: [alpineTagged]) let imageRemoved = try !isImagePresent(targetImage: alpineTagged) #expect(imageRemoved, "expected not to see image \(alpineTagged)") } catch { Issue.record("failed to pull and remove image \(error)") return } } @Test func testImageTag() throws { do { try doPull(imageName: alpine) let alpineRef: Reference = try Reference.parse(alpine) let alpineTagged = "\(alpineRef.name):testImageTag" try doImageTag(image: alpine, newName: alpineTagged) let imagePresent = try isImagePresent(targetImage: alpineTagged) #expect(imagePresent, "expected to see image \(alpineTagged) tagged") } catch { Issue.record("failed to pull and tag image \(error)") return } } @Test func testImageDefaultRegistry() throws { do { let defaultDomain = "ghcr.io" let imageName = "linuxcontainers/alpine:3.20" defer { try? doDefaultRegistrySet(domain: "docker.io") } try doDefaultRegistrySet(domain: defaultDomain) try doPull(imageName: imageName, args: ["--platform", "linux/arm64"]) guard let alpineImageDetails = try doInspectImages(image: imageName).first else { Issue.record("alpine image not found") return } #expect(alpineImageDetails.name == "\(defaultDomain)/\(imageName)") try doImageTag(image: imageName, newName: "username/image-name:mytag") guard let taggedImage = try doInspectImages(image: "username/image-name:mytag").first else { Issue.record("Tagged image not found") return } #expect(taggedImage.name == "\(defaultDomain)/username/image-name:mytag") let listOutput = try doImageListQuite() #expect(listOutput.contains("username/image-name:mytag")) #expect(listOutput.contains(imageName)) } catch { Issue.record("failed default registry test") return } } @Test func testImageSaveAndLoad() throws { do { // 1. pull image try doPull(imageName: alpine) try doPull(imageName: busybox) // 2. Tag image so we can safely remove later let alpineRef: Reference = try Reference.parse(alpine) let alpineTagged = "\(alpineRef.name):testImageSaveAndLoad" try doImageTag(image: alpine, newName: alpineTagged) let alpineTaggedImagePresent = try isImagePresent(targetImage: alpineTagged) #expect(alpineTaggedImagePresent, "expected to see image \(alpineTagged) tagged") let busyboxRef: Reference = try Reference.parse(busybox) let busyboxTagged = "\(busyboxRef.name):testImageSaveAndLoad" try doImageTag(image: busybox, newName: busyboxTagged) let busyboxTaggedImagePresent = try isImagePresent(targetImage: busyboxTagged) #expect(busyboxTaggedImagePresent, "expected to see image \(busyboxTagged) tagged") // 3. save the image as a tarball let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let tempFile = tempDir.appendingPathComponent(UUID().uuidString) let saveArgs = [ "image", "save", alpineTagged, busyboxTagged, "--output", tempFile.path(), ] let (_, _, error, status) = try run(arguments: saveArgs) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } // 4. remove the image through container try doRemoveImages(images: [alpineTagged, busyboxTagged]) // 5. verify image is no longer present let alpineImageRemoved = try !isImagePresent(targetImage: alpineTagged) #expect(alpineImageRemoved, "expected image \(alpineTagged) to be removed") let busyboxImageRemoved = try !isImagePresent(targetImage: busyboxTagged) #expect(busyboxImageRemoved, "expected image \(busyboxTagged) to be removed") // 6. load the tarball let loadArgs = [ "image", "load", "-i", tempFile.path(), ] let (_, _, loadErr, loadStatus) = try run(arguments: loadArgs) if loadStatus != 0 { throw CLIError.executionFailed("command failed: \(loadErr)") } // 7. verify image is in the list again let alpineImagePresent = try isImagePresent(targetImage: alpineTagged) #expect(alpineImagePresent, "expected \(alpineTagged) to be present") let busyboxImagePresent = try isImagePresent(targetImage: busyboxTagged) #expect(busyboxImagePresent, "expected \(busyboxTagged) to be present") } catch { Issue.record("failed to save and load image \(error)") return } } @Test func testMaxConcurrentDownloadsValidation() throws { // Test that invalid maxConcurrentDownloads value is rejected let (_, _, error, status) = try run(arguments: [ "image", "pull", "--max-concurrent-downloads", "0", "alpine:latest", ]) #expect(status != 0, "Expected command to fail with maxConcurrentDownloads=0") #expect( error.contains("maximum number of concurrent downloads must be greater than 0"), "Expected validation error message in output") } @Test func testImageLoadRejectsInvalidMembersWithoutForce() throws { do { // 0. Generate unique malicious filename for this test run let maliciousFilename = "pwned-\(UUID().uuidString).txt" let maliciousPath = "/tmp/\(maliciousFilename)" // 1. Pull image try doPull(imageName: alpine) // 2. Tag image so we can safely remove later let alpineRef: Reference = try Reference.parse(alpine) let alpineTagged = "\(alpineRef.name):testImageLoadRejectsInvalidMembers" try doImageTag(image: alpine, newName: alpineTagged) let taggedImagePresent = try isImagePresent(targetImage: alpineTagged) #expect(taggedImagePresent, "expected to see image \(alpineTagged) tagged") // 3. Save the image as a tarball let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let tempFile = tempDir.appendingPathComponent(UUID().uuidString) let saveArgs = [ "image", "save", alpineTagged, "--output", tempFile.path(), ] let (_, _, saveError, saveStatus) = try run(arguments: saveArgs) if saveStatus != 0 { throw CLIError.executionFailed("save command failed: \(saveError)") } // 4. Add malicious member to the tar try addInvalidMemberToTar(tarPath: tempFile.path(), maliciousFilename: maliciousFilename) // 5. Remove the image try doRemoveImages(images: [alpineTagged]) let imageRemoved = try !isImagePresent(targetImage: alpineTagged) #expect(imageRemoved, "expected image \(alpineTagged) to be removed") // 6. Try to load the modified tar without force - should fail let loadArgs = [ "image", "load", "-i", tempFile.path(), ] let (_, _, loadError, loadStatus) = try run(arguments: loadArgs) #expect(loadStatus != 0, "expected load to fail without force flag") #expect(loadError.contains("rejected paths") || loadError.contains(maliciousFilename), "expected error about invalid member path") // 7. Verify that malicious file was NOT created let maliciousFileExists = FileManager.default.fileExists(atPath: maliciousPath) #expect(!maliciousFileExists, "malicious file should not have been created at \(maliciousPath)") } catch { Issue.record("failed to test image load with invalid members: \(error)") return } } @Test func testImageLoadAcceptsInvalidMembersWithForce() throws { do { // 0. Generate unique malicious filename for this test run let maliciousFilename = "pwned-\(UUID().uuidString).txt" let maliciousPath = "/tmp/\(maliciousFilename)" // 1. Pull image try doPull(imageName: alpine) // 2. Tag image so we can safely remove later let alpineRef: Reference = try Reference.parse(alpine) let alpineTagged = "\(alpineRef.name):testImageLoadAcceptsInvalidMembers" try doImageTag(image: alpine, newName: alpineTagged) let taggedImagePresent = try isImagePresent(targetImage: alpineTagged) #expect(taggedImagePresent, "expected to see image \(alpineTagged) tagged") // 3. Save the image as a tarball let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let tempFile = tempDir.appendingPathComponent(UUID().uuidString) let saveArgs = [ "image", "save", alpineTagged, "--output", tempFile.path(), ] let (_, _, saveError, saveStatus) = try run(arguments: saveArgs) if saveStatus != 0 { throw CLIError.executionFailed("save command failed: \(saveError)") } // 4. Add malicious member to the tar try addInvalidMemberToTar(tarPath: tempFile.path(), maliciousFilename: maliciousFilename) // 5. Remove the image try doRemoveImages(images: [alpineTagged]) let imageRemoved = try !isImagePresent(targetImage: alpineTagged) #expect(imageRemoved, "expected image \(alpineTagged) to be removed") // 6. Try to load the modified tar with force - should succeed with warning let loadArgs = [ "image", "load", "-i", tempFile.path(), "--force", ] let (_, _, loadError, loadStatus) = try run(arguments: loadArgs) #expect(loadStatus == 0, "expected load to succeed with force flag") // Check that warning was logged about rejected member #expect(loadError.contains("invalid members") || loadError.contains(maliciousFilename), "expected warning about rejected member path") // 7. Verify image is loaded let imageLoaded = try isImagePresent(targetImage: alpineTagged) #expect(imageLoaded, "expected image \(alpineTagged) to be loaded") // 8. Verify that malicious file was NOT created let maliciousFileExists = FileManager.default.fileExists(atPath: maliciousPath) #expect(!maliciousFileExists, "malicious file should not have been created at \(maliciousPath)") } catch { Issue.record("failed to test image load with force and invalid members: \(error)") return } } @Test func testImageSaveAndLoadStdinStdout() throws { do { // 1. pull image try doPull(imageName: alpine) try doPull(imageName: busybox) // 2. Tag image so we can safely remove later let alpineRef: Reference = try Reference.parse(alpine) let alpineTagged = "\(alpineRef.name):testImageSaveAndLoadStdinStdout" try doImageTag(image: alpine, newName: alpineTagged) let alpineTaggedImagePresent = try isImagePresent(targetImage: alpineTagged) #expect(alpineTaggedImagePresent, "expected to see image \(alpineTagged) tagged") let busyboxRef: Reference = try Reference.parse(busybox) let busyboxTagged = "\(busyboxRef.name):testImageSaveAndLoadStdinStdout" try doImageTag(image: busybox, newName: busyboxTagged) let busyboxTaggedImagePresent = try isImagePresent(targetImage: busyboxTagged) #expect(busyboxTaggedImagePresent, "expected to see image \(busyboxTagged) tagged") // 3. save the image and output to stdout let saveArgs = [ "image", "save", alpineTagged, busyboxTagged, ] let (stdoutData, _, error, status) = try run(arguments: saveArgs) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } // 4. remove the image through container try doRemoveImages(images: [alpineTagged, busyboxTagged]) // 5. verify image is no longer present let alpineImageRemoved = try !isImagePresent(targetImage: alpineTagged) #expect(alpineImageRemoved, "expected image \(alpineTagged) to be removed") let busyboxImageRemoved = try !isImagePresent(targetImage: busyboxTagged) #expect(busyboxImageRemoved, "expected image \(busyboxTagged) to be removed") // 6. load the tarball from the stdout data as stdin let loadArgs = [ "image", "load", ] let (_, _, loadErr, loadStatus) = try run(arguments: loadArgs, stdin: stdoutData) if loadStatus != 0 { throw CLIError.executionFailed("command failed: \(loadErr)") } // 7. verify image is in the list again let alpineImagePresent = try isImagePresent(targetImage: alpineTagged) #expect(alpineImagePresent, "expected \(alpineTagged) to be present") let busyboxImagePresent = try isImagePresent(targetImage: busyboxTagged) #expect(busyboxImagePresent, "expected \(busyboxTagged) to be present") } catch { Issue.record("failed to save and load image \(error)") return } } @Test func testImageFullSizeFieldExists() throws { // 1. pull image try doPull(imageName: alpine) // 2. run the image ls command let (_, output, error, status) = try run(arguments: ["image", "ls", "--format", "json"]) if status != 0 { throw CLIError.executionFailed("failed to list images: \(error)") } // 3. parse the json output guard let data = output.data(using: .utf8), let json = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]], let image = json.first else { Issue.record("failed to parse JSON output or no images found: \(output)") return } // 4. check that the output has a non-empty 'fullSize' field let size = image["fullSize"] as? String ?? "" #expect(!size.isEmpty, "expected image to have non-empty 'fullSize' field: \(image)") } private func addInvalidMemberToTar(tarPath: String, maliciousFilename: String) throws { // Create a malicious entry with path traversal let evilEntryName = "../../../../../../../../../../../tmp/\(maliciousFilename)" let evilEntryContent = "pwned\n".data(using: .utf8)! // Create a temporary file for the modified tar let tempModifiedTar = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar") // Open the modified tar for writing let writer = try ArchiveWriter(format: .pax, filter: .none, file: tempModifiedTar) // First, copy all existing members from the input tar let reader = try ArchiveReader(file: URL(fileURLWithPath: tarPath)) for (entry, data) in reader { if entry.fileType == .regular { try writer.writeEntry(entry: entry, data: data) } else { try writer.writeEntry(entry: entry, data: nil) } } // Now add the evil entry let evilEntry = WriteEntry() evilEntry.path = evilEntryName evilEntry.size = Int64(evilEntryContent.count) evilEntry.modificationDate = Date() evilEntry.fileType = .regular evilEntry.permissions = 0o644 try writer.writeEntry(entry: evilEntry, data: evilEntryContent) try writer.finishEncoding() // Replace the original tar with the modified one try FileManager.default.removeItem(atPath: tarPath) try FileManager.default.moveItem(at: tempModifiedTar, to: URL(fileURLWithPath: tarPath)) } } ================================================ FILE: Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import AsyncHTTPClient import ContainerAPIClient import ContainerizationError import ContainerizationExtras import ContainerizationOS import Foundation import Testing @Suite(.serialized) class TestCLINetwork: CLITest { private static let retries = 10 private static let retryDelaySeconds = Int64(3) private func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } private func getLowercasedTestName() -> String { getTestName().lowercased() } @available(macOS 26, *) @Test func testNetworkCreateAndUse() async throws { do { let name = getLowercasedTestName() let networkDeleteArgs = ["network", "delete", name] _ = try? run(arguments: networkDeleteArgs) let networkCreateArgs = ["network", "create", name] let result = try run(arguments: networkCreateArgs) if result.status != 0 { throw CLIError.executionFailed("command failed: \(result.error)") } defer { _ = try? run(arguments: networkDeleteArgs) } let port = UInt16.random(in: 50000..<60000) try doLongRun( name: name, image: "docker.io/library/python:alpine", args: ["--network", name], containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"]) defer { try? doStop(name: name) } let container = try inspectContainer(name) #expect(container.networks.count > 0) let cidrAddress = container.networks[0].ipv4Address let url = "http://\(cidrAddress.address):\(port)" var request = HTTPClientRequest(url: url) request.method = .GET let client = getClient(useHttpProxy: false) defer { _ = client.shutdown() } var retriesRemaining = Self.retries var success = false while !success && retriesRemaining > 0 { do { let response = try await client.execute(request, timeout: .seconds(Self.retryDelaySeconds)) try #require(response.status == .ok) success = true } catch { print("request to \(url) failed, error \(error)") try await Task.sleep(for: .seconds(Self.retryDelaySeconds)) } retriesRemaining -= 1 } #expect(success, "Request to \(url) failed after \(Self.retries - retriesRemaining) retries") try doStop(name: name) } catch { Issue.record("failed to create and use network \(error)") return } } @available(macOS 26, *) @Test func testNetworkDeleteWithContainer() async throws { do { // prep: delete container and network, ignoring if it doesn't exist let name = getLowercasedTestName() try? doRemove(name: name) let networkDeleteArgs = ["network", "delete", name] _ = try? run(arguments: networkDeleteArgs) // create our network let networkCreateArgs = ["network", "create", name] let networkCreateResult = try run(arguments: networkCreateArgs) if networkCreateResult.status != 0 { throw CLIError.executionFailed("command failed: \(networkCreateResult.error)") } // ensure it's deleted defer { _ = try? run(arguments: networkDeleteArgs) } // create a container that refers to the network try doCreate(name: name, networks: [name]) defer { try? doRemove(name: name) } // deleting the network should fail let networkDeleteResult = try run(arguments: networkDeleteArgs) try #require(networkDeleteResult.status != 0) // and should fail with a certain message let msg = networkDeleteResult.error #expect(msg.contains("delete failed")) #expect(msg.contains("[\"\(name)\"]")) // now get rid of the container and its network reference try? doRemove(name: name) // delete should succeed _ = try run(arguments: networkDeleteArgs) } catch { Issue.record("failed to safely delete network \(error)") return } } @available(macOS 26, *) @Test func testNetworkLabels() async throws { do { // prep: delete container and network, ignoring if it doesn't exist let name = getLowercasedTestName() try? doRemove(name: name) let networkDeleteArgs = ["network", "delete", name] _ = try? run(arguments: networkDeleteArgs) // create our network let networkCreateArgs = ["network", "create", "--label", "foo=bar", "--label", "baz=qux", name] let networkCreateResult = try run(arguments: networkCreateArgs) guard networkCreateResult.status == 0 else { throw CLIError.executionFailed("command failed: \(networkCreateResult.error)") } // ensure it's deleted defer { _ = try? run(arguments: networkDeleteArgs) } // inspect the network let networkInspectArgs = ["network", "inspect", name] let networkInspectResult = try run(arguments: networkInspectArgs) guard networkInspectResult.status == 0 else { throw CLIError.executionFailed("command failed: \(networkInspectResult.error)") } // decode the JSON result let networkInspectOutput = networkInspectResult.output guard let jsonData = networkInspectOutput.data(using: .utf8) else { throw CLIError.invalidOutput("network inspect output invalid") } let decoder = JSONDecoder() let networks = try decoder.decode([NetworkInspectOutput].self, from: jsonData) guard networks.count == 1 else { throw CLIError.invalidOutput("expected exactly one network from inspect, got \(networks.count)") } // validate labels let expectedLabels = [ "foo": "bar", "baz": "qux", ] #expect(expectedLabels == networks[0].config.labels) // delete should succeed _ = try run(arguments: networkDeleteArgs) } catch { Issue.record("failed to safely delete network \(error)") return } } @Test func testNetworkMTU() async throws { let name = getLowercasedTestName() try? doStop(name: name) try? doRemove(name: name) try doLongRun(name: name, args: ["--network", "default,mtu=1500"]) defer { try? doStop(name: name) } try waitForContainerRunning(name) let output = try doExec(name: name, cmd: ["ip", "link", "show", "eth0"]) #expect(output.contains("mtu 1500"), "expected mtu 1500 in ip link output: \(output)") } @available(macOS 26, *) @Test func testIsolatedNetwork() async throws { do { let name = getLowercasedTestName() let networkDeleteArgs = ["network", "delete", name] _ = try? run(arguments: networkDeleteArgs) let networkCreateArgs = ["network", "create", "--internal", name] let result = try run(arguments: networkCreateArgs) if result.status != 0 { throw CLIError.executionFailed("command failed: \(result.error)") } defer { _ = try? run(arguments: networkDeleteArgs) } let port = UInt16.random(in: 50000..<60000) try doLongRun( name: name, image: "docker.io/library/python:alpine", args: ["--network", name], containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"] ) defer { try? doStop(name: name) } let container = try inspectContainer(name) #expect(container.networks.count > 0) let curlImage = "docker.io/curlimages/curl:8.6.0" let cidrAddress = container.networks[0].ipv4Address let url = "http://\(cidrAddress.address):\(port)" let (_, _, _, succeed) = try run(arguments: [ "run", "--rm", "--network", name, curlImage, "curl", url, ]) #expect(succeed == 0, "internal connection should succeed") let (_, _, _, failed) = try run(arguments: [ "run", "--rm", "--network", name, curlImage, "curl", "http://google.com", ]) #expect(failed == 6, "external connection should fail") } } } ================================================ FILE: Tests/CLITests/Subcommands/Plugins/TestCLIPluginErrors.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Testing struct TestCLIPluginErrors { @Test func testHelpfulMessageWhenPluginsUnavailable() throws { // Intentionally invoke an unknown plugin command. In CI this should run // without the APIServer started, so DefaultCommand will fail to create // a PluginLoader and emit the improved guidance. let cli = try CLITest() let (_, _, stderr, status) = try cli.run(arguments: ["nosuchplugin"]) // non-existent plugin name #expect(status != 0) #expect(stderr.contains("container system start")) #expect(stderr.contains("Plugins are unavailable") || stderr.contains("Plugin 'container-")) // Should include at least one computed plugin search path hint #expect(stderr.contains("container-plugins") || stderr.contains("container/plugins")) } } ================================================ FILE: Tests/CLITests/Subcommands/Registry/TestCLIRegistry.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 TestCLIRegistry: CLITest { @Test func testListDefaultFormat() throws { let (_, output, error, status) = try run(arguments: ["registry", "list"]) #expect(status == 0, "registry list should succeed, stderr: \(error)") // Check for table header let requiredHeaders = ["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"] #expect( requiredHeaders.allSatisfy { output.contains($0) }, "output should contain all required headers" ) } @Test func testListQuietMode() throws { let (_, output, error, status) = try run(arguments: ["registry", "list", "-q"]) #expect(status == 0, "registry list -q should succeed, stderr: \(error)") #expect(!output.contains("HOSTNAME"), "quiet mode should not contain headers") } } ================================================ FILE: Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import AsyncHTTPClient import ContainerAPIClient import ContainerizationExtras import ContainerizationOS import Foundation import Testing // FIXME: We've split the tests into two suites to prevent swamping // the API server with so many run commands that all wind up pulling // images. // // When https://github.com/swiftlang/swift-testing/pull/1390 lands // and is available on the CI runners, we can try setting the // environment variable to limit concurrency and rejoin these suites. class TestCLIRunCommand1: CLITest { func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } func getLowercasedTestName() -> String { getTestName().lowercased() } @Test func testRunCommand() throws { do { let name = getTestName() try doLongRun(name: name, args: []) defer { try? doStop(name: name) } let _ = try doExec(name: name, cmd: ["date"]) try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandCWD() throws { do { let name = getTestName() let expectedCWD = "/tmp" try doLongRun(name: name, args: ["--cwd", expectedCWD]) defer { try? doStop(name: name) } var output = try doExec(name: name, cmd: ["pwd"]) output = output.trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == expectedCWD, "expected current working directory to be \(expectedCWD), instead got \(output)") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandEnv() throws { do { let name = getTestName() let envData = "FOO=bar" try doLongRun(name: name, args: ["--env", envData]) defer { try? doStop(name: name) } let inspectResp = try inspectContainer(name) #expect( inspectResp.configuration.initProcess.environment.contains(envData), "environment variable \(envData) not set in container configuration") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandEnvFile() throws { do { let name = getTestName() let content = """ # Really cool comment FOO=bar BAR=baz wow URL=https://foo.bar?baz=wow """ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test.env") guard FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8)) else { Issue.record("failed to create temporary file \(tempFile.path())") return } defer { try? FileManager.default.removeItem(at: tempFile) } try doLongRun(name: name, args: ["--env-file", tempFile.path()]) defer { try? doStop(name: name) } let inspectResp = try inspectContainer(name) let expected = [ "FOO=bar", "BAR=baz wow", "URL=https://foo.bar?baz=wow", ] for item in expected { #expect( inspectResp.configuration.initProcess.environment.contains(item), "environment variable \(item) not set in container configuration") } try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandUserIDGroupID() throws { do { let name = getTestName() let uid = "10" let gid = "100" try doLongRun(name: name, args: ["--uid", uid, "--gid", gid]) defer { try? doStop(name: name) } var output = try doExec(name: name, cmd: ["id"]) output = output.trimmingCharacters(in: .whitespacesAndNewlines) try #expect(output.contains(Regex("uid=\(uid).*?gid=\(gid).*")), "invalid user/group id, got \(output)") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandUser() throws { do { let name = getTestName() let user = "nobody" try doLongRun(name: name, args: ["--user", user]) defer { try? doStop(name: name) } var output = try doExec(name: name, cmd: ["whoami"]) output = output.trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == user, "expected user \(user), got \(output)") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandCPUs() throws { do { let name = getTestName() let cpus = "2" try doLongRun(name: name, args: ["--cpus", cpus]) defer { try? doStop(name: name) } var output = try doExec(name: name, cmd: ["nproc"]) output = output.trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == cpus, "expected \(cpus), instead got \(output)") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandMemory() throws { do { let name = getTestName() let expectedMBs = 1024 try doLongRun(name: name, args: ["--memory", "\(expectedMBs)M"]) defer { try? doStop(name: name) } let inspectResp = try inspectContainer(name) let actualInBytes = inspectResp.configuration.resources.memoryInBytes #expect(actualInBytes == expectedMBs.mib(), "expected \(expectedMBs.mib()) bytes, instead got \(actualInBytes) bytes") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandUlimitNofile() throws { do { let name = getTestName() let softLimit = "1024" let hardLimit = "2048" try doLongRun(name: name, args: ["--ulimit", "nofile=\(softLimit):\(hardLimit)"]) defer { try? doStop(name: name) } let inspectResp = try inspectContainer(name) let rlimits = inspectResp.configuration.initProcess.rlimits let nofileRlimit = rlimits.first { $0.limit == "RLIMIT_NOFILE" } #expect(nofileRlimit != nil, "expected RLIMIT_NOFILE to be set") #expect(nofileRlimit?.soft == UInt64(softLimit), "expected soft limit \(softLimit), got \(nofileRlimit?.soft ?? 0)") #expect(nofileRlimit?.hard == UInt64(hardLimit), "expected hard limit \(hardLimit), got \(nofileRlimit?.hard ?? 0)") var output = try doExec(name: name, cmd: ["sh", "-c", "ulimit -n"]) output = output.trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == softLimit, "expected ulimit -n to return \(softLimit), got \(output)") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandUlimitNproc() throws { do { let name = getTestName() let limit = "256" try doLongRun(name: name, args: ["--ulimit", "nproc=\(limit)"]) defer { try? doStop(name: name) } let inspectResp = try inspectContainer(name) let rlimits = inspectResp.configuration.initProcess.rlimits let nprocRlimit = rlimits.first { $0.limit == "RLIMIT_NPROC" } #expect(nprocRlimit != nil, "expected RLIMIT_NPROC to be set") #expect(nprocRlimit?.soft == UInt64(limit), "expected soft limit \(limit), got \(nprocRlimit?.soft ?? 0)") #expect(nprocRlimit?.hard == UInt64(limit), "expected hard limit \(limit), got \(nprocRlimit?.hard ?? 0)") var output = try doExec(name: name, cmd: ["sh", "-c", "ulimit -u"]) output = output.trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == limit, "expected ulimit -u to return \(limit), got \(output)") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandMultipleUlimits() throws { do { let name = getTestName() try doLongRun( name: name, args: [ "--ulimit", "nofile=1024:2048", "--ulimit", "nproc=512", "--ulimit", "stack=8388608", ]) defer { try? doStop(name: name) } let inspectResp = try inspectContainer(name) let rlimits = inspectResp.configuration.initProcess.rlimits #expect(rlimits.count == 3, "expected 3 rlimits, got \(rlimits.count)") let nofile = rlimits.first { $0.limit == "RLIMIT_NOFILE" } let nproc = rlimits.first { $0.limit == "RLIMIT_NPROC" } let stack = rlimits.first { $0.limit == "RLIMIT_STACK" } #expect(nofile != nil && nofile?.soft == 1024 && nofile?.hard == 2048) #expect(nproc != nil && nproc?.soft == 512 && nproc?.hard == 512) #expect(stack != nil && stack?.soft == 8_388_608 && stack?.hard == 8_388_608) try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } } class TestCLIRunCommand2: CLITest { func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } func getLowercasedTestName() -> String { getTestName().lowercased() } @Test func testRunCommandMount() throws { do { let name = getTestName() let targetContainerPath = "/tmp/testmount" let testData = "hello world" let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let tempFile = tempDir.appendingPathComponent(UUID().uuidString) guard FileManager.default.createFile(atPath: tempFile.path(), contents: Data(testData.utf8)) else { Issue.record("failed to create temporary file \(tempFile.path())") return } defer { try? FileManager.default.removeItem(at: tempDir) } try doLongRun(name: name, args: ["--mount", "type=virtiofs,source=\(tempDir.path()),target=\(targetContainerPath),readonly"]) defer { try? doStop(name: name) } var output = try doExec(name: name, cmd: ["cat", "\(targetContainerPath)/\(tempFile.lastPathComponent)"]) output = output.trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == testData, "expected file with content '\(testData)', instead got '\(output)'") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandUnixSocketMount() throws { do { let name = getTestName() let socketPath = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let socketType = try UnixType(path: socketPath.path, unlinkExisting: true) let socket = try Socket(type: socketType, closeOnDeinit: true) try socket.listen() defer { try? socket.close() try? FileManager.default.removeItem(at: socketPath) } try doLongRun( name: name, args: ["-v", "\(socketPath.path):/woo"] ) defer { try? doStop(name: name) } let output = try doExec(name: name, cmd: ["ls", "-alh", "woo"]) let splitOutput = output.components(separatedBy: .whitespaces) #expect(splitOutput.count > 0, "expected split output of 'ls -alh' to be at least 1, instead got \(splitOutput.count)") let perms = splitOutput[0] let firstChar = perms[perms.startIndex] #expect(firstChar == "s", "expected file in guest to be of type socket, instead got '\(firstChar)'") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandTmpfs() throws { do { let name = getTestName() let targetContainerPath = "/tmp/testtmpfs" let expectedFilesystem = "tmpfs" try doLongRun(name: name, args: ["--tmpfs", targetContainerPath]) defer { try? doStop(name: name) } let output = try doExec(name: name, cmd: ["df", targetContainerPath]) let lines = output.split(separator: "\n") #expect(lines.count == 2, "expected only two rows of output, instead got \(lines.count)") let words = lines[1].split(separator: " ") #expect(words.count > 1, "expected information to contain multiple words, got \(words.count)") #expect(words[0].lowercased() == expectedFilesystem, "expected filesystem type to be \(expectedFilesystem), instead got \(output)") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandOSArch() throws { do { let name = getLowercasedTestName() let os = "linux" let arch = "amd64" let expectedArch = "x86_64" try doLongRun(name: name, args: ["--os", os, "--arch", arch]) defer { try? doStop(name: name) } var output = try doExec(name: name, cmd: ["uname", "-sm"]) output = output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() #expect(output == "\(os) \(expectedArch)", "expected container to use '\(os) \(expectedArch)', instead got '\(output)'") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandPlatform() throws { do { let name = getTestName() let os = "linux" let platform = "linux/amd64" let expectedArch = "x86_64" try doLongRun(name: name, args: ["--platform", platform]) defer { try? doStop(name: name) } var output = try doExec(name: name, cmd: ["uname", "-sm"]) output = output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() #expect(output == "\(os) \(expectedArch)", "expected container to use '\(os) \(expectedArch)', instead got '\(output)'") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandVolume() throws { do { let name = getTestName() let targetContainerPath = "/tmp/testvolume" let testData = "one small step" let volume = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: volume, withIntermediateDirectories: true) let volumeFile = volume.appendingPathComponent(UUID().uuidString) guard FileManager.default.createFile(atPath: volumeFile.path(), contents: Data(testData.utf8)) else { Issue.record("failed to create file at \(volumeFile)") return } defer { try? FileManager.default.removeItem(at: volume) } try doLongRun(name: name, args: ["--volume", "\(volume.path):\(targetContainerPath)"]) defer { try? doStop(name: name) } var output = try doExec(name: name, cmd: ["cat", "\(targetContainerPath)/\(volumeFile.lastPathComponent)"]) output = output.trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == testData, "expected file with content '\(testData)', instead got '\(output)'") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandCidfile() throws { do { let name = getTestName() let filePath = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: filePath) } try doLongRun(name: name, args: ["--cidfile", filePath.path()]) defer { try? doStop(name: name) } let actualID = try String(contentsOf: filePath, encoding: .utf8) #expect(actualID == name, "expected container ID '\(name)', instead got '\(actualID)'") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandNoDNS() throws { do { let name = getTestName() try doLongRun(name: name, args: ["--no-dns"]) defer { try? doStop(name: name) } #expect(throws: (any Error).self) { try doExec(name: name, cmd: ["cat", "/etc/resolv.conf"]) } } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandInit() throws { do { let name = getTestName() try doLongRun(name: name, args: ["--init"]) defer { try? doStop(name: name) } let inspectResp = try inspectContainer(name) #expect(inspectResp.configuration.useInit == true, "expected useInit to be true in container configuration") // With --init, PID 1 should be the init process, not "sleep". var output = try doExec(name: name, cmd: ["cat", "/proc/1/cmdline"]) output = output.trimmingCharacters(in: .whitespacesAndNewlines) #expect( !output.hasPrefix("sleep"), "expected PID 1 to be init process, not 'sleep', got '\(output)'" ) try doStop(name: name) } catch { Issue.record("failed to run container with --init: \(error)") return } } @Test func testRunCommandInitReapsZombies() throws { do { let name = getTestName() try doLongRun(name: name, args: ["--init"]) defer { try? doStop(name: name) } _ = try doExec( name: name, cmd: [ "sh", "-c", "sh -c 'sh -c \"exit 0\" &' && sleep 1", ]) let psOutput = try doExec(name: name, cmd: ["sh", "-c", "ps aux | grep -c '\\[sh\\]' || true"]) let zombieCount = Int(psOutput.trimmingCharacters(in: .whitespacesAndNewlines)) ?? -1 #expect( zombieCount == 0, "expected no zombie processes with --init, found \(zombieCount)" ) try doStop(name: name) } catch { Issue.record("failed to verify zombie reaping with --init: \(error)") return } } @Test func testRunCommandWithoutInitDefault() throws { do { let name = getTestName() try doLongRun(name: name, args: []) defer { try? doStop(name: name) } let inspectResp = try inspectContainer(name) #expect(inspectResp.configuration.useInit == false, "expected useInit to be false by default") try doStop(name: name) } catch { Issue.record("failed to run container without --init: \(error)") return } } } class TestCLIRunCommand3: CLITest { func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } func getLowercasedTestName() -> String { getTestName().lowercased() } @Test func testRunCommandDefaultResolvConf() throws { do { let name = getTestName() try doLongRun(name: name, args: []) defer { try? doStop(name: name) } let output = try doExec(name: name, cmd: ["cat", "/etc/resolv.conf"]) let actualLines = output.components(separatedBy: .newlines) .filter { !$0.isEmpty } .map { $0.components(separatedBy: .whitespaces) } .map { $0.joined(separator: " ") } let inspectOutput = try inspectContainer(name) let ip = inspectOutput.networks[0].ipv4Address.address let expectedNameserver = IPv4Address((ip.value & Prefix(length: 24)!.prefixMask32) + 1).description let defaultDomain = try getDefaultDomain() let expectedLines: [String] = [ "nameserver \(expectedNameserver)", defaultDomain.map { "domain \($0)" }, ].compactMap { $0 } #expect(expectedLines == actualLines) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandNonDefaultResolvConf() throws { do { let expectedDns: String = "8.8.8.8" let expectedDomain = "example.com" let expectedSearch = "test.com" let expectedOption = "debug" let name = getTestName() try doLongRun( name: name, args: [ "--dns", expectedDns, "--dns-domain", expectedDomain, "--dns-search", expectedSearch, "--dns-option", expectedOption, ]) defer { try? doStop(name: name) } let output = try doExec(name: name, cmd: ["cat", "/etc/resolv.conf"]) let actualLines = output.components(separatedBy: .newlines) .filter { !$0.isEmpty } .map { $0.components(separatedBy: .whitespaces) } .map { $0.joined(separator: " ") } let expectedLines: [String] = [ "nameserver \(expectedDns)", "domain \(expectedDomain)", "search \(expectedSearch)", "options \(expectedOption)", ] #expect(expectedLines == actualLines) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunDefaultHostsEntries() throws { do { let name = getTestName() try doLongRun(name: name) defer { try? doStop(name: name) } let inspectOutput = try inspectContainer(name) let ip = inspectOutput.networks[0].ipv4Address.address let output = try doExec(name: name, cmd: ["cat", "/etc/hosts"]) let lines = output.split(separator: "\n") let expectedEntries = [("127.0.0.1", "localhost"), (ip.description, name)] for (i, line) in lines.enumerated() { let words = line.split(separator: " ").map { String($0) } #expect(words.count >= 2, "expected /etc/hosts entry to have 2 or more entries") let expected = expectedEntries[i] #expect(expected.0 == words[0], "expected /etc/hosts entries IP to be \(expected.0), instead got \(words[0])") #expect(expected.1 == words[1], "expected /etc/hosts entries host to be \(expected.1), instead got \(words[1])") } } catch { Issue.record("failed to run container \(error)") return } } @Test func testForwardTCP() async throws { let retries = 10 let retryDelaySeconds = Int64(3) do { let name = getLowercasedTestName() let proxyIp = "127.0.0.1" let proxyPort = UInt16.random(in: 50000..<55000) let serverPort = UInt16.random(in: 55000..<60000) try doLongRun( name: name, image: "docker.io/library/python:alpine", args: ["--publish", "\(proxyIp):\(proxyPort):\(serverPort)/tcp"], containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(serverPort)"]) defer { try? doStop(name: name) } let url = "http://\(proxyIp):\(proxyPort)" var request = HTTPClientRequest(url: url) request.method = .GET let config = HTTPClient.Configuration(proxy: nil) let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) defer { _ = client.shutdown() } var retriesRemaining = retries var success = false while !success && retriesRemaining > 0 { do { let response = try await client.execute(request, timeout: .seconds(retryDelaySeconds)) try #require(response.status == .ok) success = true print("request to \(url) succeeded") } catch { print("request to \(url) failed, error \(error)") try await Task.sleep(for: .seconds(retryDelaySeconds)) } retriesRemaining -= 1 } try #require(success, "Request to \(url) failed after \(retries - retriesRemaining) retries") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testForwardTCPPortRange() async throws { let range = UInt16(10) for portOffset in 0.. 0 { do { let response = try await client.execute(request, timeout: .seconds(retryDelaySeconds)) try #require(response.status == .ok) success = true print("request to \(url) succeeded") } catch { print("request to \(url) failed, error: \(error)") try await Task.sleep(for: .seconds(retryDelaySeconds)) } retriesRemaining -= 1 } try #require(success, "Request to \(url) failed after \(retries - retriesRemaining) retries") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } } @available(macOS 26, *) @Test func testForwardTCPv6() async throws { let retries = 10 let retryDelaySeconds = Int64(3) do { let name = getLowercasedTestName() let proxyIp = "[::1]" let proxyPort = UInt16.random(in: 50000..<55000) let serverPort = UInt16.random(in: 55000..<60000) try doLongRun( name: name, image: "docker.io/library/node:alpine", args: ["--publish", "\(proxyIp):\(proxyPort):\(serverPort)/tcp"], containerArgs: ["npx", "http-server", "-a", "::", "-p", "\(serverPort)"]) defer { try? doStop(name: name) } let url = "http://\(proxyIp):\(proxyPort)" var request = HTTPClientRequest(url: url) request.method = .GET let config = HTTPClient.Configuration(proxy: nil) let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) defer { _ = client.shutdown() } var retriesRemaining = retries var success = false while !success && retriesRemaining > 0 { do { let response = try await client.execute(request, timeout: .seconds(retryDelaySeconds)) try #require(response.status == .ok) success = true print("request to \(url) succeeded") } catch { print("request to \(url) failed, error \(error)") try await Task.sleep(for: .seconds(retryDelaySeconds)) } retriesRemaining -= 1 } try #require(success, "Request to \(url) failed after \(retries - retriesRemaining) retries") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") return } } @Test func testRunCommandEnvFileFromNamedPipe() throws { do { let name = getTestName() let pipePath = FileManager.default.temporaryDirectory.appendingPathComponent("envfile-pipe\(UUID().uuidString)") // create pipe let result = mkfifo(pipePath.path(), 0o600) guard result == 0 else { Issue.record("failed to create named pipe: \(String(cString: strerror(errno)))") return } defer { try? FileManager.default.removeItem(at: pipePath) } let content = """ FOO=bar BAR=baz """ let group = DispatchGroup() group.enter() DispatchQueue.global().async { do { let handle = try FileHandle(forWritingTo: pipePath) try handle.write(contentsOf: Data(content.utf8)) try handle.close() } catch { Issue.record(error) return } group.leave() } try doLongRun(name: name, args: ["--env-file", pipePath.path()]) defer { try? doStop(name: name) } group.wait() let inspectResult = try inspectContainer(name) let expected = [ "FOO=bar", "BAR=baz", ] for item in expected { #expect( inspectResult.configuration.initProcess.environment.contains(item), "expected environment variable \(item) not found" ) } try doStop(name: name) } catch { Issue.record(error) } } @Test func testRunCommandReadOnly() throws { do { let name = getTestName() try doLongRun(name: name, args: ["--read-only"]) defer { try? doStop(name: name) } // Attempt to touch a file on the read-only rootfs should fail #expect(throws: (any Error).self) { try doExec(name: name, cmd: ["touch", "/testfile"]) } } catch { Issue.record("failed to run container \(error)") return } } func getDefaultDomain() throws -> String? { let (_, output, err, status) = try run(arguments: ["system", "property", "get", "dns.domain"]) try #require(status == 0, "default DNS domain retrieval returned status \(status): \(err)") let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedOutput == "" { return nil } return trimmedOutput } @Test func testPrivilegedPortError() throws { try #require(geteuid() != 0) let name = getTestName() let privilegedPort = 80 let (_, _, error, status) = try run(arguments: [ "run", "--name", name, "--publish", "127.0.0.1:\(privilegedPort):80", alpine, ]) defer { try? doRemove(name: name, force: true) } #expect(status != 0, "Command should have failed") #expect( error.contains("Permission denied while binding to host port \(privilegedPort)"), "Error message should mention permission denied for the port. Got: \(error)" ) #expect( error.contains("root privileges"), "Error message should mention root privileges requirement. Got: \(error)" ) } } ================================================ FILE: Tests/CLITests/Subcommands/Run/TestCLIRunInitImage.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Tests for the `--init-image` flag which allows specifying a custom init filesystem /// image for microvms. This enables customizing boot-time behavior before the OCI /// container starts. /// /// See: https://github.com/apple/container/discussions/838 /// /// Note: A full integration test that verifies custom init behavior would require /// a pre-built test init image that writes a marker to /dev/kmsg. This can be added /// once a test init image is published to the registry. class TestCLIRunInitImage: CLITest { private func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } /// Test that specifying a non-existent init-image fails with an appropriate error. @Test func testRunWithNonExistentInitImage() throws { let name = getTestName() let nonExistentImage = "nonexistent.invalid/init-image:does-not-exist" #expect(throws: CLIError.self, "expected container run with non-existent init-image to fail") { let (_, _, error, status) = try run(arguments: [ "run", "--rm", "--name", name, "-d", "--init-image", nonExistentImage, alpine, "sleep", "infinity", ]) defer { try? doRemove(name: name, force: true) } if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } } /// Test that the `--init-image` flag is recognized and documented in CLI help. @Test func testInitImageFlagInHelp() throws { let (_, output, _, status) = try run(arguments: ["run", "--help"]) #expect(status == 0, "expected help command to succeed") #expect( output.contains("--init-image"), "expected help output to contain --init-image flag" ) #expect( output.contains("custom init image"), "expected help output to describe the init-image flag" ) } /// Test that the `--init-image` flag works with `container create` command. @Test func testCreateWithNonExistentInitImage() throws { let name = getTestName() let nonExistentImage = "nonexistent.invalid/init-image:does-not-exist" #expect(throws: CLIError.self, "expected container create with non-existent init-image to fail") { let (_, _, error, status) = try run(arguments: [ "create", "--rm", "--name", name, "--init-image", nonExistentImage, alpine, "echo", "hello", ]) defer { try? doRemove(name: name, force: true) } if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } } /// Test that explicitly specifying the default init image works the same as /// not specifying any init image. @Test func testRunWithExplicitDefaultInitImage() throws { let name = getTestName() // Get the default init image reference let (_, defaultInitImage, _, propStatus) = try run(arguments: [ "system", "property", "get", "image.init", ]) guard propStatus == 0 else { print("Skipping testRunWithExplicitDefaultInitImage: could not get default init image") return } let initImage = defaultInitImage.trimmingCharacters(in: .whitespacesAndNewlines) // Run container with explicit default init image try doLongRun(name: name, args: ["--init-image", initImage]) defer { try? doStop(name: name) } // Verify container is running and functional try waitForContainerRunning(name) let output = try doExec(name: name, cmd: ["echo", "hello"]) #expect( output.trimmingCharacters(in: .whitespacesAndNewlines) == "hello", "expected 'hello' output from exec, got '\(output)'" ) } } ================================================ FILE: Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import Testing class TestCLIRunLifecycle: CLITest { private func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } @Test func testRunFailureCleanup() throws { let name = getTestName() // try to create a container we know will fail let badArgs: [String] = [ "--rm", "--user", name, ] #expect(throws: CLIError.self, "expect container to fail with invalid user") { try self.doLongRun(name: name, args: badArgs) } // try to create a container with the same name but no user that should succeed #expect(throws: Never.self, "expected container run to succeed") { try self.doLongRun(name: name, args: []) defer { try? self.doStop(name: name) } let _ = try self.doExec(name: name, cmd: ["date"]) try self.doStop(name: name) } } @Test func testStartIdempotent() throws { let name = getTestName() #expect(throws: Never.self, "expected container run to succeed") { try self.doLongRun(name: name, args: []) defer { try? self.doStop(name: name) } try self.waitForContainerRunning(name) let (_, output, _, status) = try self.run(arguments: ["start", name]) #expect(status == 0, "expected start to succeed on already running container") #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == name, "expected output to be container name") // Don't care about the resp, just that the container is still there and not cleaned up. let _ = try inspectContainer(name) try self.doStop(name: name) } } @Test func testStartIdempotentAttachFails() throws { let name = getTestName() #expect(throws: Never.self, "expected container run to succeed") { try self.doLongRun(name: name, args: []) defer { try? self.doStop(name: name) } try self.waitForContainerRunning(name) let (_, _, error, status) = try self.run(arguments: ["start", "-a", name]) #expect(status != 0, "expected start with attach to fail on already running container") #expect(error.contains("attach is currently unsupported on already running containers"), "expected error message about attach not supported") try self.doStop(name: name) } } @Test func testStartPortBindFails() async throws { let port = UInt16.random(in: 50000..<60000) let name = getTestName() try self.doCreate(name: name, ports: ["\(port)"]) defer { try? self.doRemove(name: name) } let server = "\(name)-server" try doLongRun( name: server, image: "docker.io/library/python:alpine", args: ["--publish", "\(port):\(port)"], containerArgs: ["python3", "-m", "http.server", "\(port)"] ) defer { try? doStop(name: server) } #expect(throws: CLIError.self) { try doStart(name: name) } let status = try getContainerStatus(name) #expect(status == "stopped") } @Test func testRunInvalidExcutable() async throws { let name = getTestName() #expect(throws: CLIError.self, "running invalid executable must throw error, not hang") { try doLongRun( name: name, containerArgs: ["foobarbaz"] ) } try? doRemove(name: name) } @Test func testExecInvalidExcutable() async throws { let name = getTestName() try doLongRun(name: name) defer { try? doStop(name: name) } #expect(throws: CLIError.self, "executing invalid executable must throw error, not hang") { try doExec( name: name, cmd: ["foobarbaz"] ) } } } ================================================ FILE: Tests/CLITests/Subcommands/System/TestCLIStatus.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Tests for `container system status` output formats and content validation. final class TestCLIStatus: CLITest { struct StatusJSON: Codable { let status: String let appRoot: String let installRoot: String let logRoot: String? let apiServerVersion: String let apiServerCommit: String let apiServerBuild: String let apiServerAppName: String } @Test func defaultDisplaysTable() throws { let (data, out, _, status) = try run(arguments: ["system", "status"]) // default is table // If apiserver is not running, skip this test guard status == 0 else { return } #expect(!out.isEmpty) // Validate table structure let lines = out.trimmingCharacters(in: .whitespacesAndNewlines) .components(separatedBy: .newlines) #expect(lines.count >= 2) // header + at least one data row // Check for header row #expect(lines[0].contains("FIELD") && lines[0].contains("VALUE")) // Check for key fields in output let fullOutput = lines.joined(separator: "\n") #expect(fullOutput.contains("status")) #expect(fullOutput.contains("running")) #expect(fullOutput.contains("app-root")) #expect(fullOutput.contains("install-root")) #expect(fullOutput.contains("apiserver-version")) #expect(fullOutput.contains("apiserver-commit")) _ = data // silence unused warning if assertions short-circuit } @Test func jsonFormat() throws { let (data, out, _, status) = try run(arguments: ["system", "status", "--format", "json"]) // If apiserver is not running, validate error JSON if status != 0 { #expect(!out.isEmpty) let decoded = try JSONDecoder().decode(StatusJSON.self, from: data) #expect(decoded.status == "not running" || decoded.status == "unregistered") return } #expect(!out.isEmpty) let decoded = try JSONDecoder().decode(StatusJSON.self, from: data) #expect(decoded.status == "running") #expect(!decoded.appRoot.isEmpty) #expect(!decoded.installRoot.isEmpty) #expect(!decoded.apiServerVersion.isEmpty) #expect(!decoded.apiServerCommit.isEmpty) #expect(!decoded.apiServerBuild.isEmpty) #expect(!decoded.apiServerAppName.isEmpty) } @Test func explicitTableFormat() throws { let (_, out, _, status) = try run(arguments: ["system", "status", "--format", "table"]) // If apiserver is not running, skip validation guard status == 0 else { return } #expect(!out.isEmpty) let lines = out.trimmingCharacters(in: .whitespacesAndNewlines) .components(separatedBy: .newlines) #expect(lines.count >= 2) #expect(lines[0].contains("FIELD") && lines[0].contains("VALUE")) let fullOutput = lines.joined(separator: "\n") #expect(fullOutput.contains("status")) #expect(fullOutput.contains("running")) } @Test func statusFieldsMatch() throws { // Validate that JSON and table outputs contain the same information let (jsonData, _, _, jsonStatus) = try run(arguments: ["system", "status", "--format", "json"]) let (_, tableOut, _, tableStatus) = try run(arguments: ["system", "status", "--format", "table"]) #expect(jsonStatus == tableStatus) // If apiserver is not running, just verify consistency guard jsonStatus == 0 else { return } let decoded = try JSONDecoder().decode(StatusJSON.self, from: jsonData) // Verify table output contains key values from JSON #expect(tableOut.contains(decoded.status)) #expect(tableOut.contains(decoded.appRoot)) #expect(tableOut.contains(decoded.installRoot)) #expect(tableOut.contains(decoded.apiServerVersion)) #expect(tableOut.contains(decoded.apiServerCommit)) #expect(tableOut.contains(decoded.apiServerBuild)) #expect(tableOut.contains(decoded.apiServerAppName)) } @Test func jsonOutputValidStructure() throws { let (data, _, _, status) = try run(arguments: ["system", "status", "--format", "json"]) // Should always produce valid JSON regardless of status #expect(throws: Never.self) { _ = try JSONDecoder().decode(StatusJSON.self, from: data) } let decoded = try JSONDecoder().decode(StatusJSON.self, from: data) if status == 0 { // When running, all fields should be populated #expect(decoded.status == "running") #expect(!decoded.appRoot.isEmpty) #expect(!decoded.installRoot.isEmpty) #expect(!decoded.apiServerVersion.isEmpty) } else { // When not running, status should indicate the issue #expect(decoded.status == "not running" || decoded.status == "unregistered") } } @Test func prefixOption() throws { // Test with explicit prefix (should work the same as default) let (_, out, _, status) = try run(arguments: ["system", "status", "--prefix", "com.apple.container."]) guard status == 0 else { return } #expect(!out.isEmpty) let lines = out.trimmingCharacters(in: .whitespacesAndNewlines) .components(separatedBy: .newlines) #expect(lines.count >= 2) } } ================================================ FILE: Tests/CLITests/Subcommands/System/TestCLIVersion.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 /// Tests for `container system version` output formats and build type detection. final class TestCLIVersion: CLITest { struct VersionInfo: Codable { let version: String let buildType: String let commit: String let appName: String } struct VersionJSON: Codable { let version: String let buildType: String let commit: String let appName: String let server: VersionInfo? } private func expectedBuildType() throws -> String { let path = try executablePath if path.path.contains("/debug/") { return "debug" } else if path.path.contains("/release/") { return "release" } // Fallback: prefer debug when ambiguous (matches SwiftPM default for tests) return "debug" } @Test func defaultDisplaysTable() throws { let (data, out, err, status) = try run(arguments: ["system", "version"]) // default is table #expect(status == 0, "system version should succeed, stderr: \(err)") #expect(!out.isEmpty) // Validate table structure let lines = out.trimmingCharacters(in: .whitespacesAndNewlines) .components(separatedBy: .newlines) #expect(lines.count >= 2) // header + at least CLI row #expect(lines[0].contains("COMPONENT") && lines[0].contains("VERSION") && lines[0].contains("BUILD") && lines[0].contains("COMMIT")) #expect(lines[1].hasPrefix("CLI ")) // Build should reflect the binary we are running (debug/release) let expected = try expectedBuildType() #expect(lines.joined(separator: "\n").contains(" CLI ")) #expect(lines.joined(separator: "\n").contains(" \(expected) ")) _ = data // silence unused warning if assertions short-circuit } @Test func jsonFormat() throws { let (data, out, err, status) = try run(arguments: ["system", "version", "--format", "json"]) #expect(status == 0, "system version --format json should succeed, stderr: \(err)") #expect(!out.isEmpty) let decoded = try JSONDecoder().decode(VersionJSON.self, from: data) #expect(decoded.appName == "container CLI") #expect(!decoded.version.isEmpty) #expect(!decoded.commit.isEmpty) let expected = try expectedBuildType() #expect(decoded.buildType == expected) } @Test func explicitTableFormat() throws { let (_, out, err, status) = try run(arguments: ["system", "version", "--format", "table"]) #expect(status == 0, "system version --format table should succeed, stderr: \(err)") #expect(!out.isEmpty) let lines = out.trimmingCharacters(in: .whitespacesAndNewlines) .components(separatedBy: .newlines) #expect(lines.count >= 2) #expect(lines[0].contains("COMPONENT") && lines[0].contains("VERSION") && lines[0].contains("BUILD") && lines[0].contains("COMMIT")) #expect(lines[1].hasPrefix("CLI ")) } @Test func buildTypeMatchesBinary() throws { // Validate build type via JSON to avoid parsing table text loosely let (data, _, err, status) = try run(arguments: ["system", "version", "--format", "json"]) #expect(status == 0, "version --format json should succeed, stderr: \(err)") let decoded = try JSONDecoder().decode(VersionJSON.self, from: data) let expected = try expectedBuildType() #expect(decoded.buildType == expected, "Expected build type \(expected) but got \(decoded.buildType)") } } ================================================ FILE: Tests/CLITests/Subcommands/System/TestKernelSet.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import ContainerPersistence import ContainerizationArchive import Foundation import Testing // This suite is run serialized since each test modifies the global default kernel @Suite(.serialized) class TestCLIKernelSet: CLITest { let defaultKernelTar = DefaultsStore.get(key: .defaultKernelURL) var remoteTar: URL! { URL(string: defaultKernelTar) } let defaultBinaryPath = DefaultsStore.get(key: .defaultKernelBinaryPath) deinit { try? resetDefaultBinary() } func resetDefaultBinary() throws { let arguments: [String] = [ "system", "kernel", "set", "--recommended", "--force", ] let (_, _, error, status) = try run(arguments: arguments) if status != 0 { throw CLIError.executionFailed("failed to reset kernel to recommended: \(error)") } } func doKernelSet(extraArgs: [String]) throws { var arguments = [ "system", "kernel", "set", "--force", ] arguments.append(contentsOf: extraArgs) let (_, _, error, status) = try run(arguments: arguments) if status != 0 { throw CLIError.executionFailed("failed to set kernel: \(error)") } } func validateContainerRun() throws { let name = getTestName() try doLongRun(name: name, args: []) defer { try? doStop(name: name) } _ = try doExec(name: name, cmd: ["date"]) try doStop(name: name) } private func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } @Test func fromLocalTar() async throws { let symlinkBinaryPath: String = URL(filePath: defaultBinaryPath).deletingLastPathComponent().appending(path: "vmlinux.container").relativePath try await withTempDir { tempDir in // manually download the tar file let localTarPath = tempDir.appending(path: remoteTar.lastPathComponent) try await ContainerAPIClient.FileDownloader.downloadFile(url: remoteTar, to: localTarPath) let extraArgs: [String] = [ "--tar", localTarPath.path, "--binary", symlinkBinaryPath, ] try doKernelSet(extraArgs: extraArgs) try validateContainerRun() } } @Test func fromRemoteTarSymlink() throws { // opt/kata/share/kata-containers/vmlinux.container should point to opt/kata/share/kata-containers/vmlinux- in the archive let symlinkBinaryPath: String = URL(filePath: defaultBinaryPath).deletingLastPathComponent().appending(path: "vmlinux.container").relativePath let extraArgs: [String] = [ "--tar", defaultKernelTar, "--binary", symlinkBinaryPath, ] try doKernelSet(extraArgs: extraArgs) try validateContainerRun() } @Test func fromLocalDisk() async throws { try await withTempDir { tempDir in // manually download the tar file let localTarPath = tempDir.appending(path: remoteTar.lastPathComponent) try await ContainerAPIClient.FileDownloader.downloadFile(url: remoteTar, to: localTarPath) // extract just the file we want let targetPath = tempDir.appending(path: URL(string: defaultBinaryPath)!.lastPathComponent) let archiveReader = try ArchiveReader(file: localTarPath) let (_, data) = try archiveReader.extractFile(path: defaultBinaryPath) try data.write(to: targetPath, options: .atomic) let extraArgs = [ "--binary", targetPath.path, ] try doKernelSet(extraArgs: extraArgs) try validateContainerRun() } } } ================================================ FILE: Tests/CLITests/Subcommands/Volumes/TestCLIAnonymousVolumes.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import Foundation import Testing @Suite(.serialized) class TestCLIAnonymousVolumes: CLITest { override init() throws { try super.init() // Clean up any leftover resources from previous test runs cleanUpAllTestResources() } private func cleanUpAllTestResources() { // Clean up test containers (force remove) if let (_, output, _, status) = try? run(arguments: ["ls", "-a"]), status == 0 { let containers = output.components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { $0.lowercased().starts(with: "test") } for container in containers { let _ = (try? run(arguments: ["delete", "--force", container])) } } // Clean up test volumes (both anonymous and named) if let (_, output, _, status) = try? run(arguments: ["volume", "list", "--quiet"]), status == 0 { let volumes = output.components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { isValidUUID($0) || $0.lowercased().starts(with: "test") } for volume in volumes { doVolumeDeleteIfExists(name: volume) } } } private func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } func getAnonymousVolumeNames() throws -> [String] { let (_, output, error, status) = try run(arguments: ["volume", "list", "--quiet"]) guard status == 0 else { throw CLIError.executionFailed("volume list failed: \(error)") } return output.components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { isValidUUID($0) } } func volumeExists(name: String) throws -> Bool { let (_, output, _, status) = try run(arguments: ["volume", "list", "--quiet"]) guard status == 0 else { return false } let volumes = output.components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } return volumes.contains(name) } func isValidUUID(_ name: String) -> Bool { let pattern = #"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"# guard let regex = try? Regex(pattern) else { return false } return (try? regex.firstMatch(in: name)) != nil } func doVolumeCreate(name: String) throws { let (_, _, error, status) = try run(arguments: ["volume", "create", name]) if status != 0 { throw CLIError.executionFailed("volume create failed: \(error)") } } func doVolumeDeleteIfExists(name: String) { let (_, _, _, _) = (try? run(arguments: ["volume", "rm", name])) ?? (nil, "", "", 1) } func doRemoveIfExists(name: String, force: Bool = false) { var args = ["delete"] if force { args.append("--force") } args.append(name) let (_, _, _, _) = (try? run(arguments: args)) ?? (nil, "", "", 1) } @Test func testAnonymousVolumeCreationAndPersistence() async throws { let testName = getTestName() let containerName = "\(testName)_c1" defer { doRemoveIfExists(name: containerName, force: true) // Clean up anonymous volumes if let volumes = try? getAnonymousVolumeNames() { volumes.forEach { doVolumeDeleteIfExists(name: $0) } } } // Get count of anonymous volumes before let beforeCount = try getAnonymousVolumeNames().count // Run container with --rm and anonymous volume let (_, _, _, status) = try run(arguments: [ "run", "--rm", "--name", containerName, "-v", "/data", alpine, "echo", "test", ]) #expect(status == 0, "container run should succeed") // Give time for container removal to complete try await Task.sleep(for: .seconds(1)) // Verify container was removed let (_, lsOutput, _, _) = try run(arguments: ["ls", "-a"]) let containers = lsOutput.components(separatedBy: .newlines) .filter { $0.contains(containerName) } #expect(containers.isEmpty, "container should be removed with --rm") // Verify anonymous volume persists (no auto-cleanup) let afterCount = try getAnonymousVolumeNames().count #expect(afterCount == beforeCount + 1, "anonymous volume should persist even with --rm") } @Test func testAnonymousVolumePersistenceWithoutRm() throws { let testName = getTestName() let containerName = "\(testName)_c1" let testData = "persistent-data" defer { doRemoveIfExists(name: containerName, force: true) // Clean up any anonymous volumes if let volumes = try? getAnonymousVolumeNames() { volumes.forEach { doVolumeDeleteIfExists(name: $0) } } } // Run container WITHOUT --rm try doLongRun(name: containerName, args: ["-v", "/data"], autoRemove: false) try waitForContainerRunning(containerName) // Write data to anonymous volume _ = try doExec(name: containerName, cmd: ["sh", "-c", "echo '\(testData)' > /data/test.txt"]) // Get the anonymous volume ID let volumeNames = try getAnonymousVolumeNames() #expect(volumeNames.count == 1, "should have exactly one anonymous volume") let volumeID = volumeNames[0] // Stop and remove container try doStop(name: containerName) doRemoveIfExists(name: containerName, force: true) // Verify volume still exists let exists = try volumeExists(name: volumeID) #expect(exists, "anonymous volume should persist without --rm") // Mount same volume in new container and verify data let containerName2 = "\(testName)_c2" try doLongRun(name: containerName2, args: ["-v", "\(volumeID):/data"], autoRemove: false) try waitForContainerRunning(containerName2) var output = try doExec(name: containerName2, cmd: ["cat", "/data/test.txt"]) output = output.trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == testData, "data should persist in anonymous volume") // Clean up try doStop(name: containerName2) doRemoveIfExists(name: containerName2, force: true) doVolumeDeleteIfExists(name: volumeID) } @Test func testMultipleAnonymousVolumes() async throws { let testName = getTestName() let containerName = "\(testName)_c1" defer { doRemoveIfExists(name: containerName, force: true) // Clean up anonymous volumes if let volumes = try? getAnonymousVolumeNames() { volumes.forEach { doVolumeDeleteIfExists(name: $0) } } } let beforeCount = try getAnonymousVolumeNames().count // Run with multiple anonymous volumes let (_, _, _, status) = try run(arguments: [ "run", "--rm", "--name", containerName, "-v", "/data1", "-v", "/data2", "-v", "/data3", alpine, "sh", "-c", "ls -d /data*", ]) #expect(status == 0, "container run should succeed") // Give time for container removal try await Task.sleep(for: .seconds(1)) // All 3 volumes should persist (no auto-cleanup) let afterCount = try getAnonymousVolumeNames().count #expect(afterCount == beforeCount + 3, "all 3 anonymous volumes should persist") } @Test func testAnonymousMountSyntax() async throws { let testName = getTestName() let containerName = "\(testName)_c1" defer { doRemoveIfExists(name: containerName, force: true) // Clean up anonymous volumes if let volumes = try? getAnonymousVolumeNames() { volumes.forEach { doVolumeDeleteIfExists(name: $0) } } } let beforeCount = try getAnonymousVolumeNames().count // Use --mount syntax let (_, _, _, status) = try run(arguments: [ "run", "--rm", "--name", containerName, "--mount", "type=volume,dst=/mydata", alpine, "ls", "-la", "/mydata", ]) #expect(status == 0, "container run with --mount should succeed") // Give time for container removal try await Task.sleep(for: .seconds(1)) // Anonymous volume should persist (no auto-cleanup) let afterCount = try getAnonymousVolumeNames().count #expect(afterCount == beforeCount + 1, "anonymous volume should persist") } @Test func testAnonymousVolumeUUIDFormat() throws { let testName = getTestName() let containerName = "\(testName)_c1" defer { try? doStop(name: containerName) doRemoveIfExists(name: containerName, force: true) if let volumes = try? getAnonymousVolumeNames() { volumes.forEach { doVolumeDeleteIfExists(name: $0) } } } // Create container with anonymous volume try doLongRun(name: containerName, args: ["-v", "/data"]) try waitForContainerRunning(containerName) // Get the anonymous volume name let volumeNames = try getAnonymousVolumeNames() #expect(volumeNames.count == 1, "should have exactly one anonymous volume") let volumeName = volumeNames[0] // Verify UUID format: {lowercase uuid} #expect(isValidUUID(volumeName), "volume name should match UUID format: \(volumeName)") // Verify total length is 36 characters (UUID without prefix) #expect(volumeName.count == 36, "volume name should be 36 characters long") } @Test func testAnonymousVolumeMetadata() throws { let testName = getTestName() let containerName = "\(testName)_c1" defer { try? doStop(name: containerName) doRemoveIfExists(name: containerName, force: true) if let volumes = try? getAnonymousVolumeNames() { volumes.forEach { doVolumeDeleteIfExists(name: $0) } } } // Create container with anonymous volume try doLongRun(name: containerName, args: ["-v", "/data"]) try waitForContainerRunning(containerName) // Get the anonymous volume let volumeNames = try getAnonymousVolumeNames() #expect(volumeNames.count == 1, "should have exactly one anonymous volume") let volumeName = volumeNames[0] // Inspect volume in JSON format let (_, output, error, status) = try run(arguments: ["volume", "list", "--format", "json"]) #expect(status == 0, "volume list should succeed: \(error)") // Parse JSON to verify metadata let data = output.data(using: .utf8)! let volumes = try JSONDecoder().decode([Volume].self, from: data) let anonVolume = volumes.first { $0.name == volumeName } #expect(anonVolume != nil, "should find anonymous volume in list") if let vol = anonVolume { #expect(vol.isAnonymous == true, "isAnonymous should be true") } } @Test func testAnonymousVolumeListDisplay() throws { let testName = getTestName() let namedVolumeName = "\(testName)_namedvol" let containerName = "\(testName)_c1" defer { try? doStop(name: containerName) doRemoveIfExists(name: containerName, force: true) doVolumeDeleteIfExists(name: namedVolumeName) if let volumes = try? getAnonymousVolumeNames() { volumes.forEach { doVolumeDeleteIfExists(name: $0) } } } // Create named volume try doVolumeCreate(name: namedVolumeName) // Create container with anonymous volume try doLongRun(name: containerName, args: ["-v", "/data"]) try waitForContainerRunning(containerName) // List volumes let (_, output, error, status) = try run(arguments: ["volume", "list"]) #expect(status == 0, "volume list should succeed: \(error)") // Verify TYPE column exists and shows both types #expect(output.contains("TYPE"), "output should contain TYPE column") #expect(output.contains("named"), "output should show named volume type") #expect(output.contains("anonymous"), "output should show anonymous volume type") #expect(output.contains(namedVolumeName), "output should contain named volume") } @Test func testAnonymousVolumeMixedWithNamedVolume() async throws { let testName = getTestName() let namedVolumeName = "\(testName)_namedvol" let containerName = "\(testName)_c1" defer { doRemoveIfExists(name: containerName, force: true) doVolumeDeleteIfExists(name: namedVolumeName) // Clean up anonymous volumes if let volumes = try? getAnonymousVolumeNames() { volumes.forEach { doVolumeDeleteIfExists(name: $0) } } } // Create named volume try doVolumeCreate(name: namedVolumeName) let beforeAnonCount = try getAnonymousVolumeNames().count // Run with both named and anonymous volumes, with --rm let (_, _, _, status) = try run(arguments: [ "run", "--rm", "--name", containerName, "-v", "\(namedVolumeName):/named", "-v", "/anon", alpine, "sh", "-c", "ls -d /*", ]) #expect(status == 0, "container run should succeed") // Give time for container removal try await Task.sleep(for: .seconds(1)) // Named volume should still exist let namedExists = try volumeExists(name: namedVolumeName) #expect(namedExists, "named volume should persist") let afterAnonCount = try getAnonymousVolumeNames().count #expect(afterAnonCount == beforeAnonCount + 1, "anonymous volume should persist") } @Test func testAnonymousVolumeManualDeletion() throws { let testName = getTestName() let containerName = "\(testName)_c1" defer { doRemoveIfExists(name: containerName, force: true) } // Create container WITHOUT --rm try doLongRun(name: containerName, args: ["-v", "/data"], autoRemove: false) try waitForContainerRunning(containerName) // Get volume ID let volumeNames = try getAnonymousVolumeNames() #expect(volumeNames.count == 1, "should have one anonymous volume") let volumeID = volumeNames[0] // Stop container (unmounts volume) try doStop(name: containerName) doRemoveIfExists(name: containerName, force: true) // Manual deletion should succeed (volume is unmounted) let (_, _, error, status) = try run(arguments: ["volume", "rm", volumeID]) #expect(status == 0, "manual deletion of unmounted anonymous volume should succeed: \(error)") // Verify volume is gone let exists = try volumeExists(name: volumeID) #expect(!exists, "volume should be deleted") } @Test func testAnonymousVolumeDetachedMode() async throws { let testName = getTestName() let containerName = "\(testName)_c1" defer { doRemoveIfExists(name: containerName, force: true) // Clean up anonymous volumes if let volumes = try? getAnonymousVolumeNames() { volumes.forEach { doVolumeDeleteIfExists(name: $0) } } } let beforeCount = try getAnonymousVolumeNames().count // Run in detached mode with --rm let (_, _, _, status) = try run(arguments: [ "run", "-d", "--rm", "--name", containerName, "-v", "/data", alpine, "sleep", "2", ]) #expect(status == 0, "detached container run should succeed") // Wait for container to exit try await Task.sleep(for: .seconds(3)) // Container should be removed let (_, lsOutput, _, _) = try run(arguments: ["ls", "-a"]) let containers = lsOutput.components(separatedBy: .newlines) .filter { $0.contains(containerName) } #expect(containers.isEmpty, "container should be auto-removed") let afterCount = try getAnonymousVolumeNames().count #expect(afterCount == beforeCount + 1, "anonymous volume should persist") } } ================================================ FILE: Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import Foundation import Testing @Suite(.serialized) class TestCLIVolumes: CLITest { func doVolumeCreate(name: String) throws { let (_, _, error, status) = try run(arguments: ["volume", "create", name]) if status != 0 { throw CLIError.executionFailed("volume create failed: \(error)") } } func doVolumeDelete(name: String) throws { let (_, _, error, status) = try run(arguments: ["volume", "rm", name]) if status != 0 { throw CLIError.executionFailed("volume delete failed: \(error)") } } func doVolumeDeleteIfExists(name: String) { let (_, _, _, _) = (try? run(arguments: ["volume", "rm", name])) ?? (nil, "", "", 1) } func doRemoveIfExists(name: String, force: Bool = false) { var args = ["delete"] if force { args.append("--force") } args.append(name) let (_, _, _, _) = (try? run(arguments: args)) ?? (nil, "", "", 1) } func doesVolumeDeleteFail(name: String) throws -> Bool { let (_, _, _, status) = try run(arguments: ["volume", "rm", name]) return status != 0 } private func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } @Test func testVolumeDataPersistenceAcrossContainers() throws { let testName = getTestName() let volumeName = "\(testName)_vol" let container1Name = "\(testName)_c1" let container2Name = "\(testName)_c2" let testData = "persistent-data-test" let testFile = "/data/test.txt" // Clean up any existing resources from previous runs doVolumeDeleteIfExists(name: volumeName) doRemoveIfExists(name: container1Name, force: true) doRemoveIfExists(name: container2Name, force: true) defer { // Clean up containers and volume try? doStop(name: container1Name) doRemoveIfExists(name: container1Name, force: true) try? doStop(name: container2Name) doRemoveIfExists(name: container2Name, force: true) doVolumeDeleteIfExists(name: volumeName) } // Create volume try doVolumeCreate(name: volumeName) // Run first container with volume, write data, then stop try doLongRun(name: container1Name, args: ["-v", "\(volumeName):/data"]) try waitForContainerRunning(container1Name) // Write test data to the volume _ = try doExec(name: container1Name, cmd: ["sh", "-c", "echo '\(testData)' > \(testFile)"]) // Stop first container try doStop(name: container1Name) // Run second container with same volume try doLongRun(name: container2Name, args: ["-v", "\(volumeName):/data"]) try waitForContainerRunning(container2Name) // Verify data persisted var output = try doExec(name: container2Name, cmd: ["cat", testFile]) output = output.trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == testData, "expected persisted data '\(testData)', instead got '\(output)'") try doStop(name: container2Name) try doVolumeDelete(name: volumeName) } @Test func testVolumeSharedAccessConflict() throws { let testName = getTestName() let volumeName = "\(testName)_vol" let container1Name = "\(testName)_c1" let container2Name = "\(testName)_c2" // Clean up any existing resources from previous runs doVolumeDeleteIfExists(name: volumeName) doRemoveIfExists(name: container1Name, force: true) doRemoveIfExists(name: container2Name, force: true) defer { // Clean up containers and volume try? doStop(name: container1Name) doRemoveIfExists(name: container1Name, force: true) try? doStop(name: container2Name) doRemoveIfExists(name: container2Name, force: true) doVolumeDeleteIfExists(name: volumeName) } // Create volume try doVolumeCreate(name: volumeName) // Run first container with volume try doLongRun(name: container1Name, args: ["-v", "\(volumeName):/data"]) try waitForContainerRunning(container1Name) // Try to run second container with same volume - should fail let (_, _, _, status) = try run(arguments: ["run", "--name", container2Name, "-v", "\(volumeName):/data", alpine] + defaultContainerArgs) #expect(status != 0, "second container should fail when trying to use volume already in use") // Clean up try doStop(name: container1Name) doRemoveIfExists(name: container1Name, force: true) doVolumeDeleteIfExists(name: volumeName) } @Test func testVolumeDeleteProtectionWhileInUse() throws { let testName = getTestName() let volumeName = "\(testName)_vol" let containerName = "\(testName)_c1" // Clean up any existing resources from previous runs doVolumeDeleteIfExists(name: volumeName) doRemoveIfExists(name: containerName, force: true) defer { // Clean up container and volume try? doStop(name: containerName) doRemoveIfExists(name: containerName, force: true) doVolumeDeleteIfExists(name: volumeName) } // Create volume try doVolumeCreate(name: volumeName) // Run container with volume try doLongRun(name: containerName, args: ["-v", "\(volumeName):/data"]) try waitForContainerRunning(containerName) // Try to delete volume while container is running - should fail let deleteFailedWhileInUse = try doesVolumeDeleteFail(name: volumeName) #expect(deleteFailedWhileInUse, "volume delete should fail while volume is in use") // Stop container try doStop(name: containerName) doRemoveIfExists(name: containerName, force: true) // Now volume delete should succeed try doVolumeDelete(name: volumeName) } @Test func testVolumeDeleteProtectionWithCreatedContainer() async throws { let testName = getTestName() let volumeName = "\(testName)_vol" let containerName = "\(testName)_c1" // Clean up any existing resources from previous runs doVolumeDeleteIfExists(name: volumeName) doRemoveIfExists(name: containerName, force: true) defer { // Clean up container and volume try? doStop(name: containerName) doRemoveIfExists(name: containerName, force: true) doVolumeDeleteIfExists(name: volumeName) } // Create volume try doVolumeCreate(name: volumeName) // Create (but don't start) container with volume try doCreate(name: containerName, image: alpine, volumes: ["\(volumeName):/mnt/data"]) // Give some time for container to be fully registered try await Task.sleep(for: .seconds(1)) // Try to delete volume while container is created - should fail let deleteFailedWhileInUse = try doesVolumeDeleteFail(name: volumeName) #expect(deleteFailedWhileInUse, "volume delete should fail when volume is used by created container") // Remove the container doRemoveIfExists(name: containerName, force: true) // Now volume delete should succeed doVolumeDeleteIfExists(name: volumeName) } @Test func testVolumeBasicOperations() throws { let testName = getTestName() let volumeName = "\(testName)_vol" // Clean up any existing resources from previous runs doVolumeDeleteIfExists(name: volumeName) defer { doVolumeDeleteIfExists(name: volumeName) } // Create volume try doVolumeCreate(name: volumeName) // List volumes and verify it exists let (_, output, error, status) = try run(arguments: ["volume", "list", "--quiet"]) if status != 0 { throw CLIError.executionFailed("volume list failed: \(error)") } let volumes = output.components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } #expect(volumes.contains(volumeName), "created volume should appear in list") // Inspect volume let (_, inspectOutput, inspectError, inspectStatus) = try run(arguments: ["volume", "inspect", volumeName]) if inspectStatus != 0 { throw CLIError.executionFailed("volume inspect failed: \(inspectError)") } #expect(inspectOutput.contains(volumeName), "volume inspect should contain volume name") // Delete volume try doVolumeDelete(name: volumeName) } @Test func testImplicitNamedVolumeCreation() throws { let testName = getTestName() let containerName = "\(testName)_c1" let volumeName = "\(testName)_autovolume" defer { doRemoveIfExists(name: containerName, force: true) doVolumeDeleteIfExists(name: volumeName) } // Verify volume doesn't exist yet let (_, listOutput, _, _) = try run(arguments: ["volume", "list", "--quiet"]) let volumeExistsBefore = listOutput.contains(volumeName) #expect(!volumeExistsBefore, "volume should not exist initially") // Run container with non-existent named volume - should auto-create let (_, output, _, status) = try run(arguments: [ "run", "--name", containerName, "-v", "\(volumeName):/data", alpine, "echo", "test", ]) // Should succeed and create volume automatically #expect(status == 0, "should succeed and auto-create named volume") #expect(output.contains("test"), "container should run successfully") // Volume should now exist let (_, listOutputAfter, _, _) = try run(arguments: ["volume", "list", "--quiet"]) let volumeExistsAfter = listOutputAfter.contains(volumeName) #expect(volumeExistsAfter, "volume should be created") } @Test func testImplicitNamedVolumeReuse() throws { let testName = getTestName() let containerName1 = "\(testName)_c1" let containerName2 = "\(testName)_c2" let volumeName = "\(testName)_sharedvolume" defer { doRemoveIfExists(name: containerName1, force: true) doRemoveIfExists(name: containerName2, force: true) doVolumeDeleteIfExists(name: volumeName) } // First container - should auto-create volume let (_, _, _, status1) = try run(arguments: [ "run", "--name", containerName1, "-v", "\(volumeName):/data", alpine, "sh", "-c", "echo 'first' > /data/test.txt", ]) #expect(status1 == 0, "first container should succeed") // Second container - should reuse existing volume let (_, _, _, status2) = try run(arguments: [ "run", "--name", containerName2, "-v", "\(volumeName):/data", alpine, "cat", "/data/test.txt", ]) #expect(status2 == 0, "second container should succeed") } @Test func testVolumePruneNoVolumes() throws { // Prune with no volumes should succeed with 0 reclaimed let (_, output, error, status) = try run(arguments: ["volume", "prune"]) if status != 0 { throw CLIError.executionFailed("volume prune failed: \(error)") } #expect(output.contains("Zero KB"), "should show no space reclaimed") } @Test func testVolumePruneUnusedVolumes() throws { let testName = getTestName() let volumeName1 = "\(testName)_vol1" let volumeName2 = "\(testName)_vol2" // Clean up any existing resources from previous runs doVolumeDeleteIfExists(name: volumeName1) doVolumeDeleteIfExists(name: volumeName2) defer { doVolumeDeleteIfExists(name: volumeName1) doVolumeDeleteIfExists(name: volumeName2) } try doVolumeCreate(name: volumeName1) try doVolumeCreate(name: volumeName2) let (_, listBefore, _, statusBefore) = try run(arguments: ["volume", "list", "--quiet"]) #expect(statusBefore == 0) #expect(listBefore.contains(volumeName1)) #expect(listBefore.contains(volumeName2)) // Prune should remove both let (_, output, error, status) = try run(arguments: ["volume", "prune"]) if status != 0 { throw CLIError.executionFailed("volume prune failed: \(error)") } #expect(output.contains(volumeName1) || !output.contains("No volumes to prune"), "should prune volume1") #expect(output.contains(volumeName2) || !output.contains("No volumes to prune"), "should prune volume2") #expect(output.contains("Reclaimed"), "should show reclaimed space") // Verify volumes are gone let (_, listAfter, _, statusAfter) = try run(arguments: ["volume", "list", "--quiet"]) #expect(statusAfter == 0) #expect(!listAfter.contains(volumeName1), "volume1 should be pruned") #expect(!listAfter.contains(volumeName2), "volume2 should be pruned") } @Test func testVolumePruneSkipsVolumeInUse() throws { let testName = getTestName() let volumeInUse = "\(testName)_inuse" let volumeUnused = "\(testName)_unused" let containerName = "\(testName)_c1" // Clean up any existing resources from previous runs doVolumeDeleteIfExists(name: volumeInUse) doVolumeDeleteIfExists(name: volumeUnused) doRemoveIfExists(name: containerName, force: true) defer { try? doStop(name: containerName) doRemoveIfExists(name: containerName, force: true) doVolumeDeleteIfExists(name: volumeInUse) doVolumeDeleteIfExists(name: volumeUnused) } try doVolumeCreate(name: volumeInUse) try doVolumeCreate(name: volumeUnused) try doLongRun(name: containerName, args: ["-v", "\(volumeInUse):/data"]) try waitForContainerRunning(containerName) // Prune should only remove the unused volume let (_, _, error, status) = try run(arguments: ["volume", "prune"]) if status != 0 { throw CLIError.executionFailed("volume prune failed: \(error)") } // Verify in-use volume still exists let (_, listAfter, _, statusAfter) = try run(arguments: ["volume", "list", "--quiet"]) #expect(statusAfter == 0) #expect(listAfter.contains(volumeInUse), "volume in use should NOT be pruned") #expect(!listAfter.contains(volumeUnused), "unused volume should be pruned") try doStop(name: containerName) doRemoveIfExists(name: containerName, force: true) doVolumeDeleteIfExists(name: volumeInUse) } @Test func testVolumePruneSkipsVolumeAttachedToStoppedContainer() async throws { let testName = getTestName() let volumeName = "\(testName)_vol" let containerName = "\(testName)_c1" // Clean up any existing resources from previous runs doVolumeDeleteIfExists(name: volumeName) doRemoveIfExists(name: containerName, force: true) defer { doRemoveIfExists(name: containerName, force: true) doVolumeDeleteIfExists(name: volumeName) } try doVolumeCreate(name: volumeName) try doCreate(name: containerName, image: alpine, volumes: ["\(volumeName):/data"]) try await Task.sleep(for: .seconds(1)) // Prune should NOT remove the volume (container exists, even if stopped) let (_, _, error, status) = try run(arguments: ["volume", "prune"]) if status != 0 { throw CLIError.executionFailed("volume prune failed: \(error)") } let (_, listAfter, _, statusAfter) = try run(arguments: ["volume", "list", "--quiet"]) #expect(statusAfter == 0) #expect(listAfter.contains(volumeName), "volume attached to stopped container should NOT be pruned") doRemoveIfExists(name: containerName, force: true) let (_, _, error2, status2) = try run(arguments: ["volume", "prune"]) if status2 != 0 { throw CLIError.executionFailed("volume prune failed: \(error2)") } // Verify volume is gone let (_, listFinal, _, statusFinal) = try run(arguments: ["volume", "list", "--quiet"]) #expect(statusFinal == 0) #expect(!listFinal.contains(volumeName), "volume should be pruned after container is deleted") } } ================================================ FILE: Tests/CLITests/TestCLINoParallelCases.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerAPIClient import ContainerPersistence import ContainerizationExtras import ContainerizationOCI import Foundation import Testing /// Tests that need total control over environment to avoid conflicts. @Suite(.serialized) class TestCLINoParallelCases: CLITest { func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } func getLowercasedTestName() -> String { getTestName().lowercased() } @Test func testImageSingleConcurrentDownload() throws { // removing this image during parallel tests breaks stuff! _ = try? run(arguments: ["image", "rm", alpine]) defer { _ = try? run(arguments: ["image", "rm", "--all"]) } do { try doPull(imageName: alpine, args: ["--max-concurrent-downloads", "1"]) let imagePresent = try isImagePresent(targetImage: alpine) #expect(imagePresent, "Expected image to be pulled with maxConcurrentDownloads=1") } catch { Issue.record("failed to pull image with maxConcurrentDownloads flag: \(error)") return } } @Test func testImageManyConcurrentDownloads() throws { // removing this image during parallel tests breaks stuff! _ = try? run(arguments: ["image", "rm", alpine]) defer { _ = try? run(arguments: ["image", "rm", "--all"]) } do { try doPull(imageName: alpine, args: ["--max-concurrent-downloads", "64"]) let imagePresent = try isImagePresent(targetImage: alpine) #expect(imagePresent, "Expected image to be pulled with maxConcurrentDownloads=64") } catch { Issue.record("failed to pull image with maxConcurrentDownloads flag: \(error)") return } } @Test func testImagePruneNoImages() throws { // Prune with no images should succeed _ = try? run(arguments: ["image", "rm", "--all"]) let (_, output, error, status) = try run(arguments: ["image", "prune"]) if status != 0 { throw CLIError.executionFailed("image prune failed: \(error)") } #expect(output.contains("Zero KB"), "should show no space reclaimed") } @Test func testImagePruneUnusedImages() throws { // 1. Pull the images _ = try? run(arguments: ["image", "rm", "--all"]) defer { _ = try? run(arguments: ["image", "rm", "--all"]) } try doPull(imageName: alpine) try doPull(imageName: busybox) // 2. Verify the images are present let alpinePresent = try isImagePresent(targetImage: alpine) #expect(alpinePresent, "expected to see image \(alpine) pulled") let busyBoxPresent = try isImagePresent(targetImage: busybox) #expect(busyBoxPresent, "expected to see image \(busybox) pulled") // 3. Prune with the -a flag should remove all unused images let (_, output, error, status) = try run(arguments: ["image", "prune", "-a"]) if status != 0 { throw CLIError.executionFailed("image prune failed: \(error)") } #expect(output.contains(alpine), "should prune alpine image") #expect(output.contains(busybox), "should prune busybox image") // 4. Verify the images are gone let alpineRemoved = try !isImagePresent(targetImage: alpine) #expect(alpineRemoved, "expected image \(alpine) to be removed") let busyboxRemoved = try !isImagePresent(targetImage: busybox) #expect(busyboxRemoved, "expected image \(busybox) to be removed") } @Test func testImagePruneDanglingImages() throws { let name = getTestName() let containerName = "\(name)_container" // 1. Pull the images _ = try? run(arguments: ["image", "rm", "--all"]) defer { _ = try? run(arguments: ["image", "rm", "--all"]) } _ = try? run(arguments: ["rm", "--all", "--force"]) defer { _ = try? run(arguments: ["rm", "--all", "--force"]) } try doPull(imageName: alpine) try doPull(imageName: busybox) // 2. Verify the images are present let alpinePresent = try isImagePresent(targetImage: alpine) #expect(alpinePresent, "expected to see image \(alpine) pulled") let busyBoxPresent = try isImagePresent(targetImage: busybox) #expect(busyBoxPresent, "expected to see image \(busybox) pulled") // 3. Create a running container based on alpine try doLongRun( name: containerName, image: alpine ) try waitForContainerRunning(containerName) // 4. Prune should only remove the dangling image let (_, output, error, status) = try run(arguments: ["image", "prune", "-a"]) if status != 0 { throw CLIError.executionFailed("image prune failed: \(error)") } #expect(output.contains(busybox), "should prune busybox image") // 5. Verify the busybox image is gone let busyboxRemoved = try !isImagePresent(targetImage: busybox) #expect(busyboxRemoved, "expected image \(busybox) to be removed") // 6. Verify the alpine image still exists let alpineStillPresent = try isImagePresent(targetImage: alpine) #expect(alpineStillPresent, "expected image \(alpine) to remain") } @available(macOS 26, *) @Test func testNetworkPruneNoNetworks() throws { // Ensure the testnetworkcreateanduse network is deleted // Clean up is necessary for testing prune with no networks doNetworkDeleteIfExists(name: "testnetworkcreateanduse") // Prune with no networks should succeed let (_, _, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) #expect(statusBefore == 0) let (_, output, error, status) = try run(arguments: ["network", "prune"]) if status != 0 { throw CLIError.executionFailed("network prune failed: \(error)") } #expect(output.isEmpty, "should show no networks pruned") } @available(macOS 26, *) @Test func testNetworkPruneUnusedNetworks() throws { let name = getTestName() let network1 = "\(name)_1" let network2 = "\(name)_2" // Clean up any existing resources from previous runs doNetworkDeleteIfExists(name: network1) doNetworkDeleteIfExists(name: network2) defer { doNetworkDeleteIfExists(name: network1) doNetworkDeleteIfExists(name: network2) } try doNetworkCreate(name: network1) try doNetworkCreate(name: network2) // Verify networks are created let (_, listBefore, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) #expect(statusBefore == 0) #expect(listBefore.contains(network1)) #expect(listBefore.contains(network2)) // Prune should remove both let (_, output, error, status) = try run(arguments: ["network", "prune"]) if status != 0 { throw CLIError.executionFailed("network prune failed: \(error)") } #expect(output.contains(network1), "should prune network1") #expect(output.contains(network2), "should prune network2") // Verify networks are gone let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) #expect(statusAfter == 0) #expect(!listAfter.contains(network1), "network1 should be pruned") #expect(!listAfter.contains(network2), "network2 should be pruned") } @available(macOS 26, *) @Test func testNetworkPruneSkipsNetworksInUse() throws { let name = getTestName() let containerName = "\(name)_c1" let networkInUse = "\(name)_inuse" let networkUnused = "\(name)_unused" // Clean up any existing resources from previous runs try? doStop(name: containerName) try? doRemove(name: containerName) doNetworkDeleteIfExists(name: networkInUse) doNetworkDeleteIfExists(name: networkUnused) defer { try? doStop(name: containerName) try? doRemove(name: containerName) doNetworkDeleteIfExists(name: networkInUse) doNetworkDeleteIfExists(name: networkUnused) } try doNetworkCreate(name: networkInUse) try doNetworkCreate(name: networkUnused) // Verify networks are created let (_, listBefore, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) #expect(statusBefore == 0) #expect(listBefore.contains(networkInUse)) #expect(listBefore.contains(networkUnused)) // Creation of container with network connection let port = UInt16.random(in: 50000..<60000) try doLongRun( name: containerName, image: "docker.io/library/python:alpine", args: ["--network", networkInUse], containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"] ) try waitForContainerRunning(containerName) let container = try inspectContainer(containerName) #expect(container.networks.count > 0) // Prune should only remove the unused network let (_, _, error, status) = try run(arguments: ["network", "prune"]) if status != 0 { throw CLIError.executionFailed("network prune failed: \(error)") } // Verify in-use network still exists let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) #expect(statusAfter == 0) #expect(listAfter.contains(networkInUse), "network in use should NOT be pruned") #expect(!listAfter.contains(networkUnused), "unused network should be pruned") } @available(macOS 26, *) @Test func testNetworkPruneSkipsNetworkAttachedToStoppedContainer() async throws { let name = getTestName() let containerName = "\(name)_c1" let networkName = "\(name)" // Clean up any existing resources from previous runs try? doStop(name: containerName) try? doRemove(name: containerName) doNetworkDeleteIfExists(name: networkName) defer { try? doStop(name: containerName) try? doRemove(name: containerName) doNetworkDeleteIfExists(name: networkName) } try doNetworkCreate(name: networkName) // Creation of container with network connection let port = UInt16.random(in: 50000..<60000) try doLongRun( name: containerName, image: "docker.io/library/python:alpine", args: ["--network", networkName], containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"] ) try await Task.sleep(for: .seconds(1)) // Prune should NOT remove the network (container exists, even if stopped) let (_, _, error, status) = try run(arguments: ["network", "prune"]) if status != 0 { throw CLIError.executionFailed("network prune failed: \(error)") } let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) #expect(statusAfter == 0) #expect(listAfter.contains(networkName), "network attached to stopped container should NOT be pruned") try? doStop(name: containerName) try? doRemove(name: containerName) let (_, _, error2, status2) = try run(arguments: ["network", "prune"]) if status2 != 0 { throw CLIError.executionFailed("network prune failed: \(error2)") } // Verify network is gone let (_, listFinal, _, statusFinal) = try run(arguments: ["network", "list", "--quiet"]) #expect(statusFinal == 0) #expect(!listFinal.contains(networkName), "network should be pruned after container is deleted") } // MARK: - Parser.resources (DefaultsStore-dependent) @Test func testResourcesCustomDefaults() throws { let result = try Parser.resources( cpus: nil, memory: nil, cpuPropertyKey: .defaultBuildCPUs, memoryPropertyKey: .defaultBuildMemory, defaultCPUs: 2, defaultMemoryInBytes: 2048.mib() ) #expect(result.cpus == 2) #expect(result.memoryInBytes == 2048.mib()) } @Test func testResourcesBuildPropertyLookup() throws { DefaultsStore.set(value: "8", key: .defaultBuildCPUs) DefaultsStore.set(value: "4g", key: .defaultBuildMemory) defer { DefaultsStore.unset(key: .defaultBuildCPUs) DefaultsStore.unset(key: .defaultBuildMemory) } let result = try Parser.resources( cpus: nil, memory: nil, cpuPropertyKey: .defaultBuildCPUs, memoryPropertyKey: .defaultBuildMemory, defaultCPUs: 2, defaultMemoryInBytes: 2048.mib() ) #expect(result.cpus == 8) #expect(result.memoryInBytes == 4096.mib()) } @Test func testResourcesCPUsFromProperty() throws { DefaultsStore.set(value: "8", key: .defaultContainerCPUs) defer { DefaultsStore.unset(key: .defaultContainerCPUs) } let result = try Parser.resources(cpus: nil, memory: nil) #expect(result.cpus == 8) } @Test func testResourcesMemoryFromProperty() throws { DefaultsStore.set(value: "2g", key: .defaultContainerMemory) defer { DefaultsStore.unset(key: .defaultContainerMemory) } let result = try Parser.resources(cpus: nil, memory: nil) #expect(result.memoryInBytes == 2048.mib()) } @Test func testResourcesFlagOverridesProperty() throws { DefaultsStore.set(value: "8", key: .defaultContainerCPUs) DefaultsStore.set(value: "2g", key: .defaultContainerMemory) defer { DefaultsStore.unset(key: .defaultContainerCPUs) DefaultsStore.unset(key: .defaultContainerMemory) } let result = try Parser.resources(cpus: 1, memory: "256m") #expect(result.cpus == 1) #expect(result.memoryInBytes == 256.mib()) } @Test func testResourcesPropertyKeysAreIsolated() throws { DefaultsStore.set(value: "16", key: .defaultContainerCPUs) DefaultsStore.set(value: "8g", key: .defaultContainerMemory) defer { DefaultsStore.unset(key: .defaultContainerCPUs) DefaultsStore.unset(key: .defaultContainerMemory) } let result = try Parser.resources( cpus: nil, memory: nil, cpuPropertyKey: .defaultBuildCPUs, memoryPropertyKey: .defaultBuildMemory, defaultCPUs: 2, defaultMemoryInBytes: 2048.mib() ) #expect(result.cpus == 2) #expect(result.memoryInBytes == 2048.mib()) } } ================================================ FILE: Tests/CLITests/Utilities/CLITest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import AsyncHTTPClient import ContainerLog import ContainerResource import Containerization import ContainerizationOS import Foundation import Logging import Synchronization import SystemPackage import Testing class CLITest { private static let commandSeq = Mutex(0) struct Image: Codable { let reference: String } // These structs need to track their counterpart presentation structs in CLI. struct ImageInspectOutput: Codable { let name: String let variants: [variant] struct variant: Codable { let platform: imagePlatform struct imagePlatform: Codable { let os: String let architecture: String } } } struct NetworkInspectOutput: Codable { let id: String let state: String let config: NetworkConfiguration let status: NetworkStatus? } let testName: String let testSuite: String var log: Logger init() throws { let name = Test.current.map { $0.name.hasSuffix("()") ? String($0.name.dropLast(2)) : $0.name } ?? UUID().uuidString let suite = "\(type(of: self))" self.testName = name self.testSuite = suite let logger = Logger(label: "com.apple.container.test") { label in if let logRootString = ProcessInfo.processInfo.environment["CLITEST_LOG_ROOT"], !logRootString.isEmpty { let logPath = FilePath(logRootString).appending("clitests").appending(suite).appending(name + ".log") if let handler = try? FileLogHandler(label: label, category: "clitests", path: logPath) { return handler } } return StderrLogHandler() } self.log = logger self.log[metadataKey: "testID"] = "\(name)" self.log[metadataKey: "suite"] = "\(suite)" } var testDir: URL! { let tempDir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) .appendingPathComponent(".clitests") .appendingPathComponent(testName) try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) return tempDir } let alpine = "ghcr.io/linuxcontainers/alpine:3.20" let alpine318 = "ghcr.io/linuxcontainers/alpine:3.18" let busybox = "ghcr.io/containerd/busybox:1.36" let defaultContainerArgs = ["sleep", "infinity"] var executablePath: URL { get throws { let containerPath = ProcessInfo.processInfo.environment["CONTAINER_CLI_PATH"] if let containerPath { return URL(filePath: containerPath) } let fileManager = FileManager.default let currentDir = fileManager.currentDirectoryPath let releaseURL = URL(fileURLWithPath: currentDir) .appendingPathComponent(".build") .appendingPathComponent("release") .appendingPathComponent("container") let debugURL = URL(fileURLWithPath: currentDir) .appendingPathComponent(".build") .appendingPathComponent("debug") .appendingPathComponent("container") let releaseExists = fileManager.fileExists(atPath: releaseURL.path) let debugExists = fileManager.fileExists(atPath: debugURL.path) if releaseExists && debugExists { // choose the latest build do { let releaseAttributes = try fileManager.attributesOfItem(atPath: releaseURL.path) let debugAttributes = try fileManager.attributesOfItem(atPath: debugURL.path) if let releaseDate = releaseAttributes[.modificationDate] as? Date, let debugDate = debugAttributes[.modificationDate] as? Date { return (releaseDate > debugDate) ? releaseURL : debugURL } } catch { throw CLIError.binaryAttributesNotFound(error) } } else if releaseExists { return releaseURL } else if debugExists { return debugURL } // both do not exist throw CLIError.binaryNotFound } } func run(arguments: [String], stdin: Data? = nil, currentDirectory: URL? = nil) throws -> (outputData: Data, output: String, error: String, status: Int32) { let seq = CLITest.commandSeq.withLock { counter in defer { counter += 1 } return counter } log.info( "command start", metadata: [ "seq": "\(seq)", "args": "\(arguments.joined(separator: " "))", ] ) let process = Process() process.executableURL = try executablePath process.arguments = arguments if let directory = currentDirectory { process.currentDirectoryURL = directory } let inputPipe = Pipe() let outputPipe = Pipe() let errorPipe = Pipe() process.standardInput = inputPipe process.standardOutput = outputPipe process.standardError = errorPipe let outputData: Data let errorData: Data do { try process.run() if let data = stdin { inputPipe.fileHandleForWriting.write(data) } inputPipe.fileHandleForWriting.closeFile() outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() } catch { throw CLIError.executionFailed("Failed to run CLI: \(error)") } let output = String(data: outputData, encoding: .utf8) ?? "" let error = String(data: errorData, encoding: .utf8) ?? "" log.info( "command end", metadata: [ "seq": "\(seq)", "status": "\(process.terminationStatus)", "stdout": "\(String(output.prefix(64)).debugDescription)", "stderr": "\(String(error.prefix(64)).debugDescription)", ] ) return (outputData: outputData, output: output, error: error, status: process.terminationStatus) } func runInteractive(arguments: [String], currentDirectory: URL? = nil) throws -> Terminal { let process = Process() process.executableURL = try executablePath process.arguments = arguments if let directory = currentDirectory { process.currentDirectoryURL = directory } do { let (parent, child) = try Terminal.create() process.standardInput = child.handle process.standardOutput = child.handle process.standardError = child.handle try process.run() return parent } catch { fatalError(error.localizedDescription) } } func waitForContainerRunning(_ name: String, _ totalAttempts: Int64 = 100) throws { var attempt = 0 var found = false while attempt < totalAttempts && !found { attempt += 1 let status = try? getContainerStatus(name) if status == "running" { found = true continue } sleep(1) } if !found { throw CLIError.containerNotFound(name) } } enum CLIError: Error { case executionFailed(String) case invalidInput(String) case invalidOutput(String) case containerNotFound(String) case containerRunFailed(String) case binaryNotFound case binaryAttributesNotFound(Error) } func doLongRun( name: String, image: String? = nil, args: [String]? = nil, containerArgs: [String]? = nil, autoRemove: Bool = true ) throws { var runArgs = [ "run" ] if autoRemove { runArgs.append("--rm") } runArgs.append(contentsOf: [ "--name", name, "-d", ]) if let args { runArgs.append(contentsOf: args) } runArgs.append(contentsOf: getProxyEnvironment()) if let image { runArgs.append(image) } else { runArgs.append(alpine) } if let containerArgs { runArgs.append(contentsOf: containerArgs) } else { runArgs.append(contentsOf: defaultContainerArgs) } let (_, _, error, status) = try run(arguments: runArgs) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } func doExec(name: String, cmd: [String], detach: Bool = false) throws -> String { var execArgs = [ "exec" ] execArgs.append(contentsOf: getProxyEnvironment()) if detach { execArgs.append("-d") } execArgs.append(name) execArgs.append(contentsOf: cmd) let (_, resp, error, status) = try run(arguments: execArgs) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } return resp } func doStop(name: String, signal: String = "SIGKILL") throws { let (_, _, error, status) = try run(arguments: [ "stop", "-s", signal, name, ]) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } func doCreate( name: String, image: String? = nil, args: [String]? = nil, volumes: [String] = [], networks: [String] = [], ports: [String] = [] ) throws { let image = image ?? alpine let args: [String] = args ?? ["sleep", "infinity"] var arguments = ["create", "--rm", "--name", name] arguments.append(contentsOf: getProxyEnvironment()) // Add volume mounts for volume in volumes { arguments += ["-v", volume] } // Add networks (can include properties like "network,mac=XX:XX:XX:XX:XX:XX") for network in networks { arguments += ["--network", network] } for port in ports { arguments += ["--publish", "\(port):\(port)"] } arguments += [image] + args let (_, _, error, status) = try run(arguments: arguments) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } func doStart(name: String) throws { let (_, _, error, status) = try run(arguments: [ "start", name, ]) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } struct inspectOutput: Codable { let status: String let configuration: ContainerConfiguration let networks: [ContainerResource.Attachment] } func getContainerStatus(_ name: String) throws -> String { try inspectContainer(name).status } func getContainerId(_ name: String) throws -> String { try inspectContainer(name).configuration.id } func inspectContainer(_ name: String) throws -> inspectOutput { let response = try run(arguments: [ "inspect", name, ]) let cmdStatus = response.status guard cmdStatus == 0 else { throw CLIError.executionFailed("container inspect failed: exit \(cmdStatus)") } let output = response.output guard let jsonData = output.data(using: .utf8) else { throw CLIError.invalidOutput("container inspect output invalid") } let decoder = JSONDecoder() typealias inspectOutputs = [inspectOutput] let io = try decoder.decode(inspectOutputs.self, from: jsonData) guard io.count > 0 else { throw CLIError.containerNotFound(name) } return io[0] } func inspectImage(_ name: String) throws -> String { let response = try run(arguments: [ "image", "inspect", name, ]) let cmdStatus = response.status guard cmdStatus == 0 else { throw CLIError.executionFailed("image inspect failed: exit \(cmdStatus)") } let output = response.output guard let jsonData = output.data(using: .utf8) else { throw CLIError.invalidOutput("image inspect output invalid") } let decoder = JSONDecoder() struct inspectOutput: Codable { let name: String } typealias inspectOutputs = [inspectOutput] let io = try decoder.decode(inspectOutputs.self, from: jsonData) guard io.count > 0 else { throw CLIError.containerNotFound(name) } return io[0].name } func doPull(imageName: String, args: [String]? = nil) throws { var pullArgs = [ "image", "pull", ] if let args { pullArgs.append(contentsOf: args) } pullArgs.append(imageName) let (_, _, error, status) = try run(arguments: pullArgs) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } func doImageListQuite() throws -> [String] { let args = [ "image", "list", "-q", ] let (_, out, error, status) = try run(arguments: args) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } return out.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines) } func doInspectImages(image: String) throws -> [ImageInspectOutput] { let (_, output, error, status) = try run(arguments: [ "image", "inspect", image, ]) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } guard let jsonData = output.data(using: .utf8) else { throw CLIError.invalidOutput("image inspect output invalid \(output)") } let decoder = JSONDecoder() return try decoder.decode([ImageInspectOutput].self, from: jsonData) } func doDefaultRegistrySet(domain: String) throws { let args = [ "system", "property", "set", "registry.domain", domain, ] let (_, _, error, status) = try run(arguments: args) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } func doDefaultRegistryUnset() throws { let args = [ "system", "property", "clear", "registry.domain", ] let (_, _, error, status) = try run(arguments: args) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } func doRemove(name: String, force: Bool = false) throws { var args = ["delete"] if force { args.append("--force") } args.append(name) let (_, _, error, status) = try run(arguments: args) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } func getClient(useHttpProxy: Bool) -> HTTPClient { var httpConfiguration = HTTPClient.Configuration() let proxyConfig: HTTPClient.Configuration.Proxy? = { guard useHttpProxy else { return nil } let proxyEnv = ProcessInfo.processInfo.environment["HTTP_PROXY"] guard let proxyEnv else { return nil } guard let url = URL(string: proxyEnv), let host = url.host(), let port = url.port else { return nil } return .server(host: host, port: port) }() httpConfiguration.proxy = proxyConfig return HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration) } func withTempDir(_ body: (URL) async throws -> T) async throws -> T { let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } return try await body(tempDir) } func doRemoveImages(images: [String]? = nil) throws { var args = [ "image", "rm", ] if let images { args.append(contentsOf: images) } else { args.append("--all") } let (_, _, error, status) = try run(arguments: args) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } func isImagePresent(targetImage: String) throws -> Bool { let images = try doListImages() return images.contains(where: { image in if image.reference == targetImage { return true } return false }) } func doListImages() throws -> [Image] { let (_, output, error, status) = try run(arguments: [ "image", "list", "--format", "json", ]) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } guard let jsonData = output.data(using: .utf8) else { throw CLIError.invalidOutput("image list output invalid \(output)") } let decoder = JSONDecoder() return try decoder.decode([Image].self, from: jsonData) } func doImageTag(image: String, newName: String) throws { let tagArgs = [ "image", "tag", image, newName, ] let (_, _, error, status) = try run(arguments: tagArgs) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } func doNetworkCreate(name: String) throws { let (_, _, error, status) = try run(arguments: ["network", "create", name]) if status != 0 { throw CLIError.executionFailed("network create failed: \(error)") } } func doNetworkDeleteIfExists(name: String) { let (_, _, _, _) = (try? run(arguments: ["network", "rm", name])) ?? (nil, "", "", 1) } private func getProxyEnvironment() -> [String] { let proxyVars = Set([ "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy", ]) return ProcessInfo.processInfo.environment .filter { (key, val) in proxyVars.contains(key) } .flatMap { (key, val) in ["-e", "\(key)=\(val)"] } } func doExport(name: String, filepath: String) throws { let (_, _, error, status) = try run(arguments: [ "export", name, "-o", filepath, ]) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } } } ================================================ FILE: Tests/ContainerAPIClientTests/ArchTests.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Testing @testable import ContainerAPIClient struct ArchTests { @Test func testAmd64Initialization() throws { let arch = Arch(rawValue: "amd64") #expect(arch != nil) #expect(arch == .amd64) } @Test func testX86_64Alias() throws { let arch = Arch(rawValue: "x86_64") #expect(arch != nil) #expect(arch == .amd64) } @Test func testX86_64WithDashAlias() throws { let arch = Arch(rawValue: "x86-64") #expect(arch != nil) #expect(arch == .amd64) } @Test func testArm64Initialization() throws { let arch = Arch(rawValue: "arm64") #expect(arch != nil) #expect(arch == .arm64) } @Test func testAarch64Alias() throws { let arch = Arch(rawValue: "aarch64") #expect(arch != nil) #expect(arch == .arm64) } @Test func testCaseInsensitive() throws { #expect(Arch(rawValue: "AMD64") == .amd64) #expect(Arch(rawValue: "X86_64") == .amd64) #expect(Arch(rawValue: "ARM64") == .arm64) #expect(Arch(rawValue: "AARCH64") == .arm64) #expect(Arch(rawValue: "Amd64") == .amd64) } @Test func testInvalidArchitecture() throws { #expect(Arch(rawValue: "invalid") == nil) #expect(Arch(rawValue: "i386") == nil) #expect(Arch(rawValue: "powerpc") == nil) #expect(Arch(rawValue: "") == nil) } @Test func testRawValueRoundTrip() throws { #expect(Arch.amd64.rawValue == "amd64") #expect(Arch.arm64.rawValue == "arm64") } } ================================================ FILE: Tests/ContainerAPIClientTests/DefaultPlatformTests.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import ContainerizationOCI import Testing @testable import ContainerAPIClient struct DefaultPlatformTests { // MARK: - fromEnvironment @Test func testFromEnvironmentWithLinuxAmd64() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/amd64"] let result = try DefaultPlatform.fromEnvironment(environment: env) #expect(result != nil) #expect(result?.os == "linux") #expect(result?.architecture == "amd64") } @Test func testFromEnvironmentWithLinuxArm64() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm64"] let result = try DefaultPlatform.fromEnvironment(environment: env) #expect(result != nil) #expect(result?.os == "linux") #expect(result?.architecture == "arm64") } @Test func testFromEnvironmentNotSet() throws { let env: [String: String] = [:] let result = try DefaultPlatform.fromEnvironment(environment: env) #expect(result == nil) } @Test func testFromEnvironmentEmptyString() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": ""] let result = try DefaultPlatform.fromEnvironment(environment: env) #expect(result == nil) } @Test func testFromEnvironmentInvalidPlatformThrows() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "not-a-valid-platform"] #expect { _ = try DefaultPlatform.fromEnvironment(environment: env) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("CONTAINER_DEFAULT_PLATFORM") && error.description.contains("not-a-valid-platform") } } @Test func testFromEnvironmentIgnoresOtherVariables() throws { let env = ["SOME_OTHER_VAR": "linux/amd64"] let result = try DefaultPlatform.fromEnvironment(environment: env) #expect(result == nil) } @Test func testFromEnvironmentWithVariant() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm/v7"] let result = try DefaultPlatform.fromEnvironment(environment: env) #expect(result != nil) #expect(result?.os == "linux") #expect(result?.architecture == "arm") #expect(result?.variant == "v7") } // MARK: - resolve (optional os/arch, used by image pull/push/save) @Test func testResolveExplicitPlatformWins() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm64"] let result = try DefaultPlatform.resolve( platform: "linux/amd64", os: nil, arch: nil, environment: env ) #expect(result != nil) #expect(result?.architecture == "amd64") #expect(result?.os == "linux") } @Test func testResolveExplicitArchWinsOverEnvVar() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm64"] let result = try DefaultPlatform.resolve( platform: nil, os: nil, arch: "amd64", environment: env ) #expect(result != nil) #expect(result?.architecture == "amd64") #expect(result?.os == "linux") } @Test func testResolveExplicitOsAndArchWinOverEnvVar() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm64"] let result = try DefaultPlatform.resolve( platform: nil, os: "linux", arch: "amd64", environment: env ) #expect(result != nil) #expect(result?.architecture == "amd64") } @Test func testResolveExplicitOsWinsOverEnvVar() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm64"] let result = try DefaultPlatform.resolve( platform: nil, os: "linux", arch: nil, environment: env ) #expect(result != nil) #expect(result?.os == "linux") } @Test func testResolveFallsBackToEnvVar() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/amd64"] let result = try DefaultPlatform.resolve( platform: nil, os: nil, arch: nil, environment: env ) #expect(result != nil) #expect(result?.os == "linux") #expect(result?.architecture == "amd64") } @Test func testResolveReturnsNilWithNoFlagsOrEnvVar() throws { let env: [String: String] = [:] let result = try DefaultPlatform.resolve( platform: nil, os: nil, arch: nil, environment: env ) #expect(result == nil) } @Test func testResolveExplicitPlatformOverridesEverything() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm64"] let result = try DefaultPlatform.resolve( platform: "linux/amd64", os: "linux", arch: "arm64", environment: env ) #expect(result?.architecture == "amd64") } @Test func testResolveExplicitPlatformIgnoresInvalidEnvVar() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "garbage"] let result = try DefaultPlatform.resolve( platform: "linux/amd64", os: nil, arch: nil, environment: env ) #expect(result?.architecture == "amd64") #expect(result?.os == "linux") } // MARK: - resolveWithDefaults (required os/arch, used by run/create) @Test func testResolveWithDefaultsExplicitPlatformWins() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm64"] let result = try DefaultPlatform.resolveWithDefaults( platform: "linux/amd64", os: "linux", arch: "arm64", environment: env ) #expect(result.architecture == "amd64") } @Test func testResolveWithDefaultsEnvVarOverridesDefaults() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/amd64"] let result = try DefaultPlatform.resolveWithDefaults( platform: nil, os: "linux", arch: "arm64", environment: env ) #expect(result.architecture == "amd64") #expect(result.os == "linux") } @Test func testResolveWithDefaultsFallsBackToOsArch() throws { let env: [String: String] = [:] let result = try DefaultPlatform.resolveWithDefaults( platform: nil, os: "linux", arch: "arm64", environment: env ) #expect(result.os == "linux") #expect(result.architecture == "arm64") } @Test func testResolveWithDefaultsEnvVarWithDifferentOs() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/amd64"] let result = try DefaultPlatform.resolveWithDefaults( platform: nil, os: "linux", arch: Arch.hostArchitecture().rawValue, environment: env ) #expect(result.architecture == "amd64") } @Test func testResolveWithDefaultsInvalidEnvVarThrows() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "garbage"] #expect { _ = try DefaultPlatform.resolveWithDefaults( platform: nil, os: "linux", arch: "arm64", environment: env ) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("CONTAINER_DEFAULT_PLATFORM") } } @Test func testResolveWithDefaultsExplicitPlatformIgnoresInvalidEnvVar() throws { let env = ["CONTAINER_DEFAULT_PLATFORM": "garbage"] let result = try DefaultPlatform.resolveWithDefaults( platform: "linux/amd64", os: "linux", arch: "arm64", environment: env ) #expect(result.architecture == "amd64") } // MARK: - Environment variable name @Test func testEnvironmentVariableName() { #expect(DefaultPlatform.environmentVariable == "CONTAINER_DEFAULT_PLATFORM") } } ================================================ FILE: Tests/ContainerAPIClientTests/DiskUsageTests.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 @testable import ContainerAPIClient struct DiskUsageTests { @Test("DiskUsageStats JSON encoding and decoding") func testJSONSerialization() throws { let stats = DiskUsageStats( images: ResourceUsage(total: 10, active: 5, sizeInBytes: 1024, reclaimable: 512), containers: ResourceUsage(total: 3, active: 2, sizeInBytes: 2048, reclaimable: 1024), volumes: ResourceUsage(total: 7, active: 4, sizeInBytes: 4096, reclaimable: 2048) ) let encoder = JSONEncoder() let data = try encoder.encode(stats) let decoder = JSONDecoder() let decoded = try decoder.decode(DiskUsageStats.self, from: data) #expect(decoded.images.total == stats.images.total) #expect(decoded.images.active == stats.images.active) #expect(decoded.images.sizeInBytes == stats.images.sizeInBytes) #expect(decoded.images.reclaimable == stats.images.reclaimable) #expect(decoded.containers.total == stats.containers.total) #expect(decoded.containers.active == stats.containers.active) #expect(decoded.containers.sizeInBytes == stats.containers.sizeInBytes) #expect(decoded.containers.reclaimable == stats.containers.reclaimable) #expect(decoded.volumes.total == stats.volumes.total) #expect(decoded.volumes.active == stats.volumes.active) #expect(decoded.volumes.sizeInBytes == stats.volumes.sizeInBytes) #expect(decoded.volumes.reclaimable == stats.volumes.reclaimable) } @Test("ResourceUsage with zero values") func testZeroValues() throws { let emptyUsage = ResourceUsage(total: 0, active: 0, sizeInBytes: 0, reclaimable: 0) let encoder = JSONEncoder() let data = try encoder.encode(emptyUsage) let decoder = JSONDecoder() let decoded = try decoder.decode(ResourceUsage.self, from: data) #expect(decoded.total == 0) #expect(decoded.active == 0) #expect(decoded.sizeInBytes == 0) #expect(decoded.reclaimable == 0) } @Test("ResourceUsage with large values") func testLargeValues() throws { let largeUsage = ResourceUsage( total: 1000, active: 500, sizeInBytes: UInt64.max, reclaimable: UInt64.max / 2 ) let encoder = JSONEncoder() let data = try encoder.encode(largeUsage) let decoder = JSONDecoder() let decoded = try decoder.decode(ResourceUsage.self, from: data) #expect(decoded.total == 1000) #expect(decoded.active == 500) #expect(decoded.sizeInBytes == UInt64.max) #expect(decoded.reclaimable == UInt64.max / 2) } @Test("ResourceUsage percentage calculations") func testPercentageCalculations() throws { // 0% reclaimable let noneReclaimable = ResourceUsage(total: 10, active: 10, sizeInBytes: 1000, reclaimable: 0) #expect(Double(noneReclaimable.reclaimable) / Double(noneReclaimable.sizeInBytes) == 0.0) // 50% reclaimable let halfReclaimable = ResourceUsage(total: 10, active: 5, sizeInBytes: 1000, reclaimable: 500) #expect(Double(halfReclaimable.reclaimable) / Double(halfReclaimable.sizeInBytes) == 0.5) // 100% reclaimable let allReclaimable = ResourceUsage(total: 10, active: 0, sizeInBytes: 1000, reclaimable: 1000) #expect(Double(allReclaimable.reclaimable) / Double(allReclaimable.sizeInBytes) == 1.0) } } ================================================ FILE: Tests/ContainerAPIClientTests/HostDNSResolverTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import ContainerizationExtras import Foundation import Testing @testable import ContainerAPIClient struct HostDNSResolverTest { @Test func testHostDNSCreate() async throws { let fm = FileManager.default let tempURL = try fm.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: .temporaryDirectory, create: true ) defer { try? FileManager.default.removeItem(at: tempURL) } let resolver = HostDNSResolver(configURL: tempURL) try resolver.createDomain(name: "foo.bar") let resolverConfigURL = tempURL.appending(path: "containerization.foo.bar") let actualText = try String(contentsOf: resolverConfigURL, encoding: .utf8) let expectedText = """ domain foo.bar search foo.bar nameserver 127.0.0.1 port 2053 """ #expect(actualText == expectedText) try resolver.createDomain(name: "bar.foo") let domains = resolver.listDomains() #expect(domains == ["bar.foo", "foo.bar"]) } @Test func testHostDNSCreateAlreadyExists() async throws { let fm = FileManager.default let tempURL = try fm.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: .temporaryDirectory, create: true ) defer { try? FileManager.default.removeItem(at: tempURL) } let resolver = HostDNSResolver(configURL: tempURL) try resolver.createDomain(name: "foo.bar") #expect { try resolver.createDomain(name: "foo.bar") } throws: { error in guard let error = error as? ContainerizationError, error.code == .exists else { return false } return true } } @Test func testHostDNSDelete() async throws { let fm = FileManager.default let tempURL = try fm.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: .temporaryDirectory, create: true ) defer { try? FileManager.default.removeItem(at: tempURL) } let resolver = HostDNSResolver(configURL: tempURL) try resolver.createDomain(name: "foo.bar") _ = try resolver.deleteDomain(name: "foo.bar") let localhost = try! IPAddress("127.0.0.1") try resolver.createDomain(name: "bar.baz", localhost: localhost) let deletedLocalhost = try resolver.deleteDomain(name: "bar.baz") #expect(localhost == deletedLocalhost) let domains = resolver.listDomains() #expect(domains == []) } @Test func testHostDNSDeleteNotFound() async throws { let fm = FileManager.default let tempURL = try fm.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: .temporaryDirectory, create: true ) defer { try? FileManager.default.removeItem(at: tempURL) } let resolver = HostDNSResolver(configURL: tempURL) try resolver.createDomain(name: "foo.bar") #expect { _ = try resolver.deleteDomain(name: "bar.foo") } throws: { error in guard let error = error as? ContainerizationError, error.code == .notFound else { return false } return true } } @Test func testHostDNSReinitialize() async throws { let isAdmin = getuid() == 0 do { try HostDNSResolver.reinitialize() #expect(isAdmin) } catch { let containerizationError = try #require(error as? ContainerizationError) #expect(containerizationError.code == .internalError) #expect(containerizationError.message == "mDNSResponder restart failed with status 1") #expect(!isAdmin) } } } ================================================ FILE: Tests/ContainerAPIClientTests/Measurement+ParseTests.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 @testable import ContainerAPIClient struct MeasurementParseTests { @Test("Parse binary units - bare unit symbols") func testBinaryUnits() throws { let result1 = try Measurement.parse(parsing: "4k") #expect(result1.value == 4.0) #expect(result1.unit == .kibibytes) let result2 = try Measurement.parse(parsing: "2m") #expect(result2.value == 2.0) #expect(result2.unit == .mebibytes) let result3 = try Measurement.parse(parsing: "1g") #expect(result3.value == 1.0) #expect(result3.unit == .gibibytes) let result4 = try Measurement.parse(parsing: "512b") #expect(result4.value == 512.0) #expect(result4.unit == .bytes) } @Test("Parse binary units - ib suffix") func testBinaryUnitsWithIbSuffix() throws { let result1 = try Measurement.parse(parsing: "4kib") #expect(result1.value == 4.0) #expect(result1.unit == .kibibytes) let result2 = try Measurement.parse(parsing: "2mib") #expect(result2.value == 2.0) #expect(result2.unit == .mebibytes) let result3 = try Measurement.parse(parsing: "1gib") #expect(result3.value == 1.0) #expect(result3.unit == .gibibytes) let result4 = try Measurement.parse(parsing: "3tib") #expect(result4.value == 3.0) #expect(result4.unit == .tebibytes) let result5 = try Measurement.parse(parsing: "1pib") #expect(result5.value == 1.0) #expect(result5.unit == .pebibytes) } @Test("Parse binary units - all suffixes now use binary") func testAllSuffixesUseBinary() throws { let result1 = try Measurement.parse(parsing: "4kb") #expect(result1.value == 4.0) #expect(result1.unit == .kibibytes) let result2 = try Measurement.parse(parsing: "2mb") #expect(result2.value == 2.0) #expect(result2.unit == .mebibytes) let result3 = try Measurement.parse(parsing: "1gb") #expect(result3.value == 1.0) #expect(result3.unit == .gibibytes) let result4 = try Measurement.parse(parsing: "3tb") #expect(result4.value == 3.0) #expect(result4.unit == .tebibytes) let result5 = try Measurement.parse(parsing: "1pb") #expect(result5.value == 1.0) #expect(result5.unit == .pebibytes) } @Test("Parse with whitespace") func testParsingWithWhitespace() throws { let result1 = try Measurement.parse(parsing: " 4k ") #expect(result1.value == 4.0) #expect(result1.unit == .kibibytes) let result2 = try Measurement.parse(parsing: " 2.5mb ") #expect(result2.value == 2.5) #expect(result2.unit == .mebibytes) } @Test("Parse decimal values") func testDecimalValues() throws { let result1 = try Measurement.parse(parsing: "4.5k") #expect(result1.value == 4.5) #expect(result1.unit == .kibibytes) let result2 = try Measurement.parse(parsing: "1.25gb") #expect(result2.value == 1.25) #expect(result2.unit == .gibibytes) let result3 = try Measurement.parse(parsing: "0.5mib") #expect(result3.value == 0.5) #expect(result3.unit == .mebibytes) } @Test("Parse case insensitive") func testCaseInsensitive() throws { let result1 = try Measurement.parse(parsing: "4K") #expect(result1.value == 4.0) #expect(result1.unit == .kibibytes) let result2 = try Measurement.parse(parsing: "2GB") #expect(result2.value == 2.0) #expect(result2.unit == .gibibytes) let result3 = try Measurement.parse(parsing: "1MIB") #expect(result3.value == 1.0) #expect(result3.unit == .mebibytes) } @Test("Parse bytes unit") func testBytesUnit() throws { let result1 = try Measurement.parse(parsing: "1024") #expect(result1.value == 1024.0) #expect(result1.unit == .bytes) let result2 = try Measurement.parse(parsing: "512b") #expect(result2.value == 512.0) #expect(result2.unit == .bytes) } @Test("Parse invalid size throws error") func testInvalidSizeThrowsError() { #expect { _ = try Measurement.parse(parsing: "abc") } throws: { error in guard let parseError = error as? Measurement.ParseError else { return false } return parseError.description == "invalid size" } #expect { _ = try Measurement.parse(parsing: "k4") } throws: { error in guard let parseError = error as? Measurement.ParseError else { return false } return parseError.description == "invalid size" } } @Test("Parse invalid symbol throws error") func testInvalidSymbolThrowsError() { #expect { _ = try Measurement.parse(parsing: "4x") } throws: { error in guard let parseError = error as? Measurement.ParseError else { return false } return parseError.description == "invalid symbol: x" } #expect { _ = try Measurement.parse(parsing: "4kx") } throws: { error in guard let parseError = error as? Measurement.ParseError else { return false } return parseError.description == "invalid symbol: kx" } } @Test("Parse empty string throws error") func testEmptyStringThrowsError() { #expect { _ = try Measurement.parse(parsing: "") } throws: { error in guard let parseError = error as? Measurement.ParseError else { return false } return parseError.description == "invalid size" } } @Test("Verify all suffixes now use binary units") func testAllSuffixesUseBinaryUnits() throws { let bareK = try Measurement.parse(parsing: "1k") let kib = try Measurement.parse(parsing: "1kib") let kb = try Measurement.parse(parsing: "1kb") #expect(bareK.unit == .kibibytes) #expect(kib.unit == .kibibytes) #expect(kb.unit == .kibibytes) let allInBytes = bareK.converted(to: .bytes).value #expect(allInBytes == 1024.0) } } ================================================ FILE: Tests/ContainerAPIClientTests/PacketFilterTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import ContainerizationExtras import Foundation import Testing @testable import ContainerAPIClient struct PacketFilterTest { @Test func testRedirectRuleUpdate() async throws { let fm = FileManager.default let tempURL = try fm.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: .temporaryDirectory, create: true ) defer { try? FileManager.default.removeItem(at: tempURL) } let configURL = tempURL.appending(path: "pf.conf") let pf = PacketFilter(configURL: configURL, anchorsURL: tempURL) let from1 = try! IPAddress("203.0.113.113") let domain1 = "aaa.com" let to = try! IPAddress("127.0.0.1") try pf.createRedirectRule(from: from1, to: to, domain: domain1) let anchorURL = tempURL.appending(path: "com.apple.container") var actualAnchorText = try String(contentsOf: anchorURL, encoding: .utf8) var expectedAnchorTest = """ rdr inet from any to \(from1) -> \(to) # \(domain1)\n """ #expect(actualAnchorText == expectedAnchorTest) let from2 = try! IPAddress("172.31.72.1") let domain2 = "bbb.com" try pf.createRedirectRule(from: from2, to: to, domain: domain2) actualAnchorText = try String(contentsOf: anchorURL, encoding: .utf8) expectedAnchorTest += """ rdr inet from any to \(from2) -> \(to) # \(domain2)\n """ #expect(actualAnchorText == expectedAnchorTest) let actualConfigText = try String(contentsOf: configURL, encoding: .utf8) let expectedConfigText = try Regex( #""" scrub-anchor "([^"]+)" nat-anchor "([^"]+)" rdr-anchor "([^"]+)" dummynet-anchor "([^"]+)" anchor "([^"]+)" load anchor "([^"]+)" from "[^"]+" """# ) #expect(actualConfigText.contains(expectedConfigText)) try pf.removeRedirectRule(from: from1, to: to, domain: domain1) try pf.removeRedirectRule(from: from2, to: to, domain: domain2) #expect(!fm.fileExists(atPath: anchorURL.path)) let configText = try String(contentsOf: configURL, encoding: .utf8) #expect(configText == "") } @Test func testPacketFilterReinitialize() async throws { let pf = PacketFilter() #expect(throws: ContainerizationError.self) { try pf.reinitialize() } } } ================================================ FILE: Tests/ContainerAPIClientTests/ParserTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerPersistence import ContainerizationError import ContainerizationExtras import Foundation import Testing @testable import ContainerAPIClient struct ParserTest { @Test func testPublishPortParserTcp() throws { let result = try Parser.publishPorts(["127.0.0.1:8080:8000/tcp"]) #expect(result.count == 1) let expectedAddress = try IPAddress("127.0.0.1") #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(8000)) #expect(result[0].proto == .tcp) #expect(result[0].count == 1) } @Test func testPublishPortParserUdp() throws { let result = try Parser.publishPorts(["192.168.32.36:8000:8080/UDP"]) #expect(result.count == 1) let expectedAddress = try IPAddress("192.168.32.36") #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8000)) #expect(result[0].containerPort == UInt16(8080)) #expect(result[0].proto == .udp) #expect(result[0].count == 1) } @Test func testPublishPortRange() throws { let result = try Parser.publishPorts(["127.0.0.1:8080-8179:9000-9099/tcp"]) #expect(result.count == 1) let expectedAddress = try IPAddress("127.0.0.1") #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(9000)) #expect(result[0].proto == .tcp) #expect(result[0].count == 100) } @Test func testPublishPortRangeSingle() throws { let result = try Parser.publishPorts(["127.0.0.1:8080-8080:9000-9000/tcp"]) #expect(result.count == 1) let expectedAddress = try IPAddress("127.0.0.1") #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(9000)) #expect(result[0].proto == .tcp) #expect(result[0].count == 1) } @Test func testPublishPortNoHostAddress() throws { let result = try Parser.publishPorts(["8080:8000/tcp"]) #expect(result.count == 1) let expectedAddress = try IPAddress("0.0.0.0") #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(8000)) #expect(result[0].proto == .tcp) #expect(result[0].count == 1) } @Test func testPublishPortNoProtocol() throws { let result = try Parser.publishPorts(["8080:8000"]) #expect(result.count == 1) let expectedAddress = try IPAddress("0.0.0.0") #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(8000)) #expect(result[0].proto == .tcp) #expect(result[0].count == 1) } @Test func testPublishPortParserIPv6() throws { let result = try Parser.publishPorts(["[fe80::36f3:5e50:ed71:1bb]:8080:8000/tcp"]) #expect(result.count == 1) let expectedAddress = try IPAddress("fe80::36f3:5e50:ed71:1bb") #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(8000)) #expect(result[0].proto == .tcp) #expect(result[0].count == 1) } @Test func testPublishPortInvalidProtocol() throws { #expect { _ = try Parser.publishPorts(["8080:8000/sctp"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish protocol") } } @Test func testPublishPortInvalidValue() throws { #expect { _ = try Parser.publishPorts([""]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish value") } } @Test func testPublishPortMissingPort() throws { #expect { _ = try Parser.publishPorts(["1234"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish value") } } @Test func testPublishInvalidIPv4Address() throws { #expect { _ = try Parser.publishPorts(["1234:8080:8000"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish IPv4 address") } } @Test func testPublishInvalidIPv6Address() throws { #expect { _ = try Parser.publishPorts([ "[1234:5678]:8080:8000", "[2001::db8::1]:8080:8080", "[2001:db8:85a3::8a2e:370g:7334]:8080:8080", "[2001:db8:85a3::][8a2e::7334]:8080:8080", ]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish IPv6 address") } } @Test func testPublishPortInvalidHostPort() throws { #expect { _ = try Parser.publishPorts(["65536:1234"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish host port") } } @Test func testPublishPortInvalidContainerPort() throws { #expect { _ = try Parser.publishPorts(["1234:65536"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish container port") } } @Test func testPublishPortRangeMismatch() throws { #expect { _ = try Parser.publishPorts(["8000-8000:9000-9001"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("counts are not equal") } } @Test func testPublishPortRangeInvalidHostPortStart() throws { #expect { _ = try Parser.publishPorts(["65536-65537:9000-9001"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish host port") } } @Test func testPublishPortRangeZeroHostPortStart() throws { #expect { _ = try Parser.publishPorts(["0-1:9000-9001"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish host port") } } @Test func testPublishPortRangeInvalidHostPortEnd() throws { #expect { _ = try Parser.publishPorts(["65535-65536:9000-9001"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish host port") } } @Test func testPublishPortRangeInvalidHostPortRange() throws { #expect { _ = try Parser.publishPorts(["8000-8001-8002:9000-9001"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish host port") } } @Test func testPublishPortRangeNegativeHostPortRange() throws { #expect { _ = try Parser.publishPorts(["8001-8000:9000-9001"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish host port") } } @Test func testPublishPortRangeInvalidContainerPortStart() throws { #expect { _ = try Parser.publishPorts(["8000-8001:65536-65537"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish container port") } } @Test func testPublishPortRangeZeroContainerPortStart() throws { #expect { _ = try Parser.publishPorts(["8000-8001:0-1"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish container port") } } @Test func testPublishPortRangeInvalidContainerPortEnd() throws { #expect { _ = try Parser.publishPorts(["8000-8001:65535-65536"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish container port") } } @Test func testPublishPortRangeInvalidContainerPortRange() throws { #expect { _ = try Parser.publishPorts(["8000-8001:9000-9001-9002"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish container port") } } @Test func testPublishPortRangeNegativeContainerPortRange() throws { #expect { _ = try Parser.publishPorts(["8000-8001:9001-9000"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid publish container port") } } @Test func testRelativePaths() throws { // Test bind mount with relative path "." do { let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-bind-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let result = try Parser.mount("type=bind,src=.,dst=/foo", relativeTo: tempDir) switch result { case .filesystem(let fs): #expect(fs.source == tempDir.standardizedFileURL.path) #expect(fs.destination == "/foo") #expect(!fs.isVolume) case .volume: #expect(Bool(false), "Expected filesystem mount, got volume") } } // Test volume with relative path "./" do { let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-volume-rel-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let result = try Parser.volume("./:/foo", relativeTo: tempDir) switch result { case .filesystem(let fs): let expectedPath = tempDir.standardizedFileURL.path // Normalize trailing slashes for comparison #expect(fs.source.trimmingCharacters(in: CharacterSet(charactersIn: "/")) == expectedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) #expect(fs.destination == "/foo") case .volume: #expect(Bool(false), "Expected filesystem mount, got volume") } } // Test volume with nested relative path "./subdir" do { let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-volume-rel-nested-\(UUID().uuidString)") let nestedDir = tempDir.appendingPathComponent("subdir") try FileManager.default.createDirectory(at: nestedDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let result = try Parser.volume("./subdir:/foo", relativeTo: tempDir) switch result { case .filesystem(let fs): let expectedPath = nestedDir.standardizedFileURL.path // Normalize trailing slashes for comparison #expect(fs.source.trimmingCharacters(in: CharacterSet(charactersIn: "/")) == expectedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) #expect(fs.destination == "/foo") case .volume: #expect(Bool(false), "Expected filesystem mount, got volume") } } // Test volume with bare "." as source (current directory) do { let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-volume-dot-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let result = try Parser.volume(".:/docs:ro", relativeTo: tempDir) switch result { case .filesystem(let fs): let expectedPath = tempDir.standardizedFileURL.path #expect(fs.source.trimmingCharacters(in: CharacterSet(charactersIn: "/")) == expectedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) #expect(fs.destination == "/docs") #expect(fs.options.contains("ro")) case .volume: #expect(Bool(false), "Expected filesystem mount, got volume") } } // Test volume with ".." as source (parent directory) do { let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-volume-dotdot-\(UUID().uuidString)") let childDir = tempDir.appendingPathComponent("child") try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let result = try Parser.volume("..:/data", relativeTo: childDir) switch result { case .filesystem(let fs): let expectedPath = tempDir.standardizedFileURL.path #expect(fs.source.trimmingCharacters(in: CharacterSet(charactersIn: "/")) == expectedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) #expect(fs.destination == "/data") case .volume: #expect(Bool(false), "Expected filesystem mount, got volume") } } } @Test func testMountBindAbsolutePath() throws { let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-bind-abs-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let result = try Parser.mount("type=bind,src=\(tempDir.path),dst=/foo") switch result { case .filesystem(let fs): #expect(fs.source == tempDir.path) #expect(fs.destination == "/foo") #expect(!fs.isVolume) case .volume: #expect(Bool(false), "Expected filesystem mount, got volume") } } @Test func testMountVolumeValidName() throws { let result = try Parser.mount("type=volume,src=myvolume,dst=/data") switch result { case .filesystem: #expect(Bool(false), "Expected volume mount, got filesystem") case .volume(let vol): #expect(vol.name == "myvolume") #expect(vol.destination == "/data") } } @Test func testMountVolumeInvalidName() throws { #expect { _ = try Parser.mount("type=volume,src=.,dst=/data") } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid volume name") } } @Test func testMountBindNonExistentPath() throws { #expect { _ = try Parser.mount("type=bind,src=/nonexistent/path,dst=/foo") } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("path") && error.description.contains("does not exist") } } @Test func testMountBindFileInsteadOfDirectory() throws { let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test-file-\(UUID().uuidString)") try "test content".write(to: tempFile, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(at: tempFile) } #expect { _ = try Parser.mount("type=bind,src=\(tempFile.path),dst=/foo") } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("path") && error.description.contains("is not a directory") } } @Test func testIsValidDomainNameOk() throws { let names = [ "a", "a.b", "foo.bar", "F-O.B-R", [ String(repeating: "0", count: 63), String(repeating: "1", count: 63), String(repeating: "2", count: 63), String(repeating: "3", count: 63), ].joined(separator: "."), ] for name in names { #expect(Parser.isValidDomainName(name)) } } @Test func testIsValidDomainNameBad() throws { let names = [ ".foo", "foo.", ".foo.bar", "foo.bar.", "-foo.bar", "foo.bar-", [ String(repeating: "0", count: 63), String(repeating: "1", count: 63), String(repeating: "2", count: 63), String(repeating: "3", count: 62), "4", ].joined(separator: "."), ] for name in names { #expect(!Parser.isValidDomainName(name)) } } // MARK: - Environment Variable Tests @Test func testEnvExplicitValue() throws { let result = Parser.env(envList: ["FOO=bar", "BAZ=qux"]) #expect(result == ["FOO=bar", "BAZ=qux"]) } @Test func testEnvImplicitInheritance() throws { guard let homeValue = ProcessInfo.processInfo.environment["PATH"] else { Issue.record("PATH environment variable not set") return } let result = Parser.env(envList: ["PATH"]) #expect(result == ["PATH=\(homeValue)"]) } @Test func testEnvImplicitUndefinedVariable() throws { // A variable that doesn't exist should be silently skipped let result = Parser.env(envList: ["THIS_VAR_DEFINITELY_DOES_NOT_EXIST_12345"]) #expect(result.isEmpty) } @Test func testEnvMixedExplicitAndImplicit() throws { guard let homeValue = ProcessInfo.processInfo.environment["HOME"] else { Issue.record("HOME environment variable not set") return } let result = Parser.env(envList: ["FOO=bar", "HOME", "BAZ=qux"]) #expect(result == ["FOO=bar", "HOME=\(homeValue)", "BAZ=qux"]) } @Test func testEnvEmptyValue() throws { // Explicit empty value should be preserved let result = Parser.env(envList: ["EMPTY="]) #expect(result == ["EMPTY="]) } @Test func testAllEnvUserOverridesImage() throws { let result = try Parser.allEnv( imageEnvs: ["FOO=fromimage", "BAR=kept"], envFiles: [], envs: ["FOO=fromuser"] ) #expect(Set(result) == Set(["FOO=fromuser", "BAR=kept"])) } @Test func testAllEnvFileOverridesImage() throws { let tmpFile = try tmpFileWithContent("FOO=fromfile\n") defer { try? FileManager.default.removeItem(at: tmpFile) } let result = try Parser.allEnv( imageEnvs: ["FOO=fromimage", "BAR=kept"], envFiles: [tmpFile.path], envs: [] ) #expect(Set(result) == Set(["FOO=fromfile", "BAR=kept"])) } @Test func testAllEnvUserOverridesFileOverridesImage() throws { let tmpFile = try tmpFileWithContent("FOO=fromfile\nBAZ=fromfile\n") defer { try? FileManager.default.removeItem(at: tmpFile) } let result = try Parser.allEnv( imageEnvs: ["FOO=fromimage", "BAR=fromimage"], envFiles: [tmpFile.path], envs: ["FOO=fromuser"] ) #expect(Set(result) == Set(["FOO=fromuser", "BAR=fromimage", "BAZ=fromfile"])) } private func tmpFileWithContent(_ content: String) throws -> URL { let tempDir = FileManager.default.temporaryDirectory let tempFile = tempDir.appendingPathComponent("envfile-test-\(UUID().uuidString)") try content.write(to: tempFile, atomically: true, encoding: .utf8) return tempFile } // NOTE: A lot of these env-file tests are recreations of the docker cli's unit tests for their // env-file support. @Test func testParseEnvFileGoodFile() throws { var content = """ foo=bar baz=quux # comment _foobar=foobaz with.dots=working and_underscore=working too """ content += "\n \t " let tmpFile = try tmpFileWithContent(content) defer { try? FileManager.default.removeItem(at: tmpFile) } let lines = try Parser.envFile(path: tmpFile.path) let expectedLines = [ "foo=bar", "baz=quux", "_foobar=foobaz", "with.dots=working", "and_underscore=working too", ] #expect(lines == expectedLines) } @Test func testParseEnvFileMultipleEqualsSigns() throws { let content = """ URL=https://foo.bar?baz=woo """ let tmpFile = try tmpFileWithContent(content) defer { try? FileManager.default.removeItem(at: tmpFile) } let lines = try Parser.envFile(path: tmpFile.path) let expectedLines = [ "URL=https://foo.bar?baz=woo" ] #expect(lines == expectedLines) } @Test func testParseEnvFileEmptyFile() throws { let tmpFile = try tmpFileWithContent("") defer { try? FileManager.default.removeItem(at: tmpFile) } let lines = try Parser.envFile(path: tmpFile.path) #expect(lines.isEmpty) } @Test func testParseEnvFileNonExistentFile() throws { #expect { _ = try Parser.envFile(path: "/nonexistent/foo_bar_baz") } throws: { error in guard let error = error as? ContainerizationError, let cause = error.cause else { return false } return String(describing: cause).contains("No such file or directory") } } @Test func testParseEnvFileBadlyFormattedFile() throws { let content = """ foo=bar f =quux """ let tmpFile = try tmpFileWithContent(content) defer { try? FileManager.default.removeItem(at: tmpFile) } #expect { _ = try Parser.envFile(path: tmpFile.path) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("contains whitespaces") } } @Test func testParseEnvFileRandomFile() throws { let content = """ first line another invalid line """ let tmpFile = try tmpFileWithContent(content) defer { try? FileManager.default.removeItem(at: tmpFile) } #expect { _ = try Parser.envFile(path: tmpFile.path) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("first line") && error.description.contains("contains whitespaces") } } @Test func testParseEnvVariableDefinitionsFile() throws { let content = """ # comment= UNDEFINED_VAR HOME """ let tmpFile = try tmpFileWithContent(content) defer { try? FileManager.default.removeItem(at: tmpFile) } let variables = try Parser.envFile(path: tmpFile.path) // HOME should be imported from environment guard let homeValue = ProcessInfo.processInfo.environment["HOME"] else { Issue.record("HOME environment variable not set") return } #expect(variables.count == 1) #expect(variables[0] == "HOME=\(homeValue)") } @Test func testParseEnvVariableWithNoNameFile() throws { let content = """ # comment= =blank variable names are an error case """ let tmpFile = try tmpFileWithContent(content) defer { try? FileManager.default.removeItem(at: tmpFile) } #expect { _ = try Parser.envFile(path: tmpFile.path) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("no variable name") } } @Test func testParseEnvFileFromNamedPipe() throws { let pipePath = FileManager.default.temporaryDirectory .appendingPathComponent("envfile-pipe-\(UUID().uuidString)") // Create a named pipe (FIFO) let result = mkfifo(pipePath.path, 0o600) guard result == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM) } defer { try? FileManager.default.removeItem(at: pipePath) } let group = DispatchGroup() group.enter() DispatchQueue.global().async { do { let handle = try FileHandle(forWritingTo: pipePath) try handle.write(contentsOf: "SECRET_KEY=value123\n".data(using: .utf8)!) try handle.close() } catch { Issue.record(error) } group.leave() } // Read from pipe (blocks until writer connects) let lines = try Parser.envFile(path: pipePath.path) // Wait for write to complete group.wait() #expect(lines == ["SECRET_KEY=value123"]) } // MARK: Network Parser Tests @Test func testParseNetworkSimpleName() throws { let result = try Parser.network("default") #expect(result.name == "default") #expect(result.macAddress == nil) } @Test func testParseNetworkWithMACAddress() throws { let result = try Parser.network("backend,mac=02:42:ac:11:00:02") #expect(result.name == "backend") #expect(result.macAddress == "02:42:ac:11:00:02") } @Test func testParseNetworkWithMACAddressHyphenSeparator() throws { let result = try Parser.network("backend,mac=02-42-ac-11-00-02") #expect(result.name == "backend") #expect(result.macAddress == "02-42-ac-11-00-02") } @Test func testParseNetworkEmptyString() throws { #expect { _ = try Parser.network("") } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("network specification cannot be empty") } } @Test func testParseNetworkEmptyName() throws { #expect { _ = try Parser.network(",mac=02:42:ac:11:00:02") } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("network name cannot be empty") } } @Test func testParseNetworkEmptyMACAddress() throws { #expect { _ = try Parser.network("backend,mac=") } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("mac address value cannot be empty") } } @Test func testParseNetworkUnknownProperty() throws { #expect { _ = try Parser.network("backend,unknown=value") } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("unknown network property") && error.description.contains("unknown") } } @Test func testParseNetworkInvalidPropertyFormat() throws { #expect { _ = try Parser.network("backend,invalidproperty") } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid property format") } } // MARK: - Relative Path Passthrough Tests @Test func testProcessEntrypointRelativePathPassthrough() throws { let processFlags = try Flags.Process.parse(["--cwd", "/bin"]) let managementFlags = try Flags.Management.parse(["--entrypoint", "./uname"]) let result = try Parser.process( arguments: [], processFlags: processFlags, managementFlags: managementFlags, config: nil ) #expect(result.executable == "./uname") #expect(result.workingDirectory == "/bin") } @Test func testUlimitParserSoftAndHard() throws { let result = try Parser.rlimits(["nofile=1024:2048"]) #expect(result.count == 1) #expect(result[0].limit == "RLIMIT_NOFILE") #expect(result[0].soft == 1024) #expect(result[0].hard == 2048) } @Test func testUlimitParserSingleValue() throws { let result = try Parser.rlimits(["nproc=512"]) #expect(result.count == 1) #expect(result[0].limit == "RLIMIT_NPROC") #expect(result[0].soft == 512) #expect(result[0].hard == 512) } @Test func testUlimitParserUnlimited() throws { let result = try Parser.rlimits(["memlock=unlimited"]) #expect(result.count == 1) #expect(result[0].limit == "RLIMIT_MEMLOCK") #expect(result[0].soft == UInt64.max) #expect(result[0].hard == UInt64.max) } @Test func testUlimitParserUnlimitedHardOnly() throws { let result = try Parser.rlimits(["stack=8192:unlimited"]) #expect(result.count == 1) #expect(result[0].limit == "RLIMIT_STACK") #expect(result[0].soft == 8192) #expect(result[0].hard == UInt64.max) } @Test func testUlimitParserMinusOneAsUnlimited() throws { let result = try Parser.rlimits(["core=-1"]) #expect(result.count == 1) #expect(result[0].limit == "RLIMIT_CORE") #expect(result[0].soft == UInt64.max) #expect(result[0].hard == UInt64.max) } @Test func testUlimitParserMultipleUlimits() throws { let result = try Parser.rlimits(["nofile=1024:2048", "nproc=256", "cpu=60:120"]) #expect(result.count == 3) #expect(result[0].limit == "RLIMIT_NOFILE") #expect(result[1].limit == "RLIMIT_NPROC") #expect(result[2].limit == "RLIMIT_CPU") } @Test func testUlimitParserAllSupportedTypes() throws { let types = ["core", "cpu", "data", "fsize", "memlock", "nofile", "nproc", "rss", "stack"] let expectedRlimits = [ "RLIMIT_CORE", "RLIMIT_CPU", "RLIMIT_DATA", "RLIMIT_FSIZE", "RLIMIT_MEMLOCK", "RLIMIT_NOFILE", "RLIMIT_NPROC", "RLIMIT_RSS", "RLIMIT_STACK", ] for (i, type) in types.enumerated() { let result = try Parser.rlimits(["\(type)=100"]) #expect(result.count == 1) #expect(result[0].limit == expectedRlimits[i]) } } @Test func testUlimitParserCaseInsensitive() throws { let result = try Parser.rlimits(["NOFILE=1024", "Nproc=512"]) #expect(result.count == 2) #expect(result[0].limit == "RLIMIT_NOFILE") #expect(result[1].limit == "RLIMIT_NPROC") } @Test func testUlimitParserInvalidFormat() throws { #expect { _ = try Parser.rlimits(["nofile"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid ulimit format") } } @Test func testUlimitParserUnsupportedType() throws { #expect { _ = try Parser.rlimits(["foo=100"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("unsupported ulimit type") } } @Test func testUlimitParserSoftExceedsHard() throws { #expect { _ = try Parser.rlimits(["nofile=2048:1024"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("soft limit") && error.description.contains("cannot exceed hard limit") } } @Test func testUlimitParserDuplicateType() throws { #expect { _ = try Parser.rlimits(["nofile=1024", "nofile=2048"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("duplicate ulimit type") } } @Test func testUlimitParserInvalidValue() throws { #expect { _ = try Parser.rlimits(["nofile=abc"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } return error.description.contains("invalid ulimit value") } } @Test func testUlimitParserEmptyArray() throws { let result = try Parser.rlimits([]) #expect(result.isEmpty) } @Test func testUlimitParserZeroValue() throws { let result = try Parser.rlimits(["core=0"]) #expect(result.count == 1) #expect(result[0].limit == "RLIMIT_CORE") #expect(result[0].soft == 0) #expect(result[0].hard == 0) } @Test func testUlimitParserLargeValues() throws { let result = try Parser.rlimits(["nproc=\(UInt64.max - 1):\(UInt64.max)"]) #expect(result.count == 1) #expect(result[0].limit == "RLIMIT_NPROC") #expect(result[0].soft == UInt64.max - 1) #expect(result[0].hard == UInt64.max) } } ================================================ FILE: Tests/ContainerAPIClientTests/RequestSchemeTests.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerPersistence import ContainerizationError import Foundation import Testing @testable import ContainerAPIClient struct RequestSchemeTests { static let defaultDnsDomain = DefaultsStore.get(key: .defaultDNSDomain) internal struct TestArg { let scheme: String let host: String let expected: RequestScheme } @Test(arguments: [ TestArg(scheme: "http", host: "myregistry.io", expected: .http), TestArg(scheme: "https", host: "myregistry.io", expected: .https), TestArg(scheme: "auto", host: "myregistry.io", expected: .https), TestArg(scheme: "https", host: "localhost", expected: .https), TestArg(scheme: "http", host: "localhost", expected: .http), TestArg(scheme: "auto", host: "localhost", expected: .http), TestArg(scheme: "http", host: "127.0.0.1", expected: .http), TestArg(scheme: "https", host: "127.0.0.1", expected: .https), TestArg(scheme: "auto", host: "127.0.0.1", expected: .http), TestArg(scheme: "https", host: "10.3.4.1", expected: .https), TestArg(scheme: "auto", host: "10.3.4.1", expected: .http), TestArg(scheme: "auto", host: "some-dns-name.io.\(Self.defaultDnsDomain)", expected: .http), TestArg(scheme: "auto", host: "some-dns-name.io", expected: .https), TestArg(scheme: "auto", host: "172.32.0.1", expected: .https), TestArg(scheme: "auto", host: "172.22.23.61", expected: .http), ]) func testIsConnectionSecure(arg: TestArg) throws { let requestScheme = RequestScheme(rawValue: arg.scheme)! #expect(try requestScheme.schemeFor(host: arg.host) == arg.expected) } func testEmptyHostThrowsError() throws { #expect(throws: (any Error).self) { let requestScheme = RequestScheme(rawValue: "https")! _ = try requestScheme.schemeFor(host: "") } } } ================================================ FILE: Tests/ContainerAPIClientTests/UtilityTests.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerResource import ContainerizationError import Foundation import Testing @testable import ContainerAPIClient struct UtilityTests { @Test("Parse simple key-value pairs") func testSimpleKeyValuePairs() { let result = Utility.parseKeyValuePairs(["key1=value1", "key2=value2"]) #expect(result["key1"] == "value1") #expect(result["key2"] == "value2") } @Test("Parse standalone keys") func testStandaloneKeys() { let result = Utility.parseKeyValuePairs(["standalone"]) #expect(result["standalone"] == "") } @Test("Parse empty input") func testEmptyInput() { let result = Utility.parseKeyValuePairs([]) #expect(result.isEmpty) } @Test("Parse mixed format") func testMixedFormat() { let result = Utility.parseKeyValuePairs(["key1=value1", "standalone", "key2=value2"]) #expect(result["key1"] == "value1") #expect(result["standalone"] == "") #expect(result["key2"] == "value2") } @Test("Valid MAC address with colons") func testValidMACAddressWithColons() throws { try Utility.validMACAddress("02:42:ac:11:00:02") try Utility.validMACAddress("AA:BB:CC:DD:EE:FF") try Utility.validMACAddress("00:00:00:00:00:00") try Utility.validMACAddress("ff:ff:ff:ff:ff:ff") } @Test("Valid MAC address with hyphens") func testValidMACAddressWithHyphens() throws { try Utility.validMACAddress("02-42-ac-11-00-02") try Utility.validMACAddress("AA-BB-CC-DD-EE-FF") } @Test("Invalid MAC address format") func testInvalidMACAddressFormat() { #expect(throws: Error.self) { try Utility.validMACAddress("invalid") } #expect(throws: Error.self) { try Utility.validMACAddress("02:42:ac:11:00") // Too short } #expect(throws: Error.self) { try Utility.validMACAddress("02:42:ac:11:00:02:03") // Too long } #expect(throws: Error.self) { try Utility.validMACAddress("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ") // Invalid hex } #expect(throws: Error.self) { try Utility.validMACAddress("02:42:ac:11:00:") // Incomplete } #expect(throws: Error.self) { try Utility.validMACAddress("02.42.ac.11.00.02") // Wrong separator } } @Test func testPublishPortParser() throws { let ports = try Parser.publishPorts([ "127.0.0.1:8000:9080", "8080-8179:9000-9099/udp", ]) #expect(ports.count == 2) #expect(ports[0].hostAddress.description == "127.0.0.1") #expect(ports[0].hostPort == 8000) #expect(ports[0].containerPort == 9080) #expect(ports[0].proto == .tcp) #expect(ports[0].count == 1) #expect(ports[1].hostAddress.description == "0.0.0.0") #expect(ports[1].hostPort == 8080) #expect(ports[1].containerPort == 9000) #expect(ports[1].proto == .udp) #expect(ports[1].count == 100) } } ================================================ FILE: Tests/ContainerBuildTests/BuildFile.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 Logging import Testing @testable import ContainerBuild @Suite class BuildFileResolvePathTests { private var baseTempURL: URL private let fileManager = FileManager.default init() throws { self.baseTempURL = URL.temporaryDirectory .appendingPathComponent("BuildFileTests-\(UUID().uuidString)") try fileManager.createDirectory(at: baseTempURL, withIntermediateDirectories: true, attributes: nil) } deinit { try? fileManager.removeItem(at: baseTempURL) } private func createFile(at url: URL, content: String = "") throws { try fileManager.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil ) let created = fileManager.createFile( atPath: url.path, contents: content.data(using: .utf8), attributes: nil ) try #require(created) } @Test func testResolvePathFindsDockerfile() throws { let contextDir = baseTempURL.path let dockerfilePath = baseTempURL.appendingPathComponent("Dockerfile") try createFile(at: dockerfilePath, content: "FROM alpine") let result = try BuildFile.resolvePath(contextDir: contextDir) #expect(result == dockerfilePath.path) } @Test func testResolvePathFindsContainerfile() throws { let contextDir = baseTempURL.path let containerfilePath = baseTempURL.appendingPathComponent("Containerfile") try createFile(at: containerfilePath, content: "FROM alpine") let result = try BuildFile.resolvePath(contextDir: contextDir) #expect(result == containerfilePath.path) } @Test func testResolvePathPrefersDockerfileWhenBothExist() throws { let contextDir = baseTempURL.path let dockerfilePath = baseTempURL.appendingPathComponent("Dockerfile") let containerfilePath = baseTempURL.appendingPathComponent("Containerfile") try createFile(at: dockerfilePath, content: "FROM alpine") try createFile(at: containerfilePath, content: "FROM ubuntu") let result = try BuildFile.resolvePath(contextDir: contextDir) #expect(result == dockerfilePath.path) } @Test func testResolvePathReturnsNilWhenNoFilesExist() throws { let contextDir = baseTempURL.path let result = try BuildFile.resolvePath(contextDir: contextDir) #expect(result == nil) } @Test func testResolvePathWithEmptyDirectory() throws { let emptyDir = baseTempURL.appendingPathComponent("empty") try fileManager.createDirectory(at: emptyDir, withIntermediateDirectories: true, attributes: nil) let result = try BuildFile.resolvePath(contextDir: emptyDir.path) #expect(result == nil) } @Test func testResolvePathWithNestedContextDirectory() throws { let nestedDir = baseTempURL.appendingPathComponent("project/build") try fileManager.createDirectory(at: nestedDir, withIntermediateDirectories: true, attributes: nil) let dockerfilePath = nestedDir.appendingPathComponent("Dockerfile") try createFile(at: dockerfilePath, content: "FROM node") let result = try BuildFile.resolvePath(contextDir: nestedDir.path) #expect(result == dockerfilePath.path) } @Test func testResolvePathWithRelativeContextDirectory() throws { let nestedDir = baseTempURL.appendingPathComponent("project") try fileManager.createDirectory(at: nestedDir, withIntermediateDirectories: true, attributes: nil) let dockerfilePath = nestedDir.appendingPathComponent("Dockerfile") try createFile(at: dockerfilePath, content: "FROM python") // Test with the absolute path let result = try BuildFile.resolvePath(contextDir: nestedDir.path) #expect(result == dockerfilePath.path) } } ================================================ FILE: Tests/ContainerBuildTests/BuilderExtensionsTests.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 @testable import ContainerBuild @Suite class URLExtensionFileSystemTests { private var baseTempURL: URL! private let fileManager = FileManager.default init() throws { baseTempURL = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("URLExtensionTests-\(UUID().uuidString)") try fileManager.createDirectory(at: baseTempURL, withIntermediateDirectories: true, attributes: nil) } deinit { if let baseTempURL = baseTempURL { try? fileManager.removeItem(at: baseTempURL) } } // MARK: - Helpers private func createDirectory(at url: URL) throws { try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) } private func createFile(at url: URL, content: String = "") throws { try fileManager.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) #expect( fileManager.createFile( atPath: url.path, contents: content.data(using: .utf8), attributes: nil)) } // MARK: - parentOf Tests @Test func testParentOfDirectParent() throws { let parentDir = baseTempURL.appendingPathComponent("dir1") let childDir = parentDir.appendingPathComponent("dir2") try createDirectory(at: childDir) #expect(parentDir.parentOf(childDir)) } @Test func testParentOfGrandparent() throws { let grandParent = baseTempURL.appendingPathComponent("dir3").appendingPathComponent("test") let childDir = grandParent.appendingPathComponent("dir4").appendingPathComponent("dir2") try createDirectory(at: childDir) #expect(grandParent.parentOf(childDir)) } @Test func testParentOfBaseTemp() throws { let childDir = baseTempURL.appendingPathComponent("dir4").appendingPathComponent("dir2") try createDirectory(at: childDir) #expect(baseTempURL.parentOf(childDir)) } @Test func testParentOfRoot() throws { let rootURL = URL(fileURLWithPath: "/") let childDir = baseTempURL.appendingPathComponent("dir4") try createDirectory(at: childDir) #expect(rootURL.parentOf(childDir)) #expect(rootURL.parentOf(baseTempURL)) } @Test func testParentOfSamePath() throws { let dir = baseTempURL.appendingPathComponent("dir4") try createDirectory(at: dir) let sameDir = URL(fileURLWithPath: dir.path) #expect(dir.parentOf(sameDir)) #expect(sameDir.parentOf(dir)) } @Test func testParentOfRootToRoot() { let root1 = URL(fileURLWithPath: "/") let root2 = URL(fileURLWithPath: "/") #expect(root1.parentOf(root2)) } @Test func testParentOfDifferentPaths() throws { let dir1 = baseTempURL .appendingPathComponent("dir3") .appendingPathComponent("test") .appendingPathComponent("dir4") let dir2 = baseTempURL .appendingPathComponent("dir3") .appendingPathComponent("another") .appendingPathComponent("file") try createDirectory(at: dir1) try createDirectory(at: dir2) #expect(false == dir1.parentOf(dir2)) #expect(false == dir2.parentOf(dir1)) } @Test func testParentOfSiblingPaths() throws { let parentDir = baseTempURL.appendingPathComponent("dir3").appendingPathComponent("test") let sibling1 = parentDir.appendingPathComponent("dir4") let sibling2 = parentDir.appendingPathComponent("dir5") try createDirectory(at: sibling1) try createDirectory(at: sibling2) #expect(false == sibling1.parentOf(sibling2)) #expect(false == sibling2.parentOf(sibling1)) } @Test func testParentOfChildIsParentFalse() throws { let parentDir = baseTempURL.appendingPathComponent("dir4") let childDir = parentDir.appendingPathComponent("dir2") try createDirectory(at: childDir) #expect(false == childDir.parentOf(parentDir)) } @Test func testParentOfPartialNameMatch() throws { let partial = baseTempURL.appendingPathComponent("Doc") let actualDir = baseTempURL.appendingPathComponent("dir4") try createDirectory(at: actualDir) #expect(false == partial.parentOf(actualDir)) } @Test func testParentOfPathNormalization() throws { let parentDir = baseTempURL.appendingPathComponent("dir4") let childDir = parentDir.appendingPathComponent("dir2") try createDirectory(at: childDir) let normalized = baseTempURL .appendingPathComponent("dir8") .appendingPathComponent("..") .appendingPathComponent("dir4") #expect(normalized.parentOf(childDir)) } @Test func testParentOfChildWithNormalization() throws { let parentDir = baseTempURL.appendingPathComponent("dir4") let targetChildDir = parentDir.appendingPathComponent("dir2") try createDirectory(at: targetChildDir) let normalizedChild = parentDir .appendingPathComponent("dir9") .appendingPathComponent("..") .appendingPathComponent("dir2") #expect(parentDir.parentOf(normalizedChild)) } @Test func testParentOfPercentEncoding() throws { let parentDir = baseTempURL.appendingPathComponent("My dir4") let childDir = parentDir.appendingPathComponent("dir2 X") try createDirectory(at: childDir) let parentEncoded = URL(fileURLWithPath: baseTempURL.path + "/My%20dir4") let childEncoded = URL(fileURLWithPath: baseTempURL.path + "/My%20dir4/dir2%20X") #expect(parentDir.parentOf(childDir)) #expect(parentEncoded.parentOf(childEncoded)) #expect(parentDir.parentOf(childEncoded)) #expect(parentEncoded.parentOf(childDir)) } @Test func testParentOfNonFileURL() throws { let httpURL = URL(string: "http://example.com/path")! let fileURL = baseTempURL.appendingPathComponent("file") try createFile(at: fileURL) #expect(false == httpURL.parentOf(fileURL)) #expect(false == fileURL.parentOf(httpURL)) } @Test func testRelativeChildPathDirectChild() throws { let parentDir = baseTempURL.appendingPathComponent("dir1") let childFile = parentDir.appendingPathComponent("dir2").appendingPathComponent("file") try createFile(at: childFile) let relative = try childFile.relativeChildPath(to: parentDir) #expect(relative == "dir2/file") } @Test func testRelativeChildPathDeeperChild() throws { let parentDir = baseTempURL.appendingPathComponent("dir3").appendingPathComponent("test") let childFile = parentDir.appendingPathComponent("dir4/dir2/file") try createFile(at: childFile) let relative = try childFile.relativeChildPath(to: parentDir) #expect(relative == "dir4/dir2/file") } @Test func testRelativeChildPathDirectlyInsideBase() throws { let childFile = baseTempURL.appendingPathComponent("file") try createFile(at: childFile) let relative = try childFile.relativeChildPath(to: baseTempURL) #expect(relative == "file") } @Test func testRelativeChildPathSamePath() throws { let dir = baseTempURL.appendingPathComponent("dir4") try createDirectory(at: dir) let dirCopy = URL(fileURLWithPath: dir.path) #expect(try dir.relativeChildPath(to: dirCopy) == "") #expect(try dirCopy.relativeChildPath(to: dir) == "") } @Test func testRelativeChildPathRootChild() throws { let rootURL = URL(fileURLWithPath: "/") let childDir = baseTempURL.appendingPathComponent("dir4") try createDirectory(at: childDir) // Compare only the portion that comes after "/" let expected = baseTempURL .standardizedFileURL .pathComponents .dropFirst() // remove "/" .joined(separator: "/") + "/dir4" let relative = try childDir.relativeChildPath(to: rootURL) #expect(relative == expected) } @Test func testRelativeChildPathRootToRootIsEmpty() throws { let root1 = URL(fileURLWithPath: "/") let root2 = URL(fileURLWithPath: "/") #expect(try root1.relativeChildPath(to: root2) == "") } @Test func testRelativeChildPathNormalization() throws { let parentDir = baseTempURL.appendingPathComponent("dir4") let childFile = parentDir.appendingPathComponent("dir2/file") try createFile(at: childFile) let normalizedParent = baseTempURL .appendingPathComponent("dir8") .appendingPathComponent("..") .appendingPathComponent("dir4") #expect(try childFile.relativeChildPath(to: normalizedParent) == "dir2/file") } @Test func testRelativeChildPathNormalizedChild() throws { let parentDir = baseTempURL.appendingPathComponent("dir4") let childFile = parentDir.appendingPathComponent("dir2/file") try createFile(at: childFile) let normalizedChild = parentDir .appendingPathComponent("dir9") .appendingPathComponent("..") .appendingPathComponent("dir2") .appendingPathComponent("file") #expect(try normalizedChild.relativeChildPath(to: parentDir) == "dir2/file") } @Test func testRelativeChildPathPercentEncoding() throws { let parentDir = baseTempURL.appendingPathComponent("My dir4") let childFile = parentDir.appendingPathComponent("dir2 X/file1") try createFile(at: childFile) #expect(try childFile.relativeChildPath(to: parentDir) == "dir2 X/file1") let parentEncoded = URL(fileURLWithPath: baseTempURL.path + "/My%20dir4") let childEncoded = URL(fileURLWithPath: baseTempURL.path + "/My%20dir4/dir2%20X/file1") #expect(try childEncoded.relativeChildPath(to: parentDir) == "dir2 X/file1") #expect(try childEncoded.relativeChildPath(to: parentEncoded) == "dir2 X/file1") } // MARK: - relativeChildPath Error Tests @Test func testRelativeChildPathThrowsWhenNotAChild() throws { let parentDir = baseTempURL.appendingPathComponent("dir4") let otherDir = baseTempURL.appendingPathComponent("dir7/file") try createDirectory(at: parentDir) try createDirectory(at: otherDir) #expect(throws: (BuildFSSync.Error.pathIsNotChild(otherDir.cleanPath, parentDir.cleanPath)).self) { try otherDir.relativeChildPath(to: parentDir) } } @Test func testRelativeChildPathThrowsForSiblings() throws { let parentDir = baseTempURL.appendingPathComponent("dir3/test") let sibling1 = parentDir.appendingPathComponent("dir4") let sibling2 = parentDir.appendingPathComponent("dir5") try createDirectory(at: sibling1) try createDirectory(at: sibling2) #expect(throws: (BuildFSSync.Error.pathIsNotChild(sibling2.cleanPath, sibling1.cleanPath)).self) { try sibling2.relativeChildPath(to: sibling1) } } @Test func testRelativeChildPathParentAsChildThrows() throws { let parentDir = baseTempURL.appendingPathComponent("dir4") let childDir = parentDir.appendingPathComponent("dir2") try createDirectory(at: childDir) #expect(throws: (BuildFSSync.Error.pathIsNotChild(parentDir.cleanPath, childDir.cleanPath)).self) { try parentDir.relativeChildPath(to: childDir) } } // MARK: - cleanPath Tests @Test func testCleanPathSimple() throws { let file = baseTempURL.appendingPathComponent("file") try createFile(at: file) #expect(file.cleanPath.hasSuffix("/file")) #expect(file.cleanPath.contains(baseTempURL.lastPathComponent)) } @Test func testCleanPathWithSpaces() throws { let file = baseTempURL.appendingPathComponent("my file with spaces") try createFile(at: file) #expect(file.cleanPath.hasSuffix("/my file with spaces")) #expect(file.cleanPath.contains(baseTempURL.lastPathComponent)) } @Test func testCleanPathWithPercentEncoding() throws { let fileWithSpace = baseTempURL.appendingPathComponent("my file") try createFile(at: fileWithSpace) let encodedPathString = baseTempURL.path + "/my%20file" let urlFromString = URL(fileURLWithPath: encodedPathString) #expect(urlFromString.cleanPath == fileWithSpace.cleanPath) #expect(urlFromString.cleanPath.hasSuffix("/my file")) } } ================================================ FILE: Tests/ContainerBuildTests/GlobberTests.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 @testable import ContainerBuild struct TestCase { let pattern: String let fileName: String let expectSuccess: Bool } // test cases adapted from https://github.com/moby/patternmatcher/tree/main let globTestCases = [ TestCase(pattern: "*", fileName: "test.go", expectSuccess: true), TestCase(pattern: "**", fileName: "test.go", expectSuccess: true), TestCase(pattern: "**", fileName: "file", expectSuccess: true), TestCase(pattern: "*.go", fileName: "test.go", expectSuccess: true), TestCase(pattern: "a.|)$(}+{bc", fileName: "a.|)$(}+{bc", expectSuccess: true), TestCase(pattern: "abc.def", fileName: "abcdef", expectSuccess: false), TestCase(pattern: "abc.def", fileName: "abc.def", expectSuccess: true), TestCase(pattern: "abc.def", fileName: "abcZdef", expectSuccess: false), TestCase(pattern: "abc?def", fileName: "abcZdef", expectSuccess: true), TestCase(pattern: "abc?def", fileName: "abcdef", expectSuccess: false), TestCase(pattern: "a[b-d]e", fileName: "ae", expectSuccess: false), TestCase(pattern: "a[b-d]e", fileName: "ace", expectSuccess: true), TestCase(pattern: "a[b-d]e", fileName: "aae", expectSuccess: false), TestCase(pattern: "a[^b-d]e", fileName: "aze", expectSuccess: true), TestCase(pattern: "a[\\^b-d]e", fileName: "abe", expectSuccess: true), TestCase(pattern: "a[\\^b-d]e", fileName: "aze", expectSuccess: false), ] let errorGlobTestCases = [ TestCase(pattern: "[]a]", fileName: "]", expectSuccess: true), TestCase(pattern: "[", fileName: "a", expectSuccess: true), TestCase(pattern: "[^", fileName: "a", expectSuccess: true), TestCase(pattern: "[^bc", fileName: "a", expectSuccess: true), TestCase(pattern: "a[", fileName: "a", expectSuccess: true), TestCase(pattern: "a[", fileName: "ab", expectSuccess: true), ] let testCases = [ TestCase(pattern: "*", fileName: "test/test.go", expectSuccess: true), TestCase(pattern: "**.go", fileName: "test/test.go", expectSuccess: true), TestCase(pattern: "**file", fileName: "test/file", expectSuccess: true), TestCase(pattern: "**/*", fileName: "test/test.go", expectSuccess: true), TestCase(pattern: "**/", fileName: "file", expectSuccess: true), TestCase(pattern: "**/", fileName: "file/", expectSuccess: true), TestCase(pattern: "**", fileName: "file", expectSuccess: true), TestCase(pattern: "**", fileName: "file/", expectSuccess: true), TestCase(pattern: "**", fileName: "dir/file", expectSuccess: true), TestCase(pattern: "**/", fileName: "dir/file", expectSuccess: true), TestCase(pattern: "**", fileName: "dir/file/", expectSuccess: true), TestCase(pattern: "**/", fileName: "dir/file/", expectSuccess: true), TestCase(pattern: "**/**", fileName: "dir/file", expectSuccess: true), TestCase(pattern: "**/**", fileName: "dir/file/", expectSuccess: true), TestCase(pattern: "dir/**", fileName: "dir/file", expectSuccess: true), TestCase(pattern: "dir/**", fileName: "dir/file/", expectSuccess: true), TestCase(pattern: "dir/**", fileName: "dir/dir2/file", expectSuccess: true), TestCase(pattern: "dir/**", fileName: "dir/dir2/file/", expectSuccess: true), TestCase(pattern: "**/dir", fileName: "dir", expectSuccess: true), TestCase(pattern: "**/dir", fileName: "dir/file", expectSuccess: true), TestCase(pattern: "**/dir2/*", fileName: "dir/dir2/file", expectSuccess: true), TestCase(pattern: "**/dir2/*", fileName: "dir/dir2/file/", expectSuccess: true), TestCase(pattern: "**/dir2/**", fileName: "dir/dir2/dir3/file", expectSuccess: true), TestCase(pattern: "**/dir2/**", fileName: "dir/dir2/dir3/file/", expectSuccess: true), TestCase(pattern: "**file", fileName: "file", expectSuccess: true), TestCase(pattern: "**file", fileName: "dir/file", expectSuccess: true), TestCase(pattern: "**/file", fileName: "dir/file", expectSuccess: true), TestCase(pattern: "**file", fileName: "dir/dir/file", expectSuccess: true), TestCase(pattern: "**/file", fileName: "dir/dir/file", expectSuccess: true), TestCase(pattern: "**/file*", fileName: "dir/dir/file", expectSuccess: true), TestCase(pattern: "**/file*", fileName: "dir/dir/file.txt", expectSuccess: true), TestCase(pattern: "**/file*txt", fileName: "dir/dir/file.txt", expectSuccess: true), TestCase(pattern: "**/file*.txt", fileName: "dir/dir/file.txt", expectSuccess: true), TestCase(pattern: "**/file*.txt*", fileName: "dir/dir/file.txt", expectSuccess: true), TestCase(pattern: "**/**/*.txt", fileName: "dir/dir/file.txt", expectSuccess: true), TestCase(pattern: "**/**/*.txt2", fileName: "dir/dir/file.txt", expectSuccess: false), TestCase(pattern: "**/*.txt", fileName: "file.txt", expectSuccess: true), TestCase(pattern: "**/**/*.txt", fileName: "file.txt", expectSuccess: true), TestCase(pattern: "a**/*.txt", fileName: "a/file.txt", expectSuccess: true), TestCase(pattern: "a**/*.txt", fileName: "a/dir/file.txt", expectSuccess: true), TestCase(pattern: "a**/*.txt", fileName: "a/dir/dir/file.txt", expectSuccess: true), TestCase(pattern: "a/*.txt", fileName: "a/dir/file.txt", expectSuccess: false), TestCase(pattern: "a/*.txt", fileName: "a/file.txt", expectSuccess: true), TestCase(pattern: "a/*.txt**", fileName: "a/file.txt", expectSuccess: true), TestCase(pattern: ".*", fileName: ".foo", expectSuccess: true), TestCase(pattern: ".*", fileName: "foo", expectSuccess: false), TestCase(pattern: "abc.def", fileName: "abcdef", expectSuccess: false), TestCase(pattern: "abc.def", fileName: "abc.def", expectSuccess: true), TestCase(pattern: "abc.def", fileName: "abcZdef", expectSuccess: false), TestCase(pattern: "abc?def", fileName: "abcZdef", expectSuccess: true), TestCase(pattern: "abc?def", fileName: "abcdef", expectSuccess: false), TestCase(pattern: "**/foo/bar", fileName: "foo/bar", expectSuccess: true), TestCase(pattern: "**/foo/bar", fileName: "dir/foo/bar", expectSuccess: true), TestCase(pattern: "**/foo/bar", fileName: "dir/dir2/foo/bar", expectSuccess: true), TestCase(pattern: "abc/**", fileName: "abc/def", expectSuccess: true), TestCase(pattern: "abc/**", fileName: "abc/def/ghi", expectSuccess: true), TestCase(pattern: "**/.foo", fileName: ".foo", expectSuccess: true), TestCase(pattern: "**/.foo", fileName: "bar.foo", expectSuccess: false), TestCase(pattern: "./bar.*", fileName: "bar.foo", expectSuccess: true), TestCase(pattern: "./bar.*/", fileName: "bar.foo", expectSuccess: true), TestCase(pattern: "a(b)c/def", fileName: "a(b)c/def", expectSuccess: true), TestCase(pattern: "a(b)c/def", fileName: "a(b)c/xyz", expectSuccess: false), TestCase(pattern: "a.|)$(}+{bc", fileName: "a.|)$(}+{bc", expectSuccess: true), TestCase(pattern: "dist/proxy.py-2.4.0rc3.dev36+g08acad9-py3-none-any.whl", fileName: "dist/proxy.py-2.4.0rc3.dev36+g08acad9-py3-none-any.whl", expectSuccess: true), TestCase(pattern: "dist/*.whl", fileName: "dist/proxy.py-2.4.0rc3.dev36+g08acad9-py3-none-any.whl", expectSuccess: true), ] @Suite struct TestGlobber { @Test("All glob patterns match", arguments: globTestCases) 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), instead got \(found)") } @Test("Invalid computed regex patterns throw error", arguments: errorGlobTestCases) func testInvalidGlob(_ test: TestCase) throws { let globber = Globber(URL(fileURLWithPath: "/")) #expect(throws: (any Error).self) { try globber.glob(test.fileName, test.pattern) } } @Test("All expected patterns match", arguments: testCases) func testExpectedPatterns(_ test: TestCase) throws { let charactersToTrim = CharacterSet(charactersIn: "/") let components = test.fileName .trimmingCharacters(in: charactersToTrim) .components(separatedBy: "/") // tempDir is the directory we're making the files or nested files in let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) var fileDir: URL = tempDir // testDir is the directory before the last component that we need to create components.dropLast().forEach { component in var d = fileDir if component == ".." { d = fileDir.deletingLastPathComponent() } else if component != "." { d = fileDir.appendingPathComponent(component) } #expect(throws: Never.self) { try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) } fileDir = d } #expect(throws: Never.self) { try FileManager.default.createDirectory(at: fileDir, withIntermediateDirectories: true) } let testFile = fileDir.appendingPathComponent(components.last!) #expect(throws: Never.self) { try "".write(to: testFile, atomically: true, encoding: .utf8) } defer { try? FileManager.default.removeItem(at: tempDir) } let globber = Globber(tempDir) #expect(throws: Never.self) { try globber.match(test.pattern) let found: Bool = !globber.results.isEmpty #expect(found == test.expectSuccess, "expected match to be \(test.expectSuccess), instead got \(found) \(tempDir.childrenRecursive)") } } @Test("Test the base directory is not include in results") func testBaseDirNotIncluded() throws { let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let testDir = tempDir.appendingPathComponent("abc") #expect(throws: Never.self) { try FileManager.default.createDirectory(at: testDir, withIntermediateDirectories: true) } defer { try? FileManager.default.removeItem(at: tempDir) } let globber = Globber(testDir) #expect(throws: Never.self) { try globber.match("abc/**") #expect(globber.results.isEmpty, "expected to find no matches, instead found \(globber.results)") } } } ================================================ FILE: Tests/ContainerNetworkServiceTests/AttachmentAllocatorTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import ContainerizationExtras import Testing @testable import ContainerNetworkService struct AttachmentAllocatorTest { @Test func testAllocateSingleHostname() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) let address = try await allocator.allocate(hostname: "test-host") #expect(address >= 100) #expect(address < 110) } @Test func testAllocateSameHostnameTwice() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) let address1 = try await allocator.allocate(hostname: "test-host") let address2 = try await allocator.allocate(hostname: "test-host") #expect(address1 == address2) } @Test func testAllocateMultipleHostnames() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) let address1 = try await allocator.allocate(hostname: "host1") let address2 = try await allocator.allocate(hostname: "host2") let address3 = try await allocator.allocate(hostname: "host3") #expect(address1 != address2) #expect(address2 != address3) #expect(address1 != address3) } @Test func testLookupAllocatedHostname() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) let allocatedAddress = try await allocator.allocate(hostname: "test-host") let lookedUpAddress = try await allocator.lookup(hostname: "test-host") #expect(lookedUpAddress == allocatedAddress) } @Test func testLookupNonExistentHostname() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) let address = try await allocator.lookup(hostname: "non-existent") #expect(address == nil) } @Test func testDeallocateAllocatedHostname() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) let allocatedAddress = try await allocator.allocate(hostname: "test-host") let deallocatedAddress = try await allocator.deallocate(hostname: "test-host") #expect(deallocatedAddress == allocatedAddress) // After deallocation, lookup should return nil let lookedUpAddress = try await allocator.lookup(hostname: "test-host") #expect(lookedUpAddress == nil) } @Test func testDeallocateNonExistentHostname() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) let deallocatedAddress = try await allocator.deallocate(hostname: "non-existent") #expect(deallocatedAddress == nil) } @Test func testReallocateAfterDeallocation() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) let address1 = try await allocator.allocate(hostname: "test-host") let released1 = try await allocator.deallocate(hostname: "test-host") #expect(address1 == released1) let address2 = try await allocator.allocate(hostname: "test-host") // After deallocation, allocating the same hostname should give a new address #expect(address2 >= 100) #expect(address2 < 110) } @Test func testAllocateUntilFull() async throws { let size = 5 let allocator = try AttachmentAllocator(lower: 100, size: size) // Allocate up to the limit for i in 0..= 100) #expect(newAddress < 103) // The three remaining allocations should all be different let finalAddress1 = try await allocator.lookup(hostname: "host1") let finalAddress3 = try await allocator.lookup(hostname: "host3") let finalAddress4 = try await allocator.lookup(hostname: "host4") #expect(finalAddress1 == address1) #expect(finalAddress3 == address3) #expect(finalAddress4 == newAddress) } @Test func testDisableAllocatorWhenEmpty() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) let disabled = await allocator.disableAllocator() #expect(disabled == true) // After disabling, allocation should fail await #expect(throws: Error.self) { try await allocator.allocate(hostname: "test-host") } } @Test func testDisableAllocatorWhenNotEmpty() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) _ = try await allocator.allocate(hostname: "test-host") let disabled = await allocator.disableAllocator() #expect(disabled == false) // Since disable failed, should still be able to allocate let address = try await allocator.allocate(hostname: "another-host") #expect(address >= 100) #expect(address < 110) } @Test func testDisableAfterDeallocatingAll() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) _ = try await allocator.allocate(hostname: "host1") _ = try await allocator.allocate(hostname: "host2") try await allocator.deallocate(hostname: "host1") try await allocator.deallocate(hostname: "host2") let disabled = await allocator.disableAllocator() #expect(disabled == true) // After disabling, allocation should fail await #expect(throws: Error.self) { try await allocator.allocate(hostname: "test-host") } } @Test func testMultipleDeallocationsOfSameHostname() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) let address = try await allocator.allocate(hostname: "test-host") let firstDeallocate = try await allocator.deallocate(hostname: "test-host") #expect(firstDeallocate == address) // Second deallocation should return nil since it's already deallocated let secondDeallocate = try await allocator.deallocate(hostname: "test-host") #expect(secondDeallocate == nil) } } ================================================ FILE: Tests/ContainerOSTests/DirectoryWatcherTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerOS import ContainerizationError import DNSServer import Foundation import Testing struct DirectoryWatcherTest { let testUUID = UUID().uuidString private var testDir: URL! { let tempDir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) .appendingPathComponent(".clitests") .appendingPathComponent(testUUID) try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) return tempDir } private func withTempDir(_ body: (URL) async throws -> T) async throws -> T { let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } return try await body(tempDir) } private actor CreatedURLs { nonisolated(unsafe) public var urls: [URL] public init() { self.urls = [] } } @Test func testWatchingExistingDirectory() async throws { try await withTempDir { tempDir in let watcher = DirectoryWatcher(directoryURL: tempDir, log: nil) let createdURLs = CreatedURLs() let name = "newFile" await watcher.startWatching { [createdURLs] urls in for url in urls where url.lastPathComponent == name { createdURLs.urls.append(url) } } try await Task.sleep(for: .milliseconds(100)) let newFile = tempDir.appendingPathComponent(name) FileManager.default.createFile(atPath: newFile.path, contents: nil) try await Task.sleep(for: .milliseconds(500)) #expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect new file") #expect(createdURLs.urls.first!.lastPathComponent == name) } } @Test func testWatchingNonExistingDirectory() async throws { try await withTempDir { tempDir in let uuid = UUID().uuidString let childDir = tempDir.appendingPathComponent(uuid) let watcher = DirectoryWatcher(directoryURL: childDir, log: nil) let createdURLs = CreatedURLs() let name = "newFile" await watcher.startWatching { [createdURLs] urls in for url in urls where url.lastPathComponent == name { createdURLs.urls.append(url) } } try await Task.sleep(for: .milliseconds(100)) try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true) try await Task.sleep(for: DirectoryWatcher.watchPeriod) let newFile = childDir.appendingPathComponent(name) FileManager.default.createFile(atPath: newFile.path, contents: nil) try await Task.sleep(for: .milliseconds(500)) #expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect parent directory") #expect(createdURLs.urls.first!.lastPathComponent == name) } } @Test func testWatchingNonExistingParent() async throws { try await withTempDir { tempDir in let parent = UUID().uuidString let child = UUID().uuidString let childDir = tempDir.appendingPathComponent(parent).appendingPathComponent(child) let watcher = DirectoryWatcher(directoryURL: childDir, log: nil) let createdURLs = CreatedURLs() let name = "newFile" await watcher.startWatching { urls in for url in urls where url.lastPathComponent == name { createdURLs.urls.append(url) } } try await Task.sleep(for: .milliseconds(100)) try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true) try await Task.sleep(for: DirectoryWatcher.watchPeriod) let newFile = childDir.appendingPathComponent(name) FileManager.default.createFile(atPath: newFile.path, contents: nil) try await Task.sleep(for: .milliseconds(500)) #expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect parent directory") #expect(createdURLs.urls.first!.lastPathComponent == name) } } @Test func testWatchingRecreatedDirectory() async throws { try await withTempDir { tempDir in let dir = tempDir.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let watcher = DirectoryWatcher(directoryURL: dir, log: nil) let createdURLs = CreatedURLs() let beforeDelete = "beforeDelete" let afterDelete = "afterDelete" await watcher.startWatching { [createdURLs] urls in for url in urls where url.lastPathComponent == beforeDelete || url.lastPathComponent == afterDelete { createdURLs.urls.append(url) } } try await Task.sleep(for: .milliseconds(100)) let file1 = dir.appendingPathComponent(beforeDelete) FileManager.default.createFile(atPath: file1.path, contents: nil) try await Task.sleep(for: .milliseconds(100)) try FileManager.default.removeItem(at: dir) try await Task.sleep(for: .milliseconds(100)) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try await Task.sleep(for: DirectoryWatcher.watchPeriod) let file2 = dir.appendingPathComponent(afterDelete) FileManager.default.createFile(atPath: file2.path, contents: nil) try await Task.sleep(for: .milliseconds(500)) #expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect new file") #expect(Set(createdURLs.urls.map { $0.lastPathComponent }) == Set([beforeDelete, afterDelete])) } } } ================================================ FILE: Tests/ContainerPluginTests/CommandLine+ExecutableTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 @testable import ContainerPlugin struct CommandLineExecutableTest { @Test func testCLIPluginConfigLoad() async throws { #expect(CommandLine.executablePathUrl.lastPathComponent == "swiftpm-testing-helper") } } ================================================ FILE: Tests/ContainerPluginTests/MockPluginFactory.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerPlugin import Foundation import Testing struct MockPluginError: Error {} struct MockPluginFactory: PluginFactory { public static let throwSuffix = "throw" private let plugins: [URL: Plugin] private let throwingURL: URL public init(tempURL: URL, plugins: [String: Plugin?]) throws { let fm = FileManager.default var prefixedPlugins: [URL: Plugin] = [:] for (suffix, plugin) in plugins { let url = tempURL.appending(path: suffix) try fm.createDirectory(at: url, withIntermediateDirectories: true) prefixedPlugins[url.standardizedFileURL] = plugin } self.plugins = prefixedPlugins self.throwingURL = tempURL.appending(path: Self.throwSuffix).standardizedFileURL } public func create(installURL: URL) throws -> Plugin? { let url = installURL.standardizedFileURL guard url != self.throwingURL else { throw MockPluginError() } return plugins[url] } public func create(parentURL: URL, name: String) throws -> Plugin? { let url = parentURL.appendingPathComponent(name).standardizedFileURL guard url != self.throwingURL else { throw MockPluginError() } return plugins[url] } } ================================================ FILE: Tests/ContainerPluginTests/PluginConfigTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 @testable import ContainerPlugin struct PluginConfigTest { @Test func testCLIPluginConfigLoad() async throws { let tempURL = try FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: .temporaryDirectory, create: true ) defer { try? FileManager.default.removeItem(at: tempURL) } let configURL = tempURL.appending(path: "config.json") let configJson = """ { "abstract" : "Default network management service", "author": "Apple" } """ try configJson.write(to: configURL, atomically: true, encoding: .utf8) let config = try #require(try PluginConfig(configURL: configURL)) #expect(config.isCLI) #expect(config.abstract == "Default network management service") #expect(config.author == "Apple") } @Test func testServicePluginConfigLoad() async throws { let tempURL = try FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: .temporaryDirectory, create: true ) defer { try? FileManager.default.removeItem(at: tempURL) } let configURL = tempURL.appending(path: "config.json") let configJson = """ { "abstract" : "Default network management service", "author": "Apple", "servicesConfig" : { "loadAtBoot" : true, "runAtLoad" : true, "defaultArguments" : ["start"], "services" : [ { "type" : "network", "description": "foo" } ] } } """ try configJson.write(to: configURL, atomically: true, encoding: .utf8) let config = try #require(try PluginConfig(configURL: configURL)) #expect(!config.isCLI) #expect(config.abstract == "Default network management service") #expect(config.author == "Apple") let servicesConfig = try #require(config.servicesConfig) #expect(servicesConfig.loadAtBoot) #expect(servicesConfig.runAtLoad) #expect(servicesConfig.services.count == 1) #expect(servicesConfig.services[0].type == .network) #expect(servicesConfig.services[0].description == "foo") #expect(servicesConfig.defaultArguments == ["start"]) } } ================================================ FILE: Tests/ContainerPluginTests/PluginFactoryTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 @testable import ContainerPlugin struct PluginFactoryTest { @Test func testDefaultFactory() async throws { let fm = FileManager.default let tempURL = try fm.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: .temporaryDirectory, create: true ) defer { try? FileManager.default.removeItem(at: tempURL) } let name = tempURL.lastPathComponent // write config to {name}/config.json let configURL = tempURL.appending(path: "config.json") let configJson = """ { "abstract" : "Default network management service", "author": "Apple" } """ try configJson.write(to: configURL, atomically: true, encoding: .utf8) // write binary to {name}/bin/{name} let binaryDirURL = tempURL.appending(path: "bin") try fm.createDirectory(at: binaryDirURL, withIntermediateDirectories: true) let binaryURL = binaryDirURL.appending(path: name) try "".write(to: binaryURL, atomically: true, encoding: .utf8) let factory = DefaultPluginFactory() let plugin = try #require(try factory.create(installURL: tempURL)) #expect(plugin.name == name) #expect(!plugin.shouldBoot) #expect(plugin.getLaunchdLabel() == "com.apple.container.\(name)") #expect(plugin.getLaunchdLabel(instanceId: "1") == "com.apple.container.\(name).1") #expect(plugin.getMachServices() == []) #expect(plugin.getMachServices(instanceId: "1") == []) #expect(plugin.getMachService(type: .runtime) == nil) #expect(plugin.getMachService(instanceId: "1", type: .runtime) == nil) #expect(!plugin.hasType(.runtime)) #expect(!plugin.hasType(.network)) #expect(plugin.helpText(padding: 40).hasSuffix("Default network management service")) } @Test func testDefaultFactoryByName() async throws { let fm = FileManager.default let tempURL = try fm.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: .temporaryDirectory, create: true ) defer { try? FileManager.default.removeItem(at: tempURL) } let name = tempURL.lastPathComponent // write config to {name}/config.json let configURL = tempURL.appending(path: "config.json") let configJson = """ { "abstract" : "Default network management service", "author": "Apple" } """ try configJson.write(to: configURL, atomically: true, encoding: .utf8) // write binary to {name}/bin/{name} let binaryDirURL = tempURL.appending(path: "bin") try fm.createDirectory(at: binaryDirURL, withIntermediateDirectories: true) let binaryURL = binaryDirURL.appending(path: name) try "".write(to: binaryURL, atomically: true, encoding: .utf8) let factory = DefaultPluginFactory() let plugin = try #require(try factory.create(parentURL: tempURL.deletingLastPathComponent(), name: name)) #expect(plugin.name == name) #expect(!plugin.shouldBoot) #expect(plugin.getLaunchdLabel() == "com.apple.container.\(name)") #expect(plugin.getLaunchdLabel(instanceId: "1") == "com.apple.container.\(name).1") #expect(plugin.getMachServices() == []) #expect(plugin.getMachServices(instanceId: "1") == []) #expect(plugin.getMachService(type: .runtime) == nil) #expect(plugin.getMachService(instanceId: "1", type: .runtime) == nil) #expect(!plugin.hasType(.runtime)) #expect(!plugin.hasType(.network)) #expect(plugin.helpText(padding: 40).hasSuffix("Default network management service")) } @Test func testDefaultFactoryMissingConfig() async throws { let fm = FileManager.default let tempURL = try fm.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: .temporaryDirectory, create: true ) defer { try? FileManager.default.removeItem(at: tempURL) } let name = tempURL.lastPathComponent // write binary to {name}/bin/{name} let binaryDirURL = tempURL.appending(path: "bin") try fm.createDirectory(at: binaryDirURL, withIntermediateDirectories: true) let binaryURL = binaryDirURL.appending(path: name) try "".write(to: binaryURL, atomically: true, encoding: .utf8) let factory = DefaultPluginFactory() let plugin = try factory.create(installURL: tempURL) #expect(plugin == nil) } @Test func testDefaultFactoryMissingBinary() async throws { let fm = FileManager.default let tempURL = try fm.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: .temporaryDirectory, create: true ) defer { try? FileManager.default.removeItem(at: tempURL) } // write config to {name}/config.json let configURL = tempURL.appending(path: "config.json") let configJson = """ { "abstract" : "Default network management service", "author": "Apple" } """ try configJson.write(to: configURL, atomically: true, encoding: .utf8) let factory = DefaultPluginFactory() let plugin = try factory.create(installURL: tempURL) #expect(plugin == nil) } @Test func testAppBundleFactory() async throws { let fm = FileManager.default let tempURL = try fm.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: .temporaryDirectory, create: true ) defer { try? FileManager.default.removeItem(at: tempURL) } let installURL = tempURL.appending(path: "test.app") try fm.createDirectory(at: installURL, withIntermediateDirectories: true) let name = String(installURL.lastPathComponent.dropLast(4)) // write config to {name}/config.json let configURL = installURL .appending(path: "Contents") .appending(path: "Resources") .appending(path: "config.json") let configJson = """ { "abstract" : "Default network management service", "author": "Apple" } """ try fm.createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) try configJson.write(to: configURL, atomically: true, encoding: .utf8) // write binary to {name}/bin/{name} let binaryURL = installURL .appending(path: "Contents") .appending(path: "MacOS") .appending(path: name) try fm.createDirectory(at: binaryURL.deletingLastPathComponent(), withIntermediateDirectories: true) try "".write(to: binaryURL, atomically: true, encoding: .utf8) let factory = AppBundlePluginFactory() let plugin = try #require(try factory.create(installURL: installURL)) #expect(plugin.name == name) #expect(!plugin.shouldBoot) #expect(plugin.getLaunchdLabel() == "com.apple.container.\(name)") #expect(plugin.getLaunchdLabel(instanceId: "1") == "com.apple.container.\(name).1") #expect(plugin.getMachServices() == []) #expect(plugin.getMachServices(instanceId: "1") == []) #expect(plugin.getMachService(type: .runtime) == nil) #expect(plugin.getMachService(instanceId: "1", type: .runtime) == nil) #expect(!plugin.hasType(.runtime)) #expect(!plugin.hasType(.network)) #expect(plugin.helpText(padding: 40).hasSuffix("Default network management service")) } @Test func testAppBundleFactoryByName() async throws { let fm = FileManager.default let tempURL = try fm.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: .temporaryDirectory, create: true ) defer { try? FileManager.default.removeItem(at: tempURL) } let installURL = tempURL.appending(path: "test.app") try fm.createDirectory(at: installURL, withIntermediateDirectories: true) let name = String(installURL.lastPathComponent.dropLast(4)) // write config to {name}/config.json let configURL = installURL .appending(path: "Contents") .appending(path: "Resources") .appending(path: "config.json") let configJson = """ { "abstract" : "Default network management service", "author": "Apple" } """ try fm.createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) try configJson.write(to: configURL, atomically: true, encoding: .utf8) // write binary to {name}/bin/{name} let binaryURL = installURL .appending(path: "Contents") .appending(path: "MacOS") .appending(path: name) try fm.createDirectory(at: binaryURL.deletingLastPathComponent(), withIntermediateDirectories: true) try "".write(to: binaryURL, atomically: true, encoding: .utf8) let factory = AppBundlePluginFactory() let plugin = try #require(try factory.create(parentURL: tempURL, name: name)) #expect(plugin.name == name) #expect(!plugin.shouldBoot) #expect(plugin.getLaunchdLabel() == "com.apple.container.\(name)") #expect(plugin.getLaunchdLabel(instanceId: "1") == "com.apple.container.\(name).1") #expect(plugin.getMachServices() == []) #expect(plugin.getMachServices(instanceId: "1") == []) #expect(plugin.getMachService(type: .runtime) == nil) #expect(plugin.getMachService(instanceId: "1", type: .runtime) == nil) #expect(!plugin.hasType(.runtime)) #expect(!plugin.hasType(.network)) #expect(plugin.helpText(padding: 40).hasSuffix("Default network management service")) } } ================================================ FILE: Tests/ContainerPluginTests/PluginLoaderTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 @testable import ContainerPlugin struct PluginLoaderTest { @Test func testFindAll() async throws { let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: tempURL) } let factory = try setupMock(tempURL: tempURL) let loader = try PluginLoader( appRoot: tempURL, installRoot: URL(filePath: "/usr/local/"), logRoot: nil, pluginDirectories: [tempURL], pluginFactories: [factory] ) let plugins = loader.findPlugins() #expect(Set(plugins.map { $0.name }) == Set(["cli", "service"])) } @Test func testFindAllSymlink() async throws { let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: tempURL) } let factory = try setupMock(tempURL: tempURL) // move the CLI plugin elsewhere and symlink it let otherTempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: otherTempURL) } try FileManager.default.createDirectory(at: otherTempURL, withIntermediateDirectories: true) let srcURL = tempURL.appendingPathComponent("cli") let dstURL = otherTempURL.appendingPathComponent("cli") try FileManager.default.moveItem( at: srcURL, to: dstURL ) try FileManager.default.createSymbolicLink( at: srcURL, withDestinationURL: dstURL ) let loader = try PluginLoader( appRoot: tempURL, installRoot: URL(filePath: "/usr/local/"), logRoot: nil, pluginDirectories: [tempURL], pluginFactories: [factory] ) let plugins = loader.findPlugins() #expect(Set(plugins.map { $0.name }) == Set(["cli", "service"])) } @Test func testFindByName() async throws { let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: tempURL) } let factory = try setupMock(tempURL: tempURL) let loader = try PluginLoader( appRoot: tempURL, installRoot: URL(filePath: "/usr/local/"), logRoot: nil, pluginDirectories: [tempURL], pluginFactories: [factory] ) #expect(loader.findPlugin(name: "cli")?.name == "cli") #expect(loader.findPlugin(name: "service")?.name == "service") #expect(loader.findPlugin(name: "throw") == nil) } @Test func testFilterEnvironmentWithContainerPrefix() async throws { let env = [ "CONTAINER_FOO": "bar", "CONTAINER_BAZ": "qux", "OTHER_VAR": "value", ] let filtered = PluginLoader.filterEnvironment(env: env, additionalAllowKeys: []) #expect(filtered == ["CONTAINER_FOO": "bar", "CONTAINER_BAZ": "qux"]) } @Test func testFilterEnvironmentWithProxyKeys() async throws { let env = [ "http_proxy": "http://proxy:8080", "HTTP_PROXY": "http://proxy:8080", "https_proxy": "https://proxy:8443", "HTTPS_PROXY": "https://proxy:8443", "no_proxy": "localhost,127.0.0.1", "NO_PROXY": "localhost,127.0.0.1", "OTHER_VAR": "value", ] let filtered = PluginLoader.filterEnvironment(env: env) #expect( filtered == [ "http_proxy": "http://proxy:8080", "HTTP_PROXY": "http://proxy:8080", "https_proxy": "https://proxy:8443", "HTTPS_PROXY": "https://proxy:8443", "no_proxy": "localhost,127.0.0.1", "NO_PROXY": "localhost,127.0.0.1", ]) } @Test func testFilterEnvironmentWithBothContainerAndProxy() async throws { let env = [ "CONTAINER_FOO": "bar", "http_proxy": "http://proxy:8080", "OTHER_VAR": "value", "ANOTHER_VAR": "value2", ] let filtered = PluginLoader.filterEnvironment(env: env) #expect( filtered == [ "CONTAINER_FOO": "bar", "http_proxy": "http://proxy:8080", ]) } @Test func testFilterEnvironmentWithCustomAllowKeys() async throws { let env = [ "CONTAINER_FOO": "bar", "CUSTOM_KEY": "custom_value", "OTHER_VAR": "value", ] let filtered = PluginLoader.filterEnvironment(env: env, additionalAllowKeys: ["CUSTOM_KEY"]) #expect( filtered == [ "CONTAINER_FOO": "bar", "CUSTOM_KEY": "custom_value", ]) } @Test func testFilterEnvironmentEmpty() async throws { let filtered = PluginLoader.filterEnvironment(env: [:]) #expect(filtered.isEmpty) } @Test func testFilterEnvironmentNoMatches() async throws { let env = [ "PATH": "/usr/bin", "HOME": "/Users/test", "USER": "testuser", ] let filtered = PluginLoader.filterEnvironment(env: env, additionalAllowKeys: []) #expect(filtered.isEmpty) } @Test func testRegisterWithLaunchdDebugTrue() async throws { let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: tempURL) } let factory = try setupMock(tempURL: tempURL) let loader = try PluginLoader( appRoot: tempURL, installRoot: URL(filePath: "/usr/local/"), logRoot: nil, pluginDirectories: [tempURL], pluginFactories: [factory] ) let plugin = loader.findPlugin(name: "service")! let stateRoot = tempURL.appendingPathComponent("test-state") try loader.registerWithLaunchd(plugin: plugin, pluginStateRoot: stateRoot, debug: true) let plistURL = stateRoot.appendingPathComponent("service.plist") #expect(FileManager.default.fileExists(atPath: plistURL.path)) let plistData = try Data(contentsOf: plistURL) let plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as! [String: Any] let programArguments = plist["ProgramArguments"] as! [String] #expect(programArguments.contains("--debug")) } @Test func testRegisterWithLaunchdDebugFalse() async throws { let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: tempURL) } let factory = try setupMock(tempURL: tempURL) let loader = try PluginLoader( appRoot: tempURL, installRoot: URL(filePath: "/usr/local/"), logRoot: nil, pluginDirectories: [tempURL], pluginFactories: [factory] ) let plugin = loader.findPlugin(name: "service")! let stateRoot = tempURL.appendingPathComponent("test-state") try loader.registerWithLaunchd(plugin: plugin, pluginStateRoot: stateRoot, debug: false) let plistURL = stateRoot.appendingPathComponent("service.plist") #expect(FileManager.default.fileExists(atPath: plistURL.path)) let plistData = try Data(contentsOf: plistURL) let plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as! [String: Any] let programArguments = plist["ProgramArguments"] as! [String] #expect(!programArguments.contains("--debug")) } @Test func testRegisterWithLaunchdDebugDefault() async throws { let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: tempURL) } let factory = try setupMock(tempURL: tempURL) let loader = try PluginLoader( appRoot: tempURL, installRoot: URL(filePath: "/usr/local/"), logRoot: nil, pluginDirectories: [tempURL], pluginFactories: [factory] ) let plugin = loader.findPlugin(name: "service")! let stateRoot = tempURL.appendingPathComponent("test-state") try loader.registerWithLaunchd(plugin: plugin, pluginStateRoot: stateRoot) let plistURL = stateRoot.appendingPathComponent("service.plist") #expect(FileManager.default.fileExists(atPath: plistURL.path)) let plistData = try Data(contentsOf: plistURL) let plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as! [String: Any] let programArguments = plist["ProgramArguments"] as! [String] #expect(!programArguments.contains("--debug")) } private func setupMock(tempURL: URL) throws -> MockPluginFactory { let cliConfig = PluginConfig(abstract: "cli", author: "CLI", servicesConfig: nil) let cliPlugin: Plugin = Plugin(binaryURL: URL(filePath: "/bin/cli"), config: cliConfig) let serviceServicesConfig = PluginConfig.ServicesConfig( loadAtBoot: false, runAtLoad: false, services: [PluginConfig.Service(type: .runtime, description: nil)], defaultArguments: [] ) let serviceConfig = PluginConfig(abstract: "service", author: "SERVICE", servicesConfig: serviceServicesConfig) let servicePlugin: Plugin = Plugin(binaryURL: URL(filePath: "/bin/service"), config: serviceConfig) let mockPlugins = [ "cli": cliPlugin, MockPluginFactory.throwSuffix: nil, "service": servicePlugin, ] return try MockPluginFactory(tempURL: tempURL, plugins: mockPlugins) } } ================================================ FILE: Tests/ContainerPluginTests/PluginTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 @testable import ContainerPlugin struct PluginTest { @Test func testCLIPlugin() async throws { let config = PluginConfig( abstract: "abstract", author: "Ted Klondike", servicesConfig: nil ) let binaryPath = "/usr/local/libexec/container/plugin/bin/container-foo" let plugin = Plugin( binaryURL: URL(filePath: binaryPath), config: config ) #expect(plugin.name == "container-foo") #expect(!plugin.shouldBoot) #expect(plugin.getLaunchdLabel() == "com.apple.container.container-foo") #expect(plugin.getLaunchdLabel(instanceId: "1") == "com.apple.container.container-foo.1") #expect(plugin.getMachServices() == []) #expect(plugin.getMachServices(instanceId: "1") == []) #expect(plugin.getMachService(type: .runtime) == nil) #expect(plugin.getMachService(instanceId: "1", type: .runtime) == nil) #expect(!plugin.hasType(.runtime)) #expect(!plugin.hasType(.network)) #expect(plugin.helpText(padding: 20) == " container-foo abstract") } @Test func testServicePlugin() async throws { let config = PluginConfig( abstract: "abstract", author: "Ted Klondike", servicesConfig: .init( loadAtBoot: false, runAtLoad: false, services: [ .init(type: .runtime, description: "runtime service") ], defaultArguments: ["foo-bar"] ) ) let binaryPath = "/usr/local/libexec/container/plugin/linux-sandboxd/bin/linux-sandboxd" let plugin = Plugin( binaryURL: URL(filePath: binaryPath), config: config ) #expect(plugin.name == "linux-sandboxd") #expect(!plugin.shouldBoot) #expect(plugin.getLaunchdLabel() == "com.apple.container.linux-sandboxd") #expect(plugin.getLaunchdLabel(instanceId: "1") == "com.apple.container.linux-sandboxd.1") #expect( plugin.getMachServices() == [ "com.apple.container.runtime.linux-sandboxd" ]) #expect( plugin.getMachServices(instanceId: "1") == [ "com.apple.container.runtime.linux-sandboxd.1" ]) #expect(plugin.getMachService(type: .runtime) == "com.apple.container.runtime.linux-sandboxd") #expect(plugin.getMachService(instanceId: "1", type: .runtime) == "com.apple.container.runtime.linux-sandboxd.1") #expect(plugin.hasType(.runtime)) #expect(!plugin.hasType(.network)) #expect(plugin.config.servicesConfig!.defaultArguments == ["foo-bar"]) } @Test func testMultipleServicePlugin() async throws { let config = PluginConfig( abstract: "abstract", author: "Ted Klondike", servicesConfig: .init( loadAtBoot: true, runAtLoad: true, services: [ .init(type: .runtime, description: "runtime service"), .init(type: .network, description: "network service"), ], defaultArguments: ["start", "with", "params"] ) ) let binaryPath = "/usr/local/libexec/container/plugin/hydra/bin/hydra" let plugin = Plugin( binaryURL: URL(filePath: binaryPath), config: config ) #expect(plugin.name == "hydra") #expect(plugin.shouldBoot) #expect(plugin.getLaunchdLabel() == "com.apple.container.hydra") #expect(plugin.getLaunchdLabel(instanceId: "1") == "com.apple.container.hydra.1") #expect( plugin.getMachServices() == [ "com.apple.container.runtime.hydra", "com.apple.container.network.hydra", ]) #expect( plugin.getMachServices(instanceId: "1") == [ "com.apple.container.runtime.hydra.1", "com.apple.container.network.hydra.1", ]) #expect(plugin.getMachService(type: .network) == "com.apple.container.network.hydra") #expect(plugin.getMachService(instanceId: "1", type: .network) == "com.apple.container.network.hydra.1") #expect(plugin.hasType(.runtime)) #expect(plugin.hasType(.network)) #expect(plugin.config.servicesConfig!.defaultArguments == ["start", "with", "params"]) } } ================================================ FILE: Tests/ContainerResourceTests/ManagedResourceTests.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 @testable import ContainerResource struct ManagedResourceTests { // Mock implementation to test the randomId function struct MockManagedResource: ManagedResource { var id: String var name: String var creationDate: Date var labels: [String: String] static func nameValid(_ name: String) -> Bool { true } } @Test("randomId generates valid hex string SHA256 hash format") func testRandomIdFormat() { let id = MockManagedResource.generateId() // SHA256 hash is 64 hex characters (256 bits / 4 bits per hex char) #expect(id.count == 64, "randomId should generate 64 character string") // Should only contain valid hexadecimal characters (0-9, a-f) let hexCharacterSet = CharacterSet(charactersIn: "0123456789abcdef") let idCharacterSet = CharacterSet(charactersIn: id) #expect( hexCharacterSet.isSuperset(of: idCharacterSet), "randomId should only contain hexadecimal characters (0-9, a-f)") } @Test("randomId generates unique values") func testRandomIdUniqueness() { // Generate multiple IDs and verify they're all different let ids = (0..<100).map { _ in MockManagedResource.generateId() } let uniqueIds = Set(ids) #expect(uniqueIds.count == 100, "All generated IDs should be unique") } @Test("randomId uses lowercase hexadecimal") func testRandomIdLowercase() { let id = MockManagedResource.generateId() // Should not contain uppercase letters let uppercaseLetters = CharacterSet.uppercaseLetters let idCharacterSet = CharacterSet(charactersIn: id) #expect( uppercaseLetters.isDisjoint(with: idCharacterSet), "randomId should use lowercase hexadecimal characters") } } ================================================ FILE: Tests/ContainerResourceTests/NetworkConfigurationTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationError import ContainerizationExtras import Testing @testable import ContainerResource struct NetworkConfigurationTest { let defaultNetworkPluginInfo = NetworkPluginInfo(plugin: "container-network-vmnet") @Test func testValidationOkDefaults() throws { let id = "foo" _ = try NetworkConfiguration( id: id, mode: .nat, pluginInfo: defaultNetworkPluginInfo ) } @Test func testValidationGoodId() throws { let ids = [ String(repeating: "0", count: 63), "0", "0-_.1", ] for id in ids { let ipv4Subnet = try CIDRv4("192.168.64.1/24") let labels = [ "foo": "bar", "baz": String(repeating: "0", count: 4096 - "baz".count - "=".count), ] _ = try NetworkConfiguration( id: id, mode: .nat, ipv4Subnet: ipv4Subnet, labels: labels, pluginInfo: defaultNetworkPluginInfo ) } } @Test func testValidationBadId() throws { let ids = [ String(repeating: "0", count: 64), "-foo", "foo_", "Foo", ] for id in ids { let ipv4Subnet = try CIDRv4("192.168.64.1/24") let labels = [ "foo": "bar", "baz": String(repeating: "0", count: 4096 - "baz".count - "=".count), ] #expect { _ = try NetworkConfiguration( id: id, mode: .nat, ipv4Subnet: ipv4Subnet, labels: labels, pluginInfo: defaultNetworkPluginInfo ) } throws: { error in guard let err = error as? ContainerizationError else { return false } #expect(err.code == .invalidArgument) #expect(err.message.starts(with: "invalid network ID")) return true } } } @Test func testValidationGoodLabels() throws { let allLabels = [ ["com.example.my-label": "bar"], ["mycompany.com/my-label": "bar"], ["foo": String(repeating: "0", count: 4096 - "foo".count - "=".count)], [String(repeating: "0", count: 128): ""], ] for labels in allLabels { let id = "foo" let ipv4Subnet = try CIDRv4("192.168.64.1/24") _ = try NetworkConfiguration( id: id, mode: .nat, ipv4Subnet: ipv4Subnet, labels: labels, pluginInfo: defaultNetworkPluginInfo ) } } @Test func testValidationBadLabels() throws { let allLabels = [ [String(repeating: "0", count: 129): ""], ["foo": String(repeating: "0", count: 4097 - "foo".count - "=".count)], ["com..example.my-label": "bar"], ["mycompany.com//my-label": "bar"], ["": String(repeating: "0", count: 4096 - "foo".count - "=".count)], ] for labels in allLabels { let id = "foo" let ipv4Subnet = try CIDRv4("192.168.64.1/24") #expect { _ = try NetworkConfiguration( id: id, mode: .nat, ipv4Subnet: ipv4Subnet, labels: labels, pluginInfo: defaultNetworkPluginInfo ) } throws: { error in guard let err = error as? ContainerizationError else { return false } #expect(err.code == .invalidArgument) #expect(err.message.starts(with: "invalid label")) return true } } } } ================================================ FILE: Tests/ContainerResourceTests/PublishPortTests.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationExtras import Foundation import Testing @testable import ContainerResource struct PublishPortTests { @Test func testPublishPortsNonOverlapping() throws { let ports = [ PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9000, containerPort: 8080, proto: .tcp, count: 100), PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9100, containerPort: 8180, proto: .tcp, count: 100), ] #expect(!ports.hasOverlaps()) } @Test func testPublishPortsOverlapping() throws { let ports = [ PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9000, containerPort: 8080, proto: .tcp, count: 101), PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9100, containerPort: 8180, proto: .tcp, count: 100), ] #expect(ports.hasOverlaps()) } @Test func testPublishPortsSamePortDifferentProtocols() throws { let ports = [ PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 8080, containerPort: 8080, proto: .tcp, count: 1), PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 8080, containerPort: 8080, proto: .udp, count: 1), PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 1024, containerPort: 1024, proto: .tcp, count: 1025), PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 1024, containerPort: 1024, proto: .udp, count: 1025), ] #expect(!ports.hasOverlaps()) } } ================================================ FILE: Tests/ContainerResourceTests/RegistryResourceTests.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 @testable import ContainerResource @testable import ContainerizationOS struct RegistryResourceTests { func createRegistryInfo( hostname: String = "docker.io", username: String = "testuser" ) -> RegistryInfo { RegistryInfo( hostname: hostname, username: username, modifiedDate: Date(timeIntervalSince1970: 1_700_000_000), createdDate: Date(timeIntervalSince1970: 1_690_000_000) ) } @Test("RegistryResource id and name are both hostname") func testRegistryResourceIdAndName() { let hostname = "ghcr.io" let registryInfo = createRegistryInfo(hostname: hostname, username: "myuser") let resource = RegistryResource(from: registryInfo) #expect(resource.id == hostname, "id should be the hostname") #expect(resource.name == hostname, "name should be the hostname") #expect(resource.id == resource.name, "id and name should be identical") } @Test("RegistryResource maps RegistryInfo correctly") func testRegistryResourceMapping() { let hostname = "registry.example.com:5000" let username = "developer" let registryInfo = createRegistryInfo(hostname: hostname, username: username) let resource = RegistryResource(from: registryInfo) #expect(resource.id == hostname) #expect(resource.name == hostname) #expect(resource.username == username) #expect(resource.creationDate == registryInfo.createdDate) #expect(resource.modificationDate == registryInfo.modifiedDate) #expect(resource.labels.isEmpty, "default labels should be empty") } @Test("RegistryResource implements ManagedResource") func testManagedResourceConformance() { let registryInfo = createRegistryInfo() let resource = RegistryResource(from: registryInfo) // Test that it conforms to ManagedResource protocol let managedResource: any ManagedResource = resource #expect(managedResource.id == "docker.io") #expect(managedResource.name == "docker.io") #expect(managedResource.creationDate == registryInfo.createdDate) #expect(managedResource.labels.isEmpty) } @Test("RegistryResource is Codable - JSON encoding") func testRegistryResourceJSONEncoding() throws { let hostname = "docker.io" let username = "testuser" let registryInfo = createRegistryInfo(hostname: hostname, username: username) let resource = RegistryResource(from: registryInfo) // Encode to JSON let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .prettyPrinted] let jsonData = try encoder.encode(resource) let jsonString = String(data: jsonData, encoding: .utf8)! // Verify JSON contains expected fields #expect(jsonString.contains("\"id\""), "JSON should contain id field") #expect(jsonString.contains("\"name\""), "JSON should contain name field") #expect(jsonString.contains("\"username\""), "JSON should contain username field") #expect(jsonString.contains("\"creationDate\""), "JSON should contain creationDate field") #expect(jsonString.contains("\"modificationDate\""), "JSON should contain modificationDate field") #expect(jsonString.contains(hostname), "JSON should contain the hostname") #expect(jsonString.contains(username), "JSON should contain the username") } @Test("RegistryResource is Codable - round trip") func testRegistryResourceRoundTrip() throws { let hostname = "ghcr.io" let username = "developer" let registryInfo = createRegistryInfo(hostname: hostname, username: username) let original = RegistryResource(from: registryInfo) // Encode let encoder = JSONEncoder() let jsonData = try encoder.encode(original) // Decode let decoder = JSONDecoder() let decoded = try decoder.decode(RegistryResource.self, from: jsonData) // Verify #expect(decoded.id == original.id) #expect(decoded.name == original.name) #expect(decoded.username == original.username) #expect(decoded.creationDate.timeIntervalSince1970 == original.creationDate.timeIntervalSince1970) #expect(decoded.modificationDate.timeIntervalSince1970 == original.modificationDate.timeIntervalSince1970) #expect(decoded.labels == original.labels) } @Test("RegistryResource nameValid validates hostnames") func testRegistryResourceNameValidation() { // Valid hostnames #expect(RegistryResource.nameValid("docker.io"), "docker.io should be valid") #expect(RegistryResource.nameValid("ghcr.io"), "ghcr.io should be valid") #expect(RegistryResource.nameValid("registry.example.com"), "registry.example.com should be valid") #expect(RegistryResource.nameValid("localhost:5000"), "localhost:5000 should be valid") #expect(RegistryResource.nameValid("registry.k8s.io"), "registry.k8s.io should be valid") // Invalid hostnames #expect(!RegistryResource.nameValid(""), "empty string should be invalid") #expect(!RegistryResource.nameValid("-invalid.com"), "hostname starting with hyphen should be invalid") #expect(!RegistryResource.nameValid("invalid-.com"), "hostname ending with hyphen should be invalid") } @Test("RegistryResource can have labels") func testRegistryResourceWithLabels() { let hostname = "docker.io" let username = "testuser" let labels = [ "environment": "production", ResourceLabelKeys.role: "primary", ] let resource = RegistryResource( hostname: hostname, username: username, creationDate: Date(), modificationDate: Date(), labels: labels ) #expect(resource.labels.count == 2) #expect(resource.labels["environment"] == "production") #expect(resource.labels[ResourceLabelKeys.role] == "primary") } @Test("RegistryResource handles hostname with port") func testRegistryResourceWithPort() { let hostname = "localhost:5000" let registryInfo = createRegistryInfo(hostname: hostname, username: "admin") let resource = RegistryResource(from: registryInfo) #expect(resource.id == hostname) #expect(resource.name == hostname) #expect(RegistryResource.nameValid(hostname)) } @Test("Multiple RegistryResources can be encoded as array") func testMultipleRegistryResourcesJSONEncoding() throws { let registries = [ RegistryResource(from: createRegistryInfo(hostname: "docker.io", username: "user1")), RegistryResource(from: createRegistryInfo(hostname: "ghcr.io", username: "user2")), RegistryResource(from: createRegistryInfo(hostname: "quay.io", username: "user3")), ] let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .prettyPrinted] let jsonData = try encoder.encode(registries) let jsonString = String(data: jsonData, encoding: .utf8)! // Verify all hostnames are present #expect(jsonString.contains("docker.io")) #expect(jsonString.contains("ghcr.io")) #expect(jsonString.contains("quay.io")) // Verify all usernames are present #expect(jsonString.contains("user1")) #expect(jsonString.contains("user2")) #expect(jsonString.contains("user3")) // Print for manual verification print("Encoded JSON:") print(jsonString) } } ================================================ FILE: Tests/ContainerResourceTests/VolumeValidationTests.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES 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 @testable import ContainerResource struct VolumeValidationTests { @Test("Valid volume names should pass validation") func testValidVolumeNames() { let validNames = [ "a", // Single alphanumeric "1", // Single numeric "volume1", // Alphanumeric "my-volume", // With hyphen "my_volume", // With underscore "my.volume", // With period "volume-1.2_test", // Mixed valid characters "1volume", // Starting with number "Avolume", // Starting with uppercase "a" + String(repeating: "x", count: 254), // Max length (255) ] for name in validNames { #expect(VolumeStorage.isValidVolumeName(name), "'\(name)' should be valid") } } @Test("Invalid volume names should fail validation") func testInvalidVolumeNames() { let invalidNames = [ "", // Empty string ".volume", // Starting with period "_volume", // Starting with underscore "-volume", // Starting with hyphen "volume@", // Contains invalid character (@) "volume space", // Contains space "volume/path", // Contains slash "volume:tag", // Contains colon "volume#hash", // Contains hash "volume$", // Contains dollar sign "volume!", // Contains exclamation "volume%", // Contains percent "volume*", // Contains asterisk "volume+", // Contains plus "volume=", // Contains equals "volume[", // Contains bracket "volume]", // Contains bracket "volume{", // Contains brace "volume}", // Contains brace "volume|", // Contains pipe "volume\\", // Contains backslash "volume\"", // Contains quote "volume'", // Contains single quote "volume<", // Contains less than "volume>", // Contains greater than "volume?", // Contains question mark "volume,", // Contains comma "volume;", // Contains semicolon "a" + String(repeating: "x", count: 255), // Too long (256 chars) ] for name in invalidNames { #expect(!VolumeStorage.isValidVolumeName(name), "'\(name)' should be invalid") } } @Test("Edge cases for volume name validation") func testVolumeNameEdgeCases() { // Test exact boundary conditions #expect(VolumeStorage.isValidVolumeName("a"), "Single character should be valid") #expect(!VolumeStorage.isValidVolumeName(""), "Empty string should be invalid") // Test maximum length boundary let maxLengthName = String(repeating: "a", count: 255) let tooLongName = String(repeating: "a", count: 256) #expect(VolumeStorage.isValidVolumeName(maxLengthName), "255 character name should be valid") #expect(!VolumeStorage.isValidVolumeName(tooLongName), "256 character name should be invalid") // Test other edge cases #expect(VolumeStorage.isValidVolumeName("0volume"), "Name starting with digit should be valid") #expect(VolumeStorage.isValidVolumeName("Volume"), "Name starting with uppercase should be valid") #expect(!VolumeStorage.isValidVolumeName(".hidden"), "Name starting with period should be invalid") #expect(!VolumeStorage.isValidVolumeName("_private"), "Name starting with underscore should be invalid") #expect(!VolumeStorage.isValidVolumeName("-dash"), "Name starting with hyphen should be invalid") } @Test("Unicode and special character handling") func testUnicodeCharacters() { let unicodeNames = [ "volume-ñ", // Non-ASCII letter "volume-中文", // Chinese characters "volume-🍎", // Emoji "volume-café", // Accented characters "αβγ", // Greek letters ] for name in unicodeNames { #expect(!VolumeStorage.isValidVolumeName(name), "Unicode name '\(name)' should be invalid") } } @Test("Common Container volume name patterns") func testCommonVolumeNames() { let commonPatterns = [ "myapp-data", // Common app data volume "postgres_data", // Database volume "nginx.conf", // Config volume "logs-2024", // Log volume with year "cache_redis_v1.2", // Version-tagged cache "backup.daily", // Backup volume "shared-storage", // Shared volume ] for name in commonPatterns { #expect(VolumeStorage.isValidVolumeName(name), "Common volume name pattern '\(name)' should be valid") } } } ================================================ FILE: Tests/ContainerSandboxServiceTests/RuntimeConfigurationTests.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// // import ContainerAPIService import ContainerResource import ContainerSandboxServiceClient import Containerization // import ContainerizationOCI import Foundation import Testing /// Unit tests for RuntimeConfiguration functionality. /// /// These tests verify the runtime configuration serialization and deserialization, /// ensuring that configuration can be properly written, read, and used to create bundles. struct RuntimeConfigurationTests { /// Test that reading non-existent runtime configuration file throws /// appropriate error @Test func testReadNonExistentRuntimeConfiguration() throws { let tempDir = FileManager.default.temporaryDirectory let nonExistentPath = tempDir.appendingPathComponent("non-existent-\(UUID()).json") #expect(throws: Error.self) { _ = try RuntimeConfiguration.readRuntimeConfiguration(from: nonExistentPath) } } /// Test that runtime configuration reads and writes as expected @Test func testRuntimeConfigurationReadWrite() throws { let tempDir = FileManager.default.temporaryDirectory let bundlePath = tempDir.appendingPathComponent("test-bundle-\(UUID())") defer { try? FileManager.default.removeItem(at: bundlePath) } let initFs = Filesystem.virtiofs( source: "/path/to/initfs", destination: "/", options: ["ro"] ) let kernel = Kernel( path: URL(fileURLWithPath: "/path/to/kernel"), platform: .linuxArm ) let runtimeConfig = RuntimeConfiguration( path: bundlePath, initialFilesystem: initFs, kernel: kernel, containerConfiguration: nil, containerRootFilesystem: nil, options: nil ) try runtimeConfig.writeRuntimeConfiguration() defer { try? FileManager.default.removeItem(at: runtimeConfig.runtimeConfigurationPath) } let readRuntimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) #expect( readRuntimeConfig.path == bundlePath, "Path should match") #expect( readRuntimeConfig.kernel.path == kernel.path, "Kernel path should match") #expect( readRuntimeConfig.initialFilesystem.source == initFs.source, "Initial filesystem source should match") #expect( readRuntimeConfig.containerConfiguration == nil, "Container configuration should be nil") #expect( readRuntimeConfig.containerRootFilesystem == nil, "Root filesystem should be nil") #expect( readRuntimeConfig.options == nil, "Options should be nil") } } ================================================ FILE: Tests/DNSServerTests/CompositeResolverTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationExtras import Testing @testable import DNSServer struct CompositeResolverTest { @Test func testCompositeResolver() async throws { let foo = FooHandler() let bar = BarHandler() let resolver = CompositeResolver(handlers: [foo, bar]) let fooQuery = Message( id: UInt16(1), type: .query, questions: [ Question(name: "foo.", type: .host) ]) let fooResponse = try await resolver.answer(query: fooQuery) #expect(.noError == fooResponse?.returnCode) #expect(1 == fooResponse?.id) #expect(1 == fooResponse?.answers.count) let fooAnswer = fooResponse?.answers[0] as? HostRecord #expect(try IPv4Address("1.2.3.4") == fooAnswer?.ip) let barQuery = Message( id: UInt16(1), type: .query, questions: [ Question(name: "bar.", type: .host) ]) let barResponse = try await resolver.answer(query: barQuery) #expect(.noError == barResponse?.returnCode) #expect(1 == barResponse?.id) #expect(1 == barResponse?.answers.count) let barAnswer = barResponse?.answers[0] as? HostRecord #expect(try IPv4Address("5.6.7.8") == barAnswer?.ip) let otherQuery = Message( id: UInt16(1), type: .query, questions: [ Question(name: "other.", type: .host) ]) let otherResponse = try await resolver.answer(query: otherQuery) #expect(nil == otherResponse) } } ================================================ FILE: Tests/DNSServerTests/HostTableResolverTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationExtras import Testing @testable import DNSServer struct HostTableResolverTest { @Test func testEmptyQuestionsReturnsNil() async throws { let ip = try IPv4Address("1.2.3.4") let handler = try HostTableResolver(hosts4: ["foo.": ip]) let query = Message(id: UInt16(1), type: .query, questions: []) let response = try await handler.answer(query: query) #expect(nil == response) } @Test func testUnsupportedQuestionType() async throws { let ip = try IPv4Address("1.2.3.4") let handler = try HostTableResolver(hosts4: ["foo.": ip]) let query = Message( id: UInt16(1), type: .query, questions: [ Question(name: "foo.", type: .mailExchange) ]) let response = try await handler.answer(query: query) #expect(.notImplemented == response?.returnCode) #expect(1 == response?.id) #expect(.response == response?.type) #expect(1 == response?.questions.count) #expect(0 == response?.answers.count) } @Test func testAAAAQueryReturnsNoDataWhenARecordExists() async throws { let ip = try IPv4Address("1.2.3.4") let handler = try HostTableResolver(hosts4: ["foo.": ip]) let query = Message( id: UInt16(1), type: .query, questions: [ Question(name: "foo.", type: .host6) ]) let response = try await handler.answer(query: query) // AAAA queries should return NODATA (noError with empty answers) when A record exists // to avoid musl libc issues where NXDOMAIN causes complete DNS resolution failure #expect(.noError == response?.returnCode) #expect(1 == response?.id) #expect(.response == response?.type) #expect(1 == response?.questions.count) #expect(0 == response?.answers.count) } @Test func testAAAAQueryReturnsNilWhenHostDoesNotExist() async throws { let ip = try IPv4Address("1.2.3.4") let handler = try HostTableResolver(hosts4: ["foo.": ip]) let query = Message( id: UInt16(1), type: .query, questions: [ Question(name: "bar.", type: .host6) ]) let response = try await handler.answer(query: query) // AAAA queries for non-existent hosts should return nil (which becomes NXDOMAIN) #expect(nil == response) } @Test func testHostNotPresent() async throws { let ip = try IPv4Address("1.2.3.4") let handler = try HostTableResolver(hosts4: ["foo.": ip]) let query = Message( id: UInt16(1), type: .query, questions: [ Question(name: "bar.", type: .host) ]) let response = try await handler.answer(query: query) #expect(nil == response) } @Test func testHostPresent() async throws { let ip = try IPv4Address("1.2.3.4") let handler = try HostTableResolver(hosts4: ["foo.": ip]) let query = Message( id: UInt16(1), type: .query, questions: [ Question(name: "foo.", type: .host) ]) let response = try await handler.answer(query: query) #expect(.noError == response?.returnCode) #expect(1 == response?.id) #expect(.response == response?.type) #expect(1 == response?.questions.count) #expect("foo." == response?.questions[0].name) #expect(.host == response?.questions[0].type) #expect(1 == response?.answers.count) let answer = response?.answers[0] as? HostRecord #expect(try IPv4Address("1.2.3.4") == answer?.ip) } @Test func testHostPresentUppercaseTable() async throws { let ip = try IPv4Address("1.2.3.4") let handler = try HostTableResolver(hosts4: ["FOO.": ip]) let query = Message( id: UInt16(1), type: .query, questions: [ Question(name: "foo.", type: .host) ]) let response = try await handler.answer(query: query) #expect(.noError == response?.returnCode) #expect(1 == response?.id) #expect(.response == response?.type) #expect(1 == response?.questions.count) #expect("foo." == response?.questions[0].name) #expect(.host == response?.questions[0].type) #expect(1 == response?.answers.count) let answer = response?.answers[0] as? HostRecord #expect(try IPv4Address("1.2.3.4") == answer?.ip) } @Test func testHostPresentUppercaseQuestion() async throws { let ip = try IPv4Address("1.2.3.4") let handler = try HostTableResolver(hosts4: ["foo.": ip]) let query = Message( id: UInt16(1), type: .query, questions: [ Question(name: "FOO.", type: .host) ]) let response = try await handler.answer(query: query) #expect(.noError == response?.returnCode) #expect(1 == response?.id) #expect(.response == response?.type) #expect(1 == response?.questions.count) #expect("FOO." == response?.questions[0].name) #expect(.host == response?.questions[0].type) #expect(1 == response?.answers.count) let answer = response?.answers[0] as? HostRecord #expect(try IPv4Address("1.2.3.4") == answer?.ip) } } ================================================ FILE: Tests/DNSServerTests/MockHandlers.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationExtras import Testing @testable import DNSServer struct FooHandler: DNSHandler { public func answer(query: Message) async throws -> Message? { if query.questions[0].name == "foo." { let ip = try IPv4Address("1.2.3.4") return Message( id: query.id, type: .response, returnCode: .noError, questions: query.questions, answers: [HostRecord(name: query.questions[0].name, ttl: 0, ip: ip)] ) } return nil } } struct BarHandler: DNSHandler { public func answer(query: Message) async throws -> Message? { let question = query.questions[0] if question.name == "foo." || question.name == "bar." { let ip = try IPv4Address("5.6.7.8") return Message( id: query.id, type: .response, returnCode: .noError, questions: query.questions, answers: [HostRecord(name: query.questions[0].name, ttl: 0, ip: ip)] ) } return nil } } ================================================ FILE: Tests/DNSServerTests/NxDomainResolverTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Testing @testable import DNSServer struct NxDomainResolverTest { @Test func testUnsupportedQuestionType() async throws { let handler: NxDomainResolver = NxDomainResolver() let query = Message( id: UInt16(1), type: .query, questions: [ Question(name: "foo.", type: .host6) ]) let response = try await handler.answer(query: query) #expect(.notImplemented == response?.returnCode) #expect(1 == response?.id) #expect(.response == response?.type) #expect(1 == response?.questions.count) #expect(0 == response?.answers.count) } @Test func testHostNotPresent() async throws { let handler: NxDomainResolver = NxDomainResolver() let query = Message( id: UInt16(1), type: .query, questions: [ Question(name: "bar.", type: .host) ]) let response = try await handler.answer(query: query) #expect(.nonExistentDomain == response?.returnCode) #expect(1 == response?.id) #expect(.response == response?.type) #expect(1 == response?.questions.count) #expect(0 == response?.answers.count) } } ================================================ FILE: Tests/DNSServerTests/RecordsTests.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationExtras import Foundation import Testing @testable import DNSServer @Suite("DNS Records Tests") struct RecordsTests { // MARK: - DNSName Tests @Suite("DNSName") struct DNSNameTests { @Test("Create from string") func createFromString() throws { let name = try DNSName("example.com") #expect(name.labels == ["example", "com"]) } @Test("Create from string with trailing dot") func createFromStringTrailingDot() throws { let name = try DNSName("example.com.") #expect(name.labels == ["example", "com"]) } @Test("Description includes trailing dot") func descriptionTrailingDot() throws { let name = try DNSName("example.com") #expect(name.description == "example.com.") } @Test("Root domain") func rootDomain() throws { let name = try DNSName("") #expect(name.labels == []) #expect(name.description == ".") } @Test("Size calculation") func sizeCalculation() throws { let name = try DNSName("example.com") // [7]example[3]com[0] = 1 + 7 + 1 + 3 + 1 = 13 #expect(name.size == 13) } @Test("Serialize and deserialize") func serializeDeserialize() throws { let original = try DNSName("test.example.com") var buffer = [UInt8](repeating: 0, count: 64) let endOffset = try original.appendBuffer(&buffer, offset: 0) var parsed = DNSName() let readOffset = try parsed.bindBuffer(&buffer, offset: 0) // [4]test[7]example[3]com[0] = 5+8+4+1 = 18 #expect(endOffset == 18) #expect(readOffset == endOffset) #expect(parsed.labels == original.labels) } @Test("Serialize subdomain") func serializeSubdomain() throws { let name = try DNSName("a.b.c.d.example.com") var buffer = [UInt8](repeating: 0, count: 64) let endOffset = try name.appendBuffer(&buffer, offset: 0) var parsed = DNSName() let readOffset = try parsed.bindBuffer(&buffer, offset: 0) // [1]a[1]b[1]c[1]d[7]example[3]com[0] = 2+2+2+2+8+4+1 = 21 #expect(endOffset == 21) #expect(readOffset == endOffset) #expect(parsed.labels == ["a", "b", "c", "d", "example", "com"]) } @Test("Reject label too long") func rejectLabelTooLong() { let longLabel = String(repeating: "a", count: 64) #expect(throws: DNSBindError.self) { _ = try DNSName(longLabel + ".com") } } @Test("Reject embedded carriage return") func rejectEmbeddedCarriageReturn() { #expect(throws: DNSBindError.self) { _ = try DNSName("foo\r.com") } } @Test("Reject embedded newline") func rejectEmbeddedNewline() { #expect(throws: DNSBindError.self) { _ = try DNSName("foo\n.com") } } @Test("Reject embedded null byte") func rejectEmbeddedNullByte() { #expect(throws: DNSBindError.self) { _ = try DNSName("foo\0.com") } } @Test("Reject empty label") func rejectEmptyLabel() { #expect(throws: DNSBindError.self) { _ = try DNSName("foo..com") } } @Test("Reject name too long") func rejectNameTooLong() { // 9 labels * (1 + 30) bytes + 1 null = 280 bytes > 255 let label = String(repeating: "a", count: 30) let name = Array(repeating: label, count: 9).joined(separator: ".") #expect(throws: DNSBindError.self) { _ = try DNSName(name) } } @Test("Reject leading hyphen") func rejectLeadingHyphen() { #expect(throws: DNSBindError.self) { _ = try DNSName("-foo.com") } } @Test("Reject trailing hyphen") func rejectTrailingHyphen() { #expect(throws: DNSBindError.self) { _ = try DNSName("foo-.com") } } @Test("Reject leading underscore") func rejectLeadingUnderscore() { #expect(throws: DNSBindError.self) { _ = try DNSName("_foo.com") } } @Test("Reject trailing underscore") func rejectTrailingUnderscore() { #expect(throws: DNSBindError.self) { _ = try DNSName("foo_.com") } } @Test("Accept service labels via init(labels:)") func acceptServiceLabels() throws { let name = try DNSName(labels: ["_dns-sd", "_udp", "local"]) #expect(name.labels == ["_dns-sd", "_udp", "local"]) } @Test("Lowercase labels on init") func lowercaseLabelsOnInit() throws { let name = try DNSName("EXAMPLE.COM") #expect(name.labels == ["example", "com"]) } @Test("Lowercase labels on init with trailing dot") func lowercaseLabelsOnInitTrailingDot() throws { let name = try DNSName("Example.Com.") #expect(name.labels == ["example", "com"]) } @Test("Lowercase labels from wire format") func lowercaseLabelsFromWire() throws { // Wire-encode "EXAMPLE.COM" with uppercase bytes, then decode let upper = try DNSName(labels: ["EXAMPLE", "COM"]) var buffer = [UInt8](repeating: 0, count: 64) let endOffset = try upper.appendBuffer(&buffer, offset: 0) var parsed = DNSName() let readOffset = try parsed.bindBuffer(&buffer, offset: 0) // [7]example[3]com[0] = 8+4+1 = 13 #expect(endOffset == 13) #expect(readOffset == endOffset) #expect(parsed.labels == ["example", "com"]) } @Test("Follow valid compression pointer") func followCompressionPointer() throws { // Build a buffer with two names: // offset 0: "example.com." — [7]example[3]com[0] (13 bytes) // offset 13: "test." — [4]test 0xC0 0x00 ( 7 bytes) // The pointer 0xC0 0x00 points back to offset 0. var buffer: [UInt8] = [ 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, // [7]example 0x03, 0x63, 0x6f, 0x6d, // [3]com 0x00, // null terminator 0x04, 0x74, 0x65, 0x73, 0x74, // [4]test 0xC0, 0x00, // pointer to offset 0 ] var name = DNSName() let readOffset = try name.bindBuffer(&buffer, offset: 13) // Pointer bytes are at offset 18–19; returnOffset = 18 + 2 = 20 #expect(readOffset == 20) #expect(name.labels == ["test", "example", "com"]) } @Test("Reject forward compression pointer") func rejectForwardCompressionPointer() throws { // Craft a packet with a forward compression pointer at offset 12 pointing to offset 20 // Header (12 bytes) + pointer bytes var buffer = [UInt8](repeating: 0, count: 32) // At offset 0: compression pointer to offset 20 (forward) buffer[0] = 0xC0 buffer[1] = 0x14 // points to offset 20, which is > 0 #expect(throws: DNSBindError.self) { var b = buffer var name = DNSName() _ = try name.bindBuffer(&b, offset: 0) } } @Test("Reject self-referential compression pointer") func rejectSelfReferentialCompressionPointer() throws { var buffer = [UInt8](repeating: 0, count: 16) // At offset 0: compression pointer pointing back to offset 0 (same location) buffer[0] = 0xC0 buffer[1] = 0x00 // points to offset 0 == current offset, not prior #expect(throws: DNSBindError.self) { var b = buffer var name = DNSName() _ = try name.bindBuffer(&b, offset: 0) } } @Test("Reject compression pointer hop limit exceeded") func rejectCompressionPointerHopLimit() throws { // Build a chain of backward pointers: // offset 0: [1]a[0] — terminal name (3 bytes) // offset 3: 0xC0 0x00 — pointer → 0 // offset 5: 0xC0 0x03 — pointer → 3 // ...each entry points to the one before it... // offset 23: 0xC0 0x15 — pointer → 21 // offset 25: 0xC0 0x17 — pointer → 23 // // Reading from offset 25 follows 11 hops (25→23→21→...→3→0), // which exceeds the limit of 10. var buffer: [UInt8] = [ 0x01, 0x61, 0x00, // offset 0: [1]a[0] 0xC0, 0x00, // offset 3: → 0 0xC0, 0x03, // offset 5: → 3 0xC0, 0x05, // offset 7: → 5 0xC0, 0x07, // offset 9: → 7 0xC0, 0x09, // offset 11: → 9 0xC0, 0x0B, // offset 13: → 11 0xC0, 0x0D, // offset 15: → 13 0xC0, 0x0F, // offset 17: → 15 0xC0, 0x11, // offset 19: → 17 0xC0, 0x13, // offset 21: → 19 0xC0, 0x15, // offset 23: → 21 0xC0, 0x17, // offset 25: → 23 ] #expect(throws: DNSBindError.self) { var name = DNSName() _ = try name.bindBuffer(&buffer, offset: 25) } } } // MARK: - Question Tests @Suite("Question") struct QuestionTests { @Test("Create question") func create() { let q = Question(name: "example.com.", type: .host, recordClass: .internet) #expect(q.name == "example.com.") #expect(q.type == .host) #expect(q.recordClass == .internet) } @Test("Serialize and deserialize A record question") func serializeDeserializeA() throws { let original = Question(name: "example.com.", type: .host, recordClass: .internet) var buffer = [UInt8](repeating: 0, count: 64) let endOffset = try original.appendBuffer(&buffer, offset: 0) var parsed = Question(name: "") let readOffset = try parsed.bindBuffer(&buffer, offset: 0) // name([7]example[3]com[0]=13) + type(2) + class(2) = 17 #expect(endOffset == 17) #expect(readOffset == endOffset) #expect(parsed.type == .host) #expect(parsed.recordClass == .internet) } @Test("Serialize and deserialize AAAA record question") func serializeDeserializeAAAA() throws { let original = Question(name: "example.com.", type: .host6, recordClass: .internet) var buffer = [UInt8](repeating: 0, count: 64) let endOffset = try original.appendBuffer(&buffer, offset: 0) var parsed = Question(name: "") let readOffset = try parsed.bindBuffer(&buffer, offset: 0) // name([7]example[3]com[0]=13) + type(2) + class(2) = 17 #expect(endOffset == 17) #expect(readOffset == endOffset) #expect(parsed.type == .host6) } } // MARK: - HostRecord Tests @Suite("HostRecord") struct HostRecordTests { @Test("Create A record") func createARecord() throws { let ip = try IPv4Address("192.168.1.1") let record = HostRecord(name: "example.com.", ttl: 300, ip: ip) #expect(record.name == "example.com.") #expect(record.type == .host) #expect(record.ttl == 300) #expect(record.ip == ip) } @Test("Create AAAA record") func createAAAARecord() throws { let ip = try IPv6Address("::1") let record = HostRecord(name: "example.com.", ttl: 600, ip: ip) #expect(record.name == "example.com.") #expect(record.type == .host6) #expect(record.ttl == 600) } @Test("Serialize A record") func serializeARecord() throws { let ip = try IPv4Address("10.0.0.1") let record = HostRecord(name: "test.com.", ttl: 300, ip: ip) var buffer = [UInt8](repeating: 0, count: 64) let endOffset = try record.appendBuffer(&buffer, offset: 0) // name([4]test[3]com[0]=10) + type(2) + class(2) + ttl(4) + rdlen(2) + rdata(4) = 24 #expect(endOffset == 24) // Verify IP bytes at the end #expect(buffer[endOffset - 4] == 10) #expect(buffer[endOffset - 3] == 0) #expect(buffer[endOffset - 2] == 0) #expect(buffer[endOffset - 1] == 1) } @Test("Serialize AAAA record") func serializeAAAARecord() throws { let ip = try IPv6Address("::1") let record = HostRecord(name: "test.com.", ttl: 300, ip: ip) var buffer = [UInt8](repeating: 0, count: 64) let endOffset = try record.appendBuffer(&buffer, offset: 0) // name([4]test[3]com[0]=10) + type(2) + class(2) + ttl(4) + rdlen(2) + rdata(16) = 36 #expect(endOffset == 36) #expect(buffer[endOffset - 1] == 1) } } // MARK: - Message Tests @Suite("Message") struct MessageTests { @Test("Create query message") func createQuery() { let msg = Message( id: 0x1234, type: .query, questions: [Question(name: "example.com.", type: .host)] ) #expect(msg.id == 0x1234) #expect(msg.type == .query) #expect(msg.questions.count == 1) } @Test("Create response message") func createResponse() throws { let ip = try IPv4Address("192.168.1.1") let msg = Message( id: 0x1234, type: .response, returnCode: .noError, questions: [Question(name: "example.com.", type: .host)], answers: [HostRecord(name: "example.com.", ttl: 300, ip: ip)] ) #expect(msg.type == .response) #expect(msg.returnCode == .noError) #expect(msg.answers.count == 1) } @Test("Serialize and deserialize query") func serializeDeserializeQuery() throws { let original = Message( id: 0xABCD, type: .query, recursionDesired: true, questions: [Question(name: "example.com.", type: .host)] ) let data = try original.serialize() let parsed = try Message(deserialize: data) #expect(parsed.id == 0xABCD) #expect(parsed.type == .query) #expect(parsed.recursionDesired == true) #expect(parsed.questions.count == 1) #expect(parsed.questions[0].type == .host) } @Test("Serialize response with answer") func serializeResponse() throws { let ip = try IPv4Address("10.0.0.1") let msg = Message( id: 0x1234, type: .response, authoritativeAnswer: true, returnCode: .noError, questions: [Question(name: "test.com.", type: .host)], answers: [HostRecord(name: "test.com.", ttl: 300, ip: ip)] ) let data = try msg.serialize() // Verify we can at least parse the header back let parsed = try Message(deserialize: data) #expect(parsed.id == 0x1234) #expect(parsed.type == .response) #expect(parsed.authoritativeAnswer == true) #expect(parsed.returnCode == .noError) } @Test("Serialize NXDOMAIN response") func serializeNxdomain() throws { let msg = Message( id: 0x1234, type: .response, returnCode: .nonExistentDomain, questions: [Question(name: "unknown.com.", type: .host)], answers: [] ) let data = try msg.serialize() let parsed = try Message(deserialize: data) #expect(parsed.returnCode == .nonExistentDomain) #expect(parsed.answers.count == 0) } @Test("Serialize NODATA response (empty answers with noError)") func serializeNodata() throws { let msg = Message( id: 0x1234, type: .response, returnCode: .noError, questions: [Question(name: "example.com.", type: .host6)], answers: [] ) let data = try msg.serialize() let parsed = try Message(deserialize: data) #expect(parsed.returnCode == .noError) #expect(parsed.answers.count == 0) } @Test("Multiple questions") func multipleQuestions() throws { let msg = Message( id: 0x1234, type: .query, questions: [ Question(name: "a.com.", type: .host), Question(name: "b.com.", type: .host6), ] ) let data = try msg.serialize() let parsed = try Message(deserialize: data) #expect(parsed.questions.count == 2) #expect(parsed.questions[0].type == .host) #expect(parsed.questions[1].type == .host6) } @Test("Reject too many questions") func rejectTooManyQuestions() { let questions = Array(repeating: Question(name: "a.com.", type: .host), count: Int(UInt16.max) + 1) let msg = Message(id: 0, type: .query, questions: questions) #expect(throws: DNSBindError.self) { _ = try msg.serialize() } } @Test("Reject too many answers") func rejectTooManyAnswers() throws { let ip = try IPv4Address("1.2.3.4") let answers = Array(repeating: HostRecord(name: "a.com.", ttl: 0, ip: ip), count: Int(UInt16.max) + 1) let msg = Message(id: 0, type: .response, answers: answers) #expect(throws: DNSBindError.self) { _ = try msg.serialize() } } } // MARK: - Wire Format Tests @Suite("Wire Format") struct WireFormatTests { @Test("Parse real DNS query bytes") func parseRealQuery() throws { // A minimal DNS query for "example.com" A record // Header: ID=0x1234, QR=0, OPCODE=0, RD=1, QDCOUNT=1 let queryBytes: [UInt8] = [ 0x12, 0x34, // ID 0x01, 0x00, // Flags: RD=1 0x00, 0x01, // QDCOUNT=1 0x00, 0x00, // ANCOUNT=0 0x00, 0x00, // NSCOUNT=0 0x00, 0x00, // ARCOUNT=0 // Question: example.com A IN 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, // "example" 0x03, 0x63, 0x6f, 0x6d, // "com" 0x00, // null terminator 0x00, 0x01, // QTYPE=A 0x00, 0x01, // QCLASS=IN ] let msg = try Message(deserialize: Data(queryBytes)) #expect(msg.id == 0x1234) #expect(msg.type == .query) #expect(msg.recursionDesired == true) #expect(msg.questions.count == 1) #expect(msg.questions[0].type == .host) #expect(msg.questions[0].recordClass == .internet) } @Test("Roundtrip preserves data") func roundtrip() throws { let ip = try IPv4Address("1.2.3.4") let original = Message( id: 0xBEEF, type: .response, operationCode: .query, authoritativeAnswer: true, truncation: false, recursionDesired: true, recursionAvailable: true, returnCode: .noError, questions: [Question(name: "test.example.com.", type: .host)], answers: [HostRecord(name: "test.example.com.", ttl: 3600, ip: ip)] ) let data = try original.serialize() let parsed = try Message(deserialize: data) #expect(parsed.id == original.id) #expect(parsed.type == original.type) #expect(parsed.authoritativeAnswer == original.authoritativeAnswer) #expect(parsed.truncation == original.truncation) #expect(parsed.recursionDesired == original.recursionDesired) #expect(parsed.recursionAvailable == original.recursionAvailable) #expect(parsed.returnCode == original.returnCode) #expect(parsed.questions.count == original.questions.count) } @Test("Reject unknown opcode") func rejectUnknownOpcode() { // Opcode occupies bits 14–11 of the flags word. Value 3 is reserved. // Flags: 0x18 0x00 = QR=0, OPCODE=3, all other bits clear. let bytes: [UInt8] = [ 0x00, 0x01, // ID 0x18, 0x00, // Flags: OPCODE=3 (reserved) 0x00, 0x00, // QDCOUNT=0 0x00, 0x00, // ANCOUNT=0 0x00, 0x00, // NSCOUNT=0 0x00, 0x00, // ARCOUNT=0 ] #expect(throws: DNSBindError.self) { _ = try Message(deserialize: Data(bytes)) } } @Test("Reject unknown RCODE") func rejectUnknownRcode() { // RCODE occupies bits 3–0 of the flags word. Value 12 is reserved. // Flags: 0x00 0x0C = QR=0, OPCODE=0, RCODE=12. let bytes: [UInt8] = [ 0x00, 0x01, // ID 0x00, 0x0C, // Flags: RCODE=12 (reserved) 0x00, 0x00, // QDCOUNT=0 0x00, 0x00, // ANCOUNT=0 0x00, 0x00, // NSCOUNT=0 0x00, 0x00, // ARCOUNT=0 ] #expect(throws: DNSBindError.self) { _ = try Message(deserialize: Data(bytes)) } } @Test("Reject unknown query type") func rejectUnknownQueryType() { // Type 54 is unassigned in the IANA DNS parameters registry. let bytes: [UInt8] = [ 0x00, 0x01, // ID 0x00, 0x00, // Flags: standard query 0x00, 0x01, // QDCOUNT=1 0x00, 0x00, // ANCOUNT=0 0x00, 0x00, // NSCOUNT=0 0x00, 0x00, // ARCOUNT=0 0x01, 0x61, 0x00, // name: [1]a[0] 0x00, 0x36, // QTYPE=54 (unassigned) 0x00, 0x01, // QCLASS=IN ] #expect(throws: DNSBindError.self) { _ = try Message(deserialize: Data(bytes)) } } @Test("Reject unknown record class") func rejectUnknownRecordClass() { // Class 2 is unassigned in the IANA DNS parameters registry. let bytes: [UInt8] = [ 0x00, 0x01, // ID 0x00, 0x00, // Flags: standard query 0x00, 0x01, // QDCOUNT=1 0x00, 0x00, // ANCOUNT=0 0x00, 0x00, // NSCOUNT=0 0x00, 0x00, // ARCOUNT=0 0x01, 0x61, 0x00, // name: [1]a[0] 0x00, 0x01, // QTYPE=A 0x00, 0x02, // QCLASS=2 (unassigned) ] #expect(throws: DNSBindError.self) { _ = try Message(deserialize: Data(bytes)) } } } } ================================================ FILE: Tests/DNSServerTests/StandardQueryValidatorTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import ContainerizationExtras import Testing @testable import DNSServer struct StandardQueryValidatorTest { @Test func testRejectResponseAsQuery() async throws { let fooHandler = FooHandler() let handler = StandardQueryValidator(handler: fooHandler) let query = Message( id: UInt16(1), type: .response, questions: [ Question(name: "foo.", type: .host) ]) let response = try await handler.answer(query: query) #expect(.formatError == response?.returnCode) #expect(1 == response?.id) #expect(.response == response?.type) #expect(1 == response?.questions.count) #expect("foo." == response?.questions[0].name) #expect(.host == response?.questions[0].type) #expect(0 == response?.answers.count) } @Test func testRejectNonQueryOperation() async throws { let fooHandler = FooHandler() let handler = StandardQueryValidator(handler: fooHandler) let query = Message( id: UInt16(2), type: .query, operationCode: .notify, questions: [ Question(name: "foo.", type: .host) ]) let response = try await handler.answer(query: query) #expect(.notImplemented == response?.returnCode) #expect(2 == response?.id) #expect(.response == response?.type) #expect(1 == response?.questions.count) #expect("foo." == response?.questions[0].name) #expect(.host == response?.questions[0].type) #expect(0 == response?.answers.count) } @Test func testRejectNoQuestions() async throws { let fooHandler = FooHandler() let handler = StandardQueryValidator(handler: fooHandler) let query = Message(id: UInt16(3), type: .query, questions: []) let response = try await handler.answer(query: query) #expect(.formatError == response?.returnCode) #expect(3 == response?.id) #expect(.response == response?.type) #expect(0 == response?.answers.count) } @Test func testRejectMultipleQuestions() async throws { let fooHandler = FooHandler() let handler = StandardQueryValidator(handler: fooHandler) let query = Message( id: UInt16(2), type: .query, questions: [ Question(name: "foo.", type: .host), Question(name: "bar.", type: .host), ]) let response = try await handler.answer(query: query) #expect(.formatError == response?.returnCode) #expect(2 == response?.id) #expect(.response == response?.type) #expect(2 == response?.questions.count) #expect("foo." == response?.questions[0].name) #expect(.host == response?.questions[0].type) #expect("bar." == response?.questions[1].name) #expect(.host == response?.questions[1].type) #expect(0 == response?.answers.count) } @Test func testSuccessfulValidation() async throws { let fooHandler = FooHandler() let handler = StandardQueryValidator(handler: fooHandler) let query = Message( id: UInt16(2), type: .query, questions: [ Question(name: "foo.", type: .host) ]) let response = try await handler.answer(query: query) #expect(.noError == response?.returnCode) #expect(2 == response?.id) #expect(.response == response?.type) #expect(1 == response?.questions.count) #expect("foo." == response?.questions[0].name) #expect(.host == response?.questions[0].type) #expect(1 == response?.answers.count) let answer = response?.answers[0] as? HostRecord #expect(try IPv4Address("1.2.3.4") == answer?.ip) } } ================================================ FILE: Tests/SocketForwarderTests/ConnectHandlerRaceTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import NIO import Testing @testable import SocketForwarder struct ConnectHandlerRaceTest { let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) @Test func testRapidConnectDisconnect() async throws { let requestCount = 500 let serverAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0) let server = TCPEchoServer(serverAddress: serverAddress, eventLoopGroup: eventLoopGroup) let serverChannel = try await server.run().get() let actualServerAddress = try #require(serverChannel.localAddress) let proxyAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0) let forwarder = try TCPForwarder( proxyAddress: proxyAddress, serverAddress: actualServerAddress, eventLoopGroup: eventLoopGroup ) let forwarderResult = try await forwarder.run().get() let actualProxyAddress = try #require(forwarderResult.proxyAddress) try await withThrowingTaskGroup(of: Void.self) { group in for _ in 0..(size: 3) #expect(cache.count == 0) #expect(cache.put(key: "foo", value: "1") == nil) #expect(cache.count == 1) #expect(cache.put(key: "bar", value: "2") == nil) #expect(cache.count == 2) #expect(cache.put(key: "baz", value: "3") == nil) #expect(cache.count == 3) let replaced = try #require(cache.put(key: "bar", value: "4")) #expect(replaced == ("bar", "2")) #expect(cache.count == 3) let firstEvicted = try #require(cache.put(key: "qux", value: "5")) #expect(firstEvicted == ("foo", "1")) #expect(cache.count == 3) let secondEvicted = try #require(cache.put(key: "quux", value: "6")) #expect(secondEvicted == ("baz", "3")) #expect(cache.count == 3) #expect(cache.get("foo") == nil) #expect(cache.get("bar") == "4") #expect(cache.get("baz") == nil) #expect(cache.get("qux") == "5") #expect(cache.get("quux") == "6") } } ================================================ FILE: Tests/SocketForwarderTests/TCPEchoHandler.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import NIO final class TCPEchoHandler: ChannelInboundHandler { typealias InboundIn = ByteBuffer typealias OutboundOut = ByteBuffer func channelRead(context: ChannelHandlerContext, data: NIOAny) { context.writeAndFlush(data, promise: nil) } func errorCaught(context: ChannelHandlerContext, error: Error) { context.close(promise: nil) } } ================================================ FILE: Tests/SocketForwarderTests/TCPEchoServer.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import NIO struct TCPEchoServer: Sendable { private let serverAddress: SocketAddress private let eventLoopGroup: MultiThreadedEventLoopGroup public init(serverAddress: SocketAddress, eventLoopGroup: MultiThreadedEventLoopGroup) { self.serverAddress = serverAddress self.eventLoopGroup = eventLoopGroup } public func run() throws -> EventLoopFuture { let bootstrap = ServerBootstrap(group: self.eventLoopGroup) .serverChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1) .childChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1) .childChannelInitializer { channel in channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler( BackPressureHandler() ) try channel.pipeline.syncOperations.addHandler( TCPEchoHandler() ) } } return bootstrap.bind(to: self.serverAddress) } } ================================================ FILE: Tests/SocketForwarderTests/TCPForwarderTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import NIO import Testing @testable import SocketForwarder struct TCPForwarderTest { let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) @Test func testTCPForwarder() async throws { let requestCount = 100 var responses: [String] = [] // bring up server on ephemeral port and get address let serverAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0) let server = TCPEchoServer(serverAddress: serverAddress, eventLoopGroup: eventLoopGroup) let serverChannel = try await server.run().get() let actualServerAddress = try #require(serverChannel.localAddress) // bring up proxy on ephemeral port and get address let proxyAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0) let forwarder = try TCPForwarder( proxyAddress: proxyAddress, serverAddress: actualServerAddress, eventLoopGroup: eventLoopGroup ) let forwarderResult = try await forwarder.run().get() let actualProxyAddress = try #require(forwarderResult.proxyAddress) // send a bunch of messages and collect them try await withThrowingTaskGroup(of: String.self) { group in for i in 0.. 1) #expect(bParts.count > 1) let aIndex = try #require(Int(aParts[0])) let bIndex = try #require(Int(bParts[0])) return aIndex < bIndex } #expect(sortedResponses.count == requestCount) for i in 0.. DecodingState { let readableBytes = buffer.readableBytesView guard let firstLine = readableBytes.firstIndex(of: self.newLine).map({ readableBytes[..<$0] }) else { return .needMoreData } buffer.moveReaderIndex(forwardBy: firstLine.count + 1) // Fire a read without a newline let data = Self.wrapInboundOut(String(buffer: ByteBuffer(firstLine))) context.fireChannelRead(data) return .continue } func encode(data: String, out: inout ByteBuffer) throws { out.writeString(data) out.writeInteger(self.newLine) } } ================================================ FILE: Tests/SocketForwarderTests/UDPEchoHandler.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import NIO final class UDPEchoHandler: ChannelInboundHandler { typealias InboundIn = AddressedEnvelope func channelRead(context: ChannelHandlerContext, data: NIOAny) { context.writeAndFlush(data, promise: nil) } func errorCaught(context: ChannelHandlerContext, error: Error) { context.close(promise: nil) } } ================================================ FILE: Tests/SocketForwarderTests/UDPEchoServer.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import NIO struct UDPEchoServer: Sendable { private let serverAddress: SocketAddress private let eventLoopGroup: MultiThreadedEventLoopGroup public init(serverAddress: SocketAddress, eventLoopGroup: MultiThreadedEventLoopGroup) { self.serverAddress = serverAddress self.eventLoopGroup = eventLoopGroup } public func run() throws -> EventLoopFuture { let bootstrap = DatagramBootstrap(group: self.eventLoopGroup) .channelInitializer { channel in channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler( UDPEchoHandler() ) } } return bootstrap.bind(to: self.serverAddress) } } ================================================ FILE: Tests/SocketForwarderTests/UDPForwarderTest.swift ================================================ //===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Logging import NIO import Testing @testable import SocketForwarder struct UDPForwarderTest { let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) @Test func testUDPForwarder() async throws { let requestCount = 100 var responses: [String] = [] // bring up server on ephemeral port and get address let serverAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0) let server = UDPEchoServer(serverAddress: serverAddress, eventLoopGroup: eventLoopGroup) let serverChannel = try await server.run().get() let actualServerAddress = try #require(serverChannel.localAddress) // bring up proxy on ephemeral port and get address let proxyAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0) let forwarder = try UDPForwarder( proxyAddress: proxyAddress, serverAddress: actualServerAddress, eventLoopGroup: eventLoopGroup ) let forwarderResult = try await forwarder.run().get() let actualProxyAddress = try #require(forwarderResult.proxyAddress) // send a bunch of messages and collect them print("testUDPForwarder: send messages") try await withThrowingTaskGroup(of: String.self) { group in for i in 0...self, outboundType: AddressedEnvelope.self ) ) } } try await channel.executeThenClose { inbound, outbound in let remoteAddress = try #require(channel.channel.remoteAddress) let data = ByteBufferAllocator().buffer(string: "\(i): success-udp") try await outbound.write(AddressedEnvelope(remoteAddress: remoteAddress, data: data)) for try await inboundData in inbound { response = String(buffer: inboundData.data) break } } return response } } for try await response in group { responses.append(response) } } // close everything down print("testUDPForwarder: close server") serverChannel.eventLoop.execute { _ = serverChannel.close() } try await serverChannel.closeFuture.get() print("testUDPForwarder: close forwarder") forwarderResult.close() try await forwarderResult.wait() // verify all expected messages print("testUDPForwarder: validate responses") let sortedResponses = try responses.sorted { (a, b) in let aParts = a.split(separator: ":") let bParts = b.split(separator: ":") #expect(aParts.count > 1) #expect(bParts.count > 1) let aIndex = try #require(Int(aParts[0])) let bIndex = try #require(Int(bParts[0])) return aIndex < bIndex } #expect(sortedResponses.count == requestCount) for i in 0.. [!IMPORTANT] > This file contains documentation for the CURRENT BRANCH. To find documentation for official releases, find the target release on the [Release Page](https://github.com/apple/container/releases) and click the tag corresponding to your release version. > > Example: [release 0.4.1 tag](https://github.com/apple/container/tree/0.4.1) Note: Command availability may vary depending on host operating system and macOS version. ## Core Commands ### `container run` Runs a container from an image. If a command is provided, it will execute inside the container; otherwise the image's default command runs. By default the container runs in the foreground and stdin remains closed unless `-i`/`--interactive` is specified. **Usage** ```bash container run [] [ ...] ``` **Arguments** * ``: Image name * ``: Container init process arguments **Process Options** * `-e, --env `: Set environment variables (format: key=value) * `--env-file `: Read in a file of environment variables (key=value format, ignores # comments and blank lines) * `--gid `: Set the group ID for the process * `-i, --interactive`: Keep the standard input open even if not attached * `-t, --tty`: Open a TTY with the process * `-u, --user `: Set the user for the process (format: name|uid[:gid]) * `--uid `: Set the user ID for the process * `-w, --workdir, --cwd `: Set the initial working directory inside the container **Resource Options** * `-c, --cpus `: Number of CPUs to allocate to the container * `-m, --memory `: Amount of memory (1MiByte granularity), with optional K, M, G, T, or P suffix **Management Options** * `-a, --arch `: Set arch if image can target multiple architectures (default: arm64) * `--cidfile `: Write the container ID to the path provided * `-d, --detach`: Run the container and detach from the process * `--dns `: DNS nameserver IP address * `--dns-domain `: Default DNS domain * `--dns-option