diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index c77f51a59..413db080c 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -225,6 +225,7 @@ public struct Application: AsyncLoggableCommand { return [ BuilderCommand.self, SystemCommand.self, + UpgradeCommand.self, ] } @@ -232,6 +233,7 @@ public struct Application: AsyncLoggableCommand { BuilderCommand.self, NetworkCommand.self, SystemCommand.self, + UpgradeCommand.self, ] } diff --git a/Sources/ContainerCommands/Upgrade/GitHubRelease.swift b/Sources/ContainerCommands/Upgrade/GitHubRelease.swift new file mode 100644 index 000000000..d0a9ff560 --- /dev/null +++ b/Sources/ContainerCommands/Upgrade/GitHubRelease.swift @@ -0,0 +1,89 @@ +//===----------------------------------------------------------------------===// +// 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 downloadable asset attached to a GitHub release. +struct GitHubReleaseAsset: Decodable, Equatable { + let name: String + let browserDownloadURL: URL + + enum CodingKeys: String, CodingKey { + case name + case browserDownloadURL = "browser_download_url" + } +} + +/// The subset of the GitHub release API response needed to upgrade `container`. +struct GitHubRelease: Decodable, Equatable { + let tagName: String + let assets: [GitHubReleaseAsset] + + enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case assets + } +} + +/// An installer package selected from a release's assets. +struct InstallerPackage: Equatable { + let url: URL + let isSigned: Bool +} + +extension GitHubRelease { + /// The GitHub API endpoint for a release: the latest release when + /// `version` is nil, otherwise the release tagged `version`. + static func endpoint(forVersion version: String?) -> URL { + let base = "https://api.github.com/repos/apple/container/releases" + guard let version else { + return URL(string: "\(base)/latest")! + } + return URL(string: "\(base)/tags/\(version)")! + } + + /// Selects the installer package, preferring signed packages, with the + /// same name precedence as `scripts/update-container.sh`. + func installerPackage() -> InstallerPackage? { + let signedNames = [ + "container-installer-signed.pkg", + "container-\(tagName)-installer-signed.pkg", + ] + let unsignedNames = [ + "container-installer-unsigned.pkg", + "container-\(tagName)-installer-unsigned.pkg", + ] + for name in signedNames { + if let match = assets.first(where: { $0.name == name }) { + return InstallerPackage(url: match.browserDownloadURL, isSigned: true) + } + } + for name in unsignedNames { + if let match = assets.first(where: { $0.name == name }) { + return InstallerPackage(url: match.browserDownloadURL, isSigned: false) + } + } + return nil + } +} + +/// Decides whether an upgrade should proceed, mirroring the plain string +/// comparison in `scripts/update-container.sh` (no semver ordering). +enum UpgradePolicy { + static func shouldUpgrade(target: String, installed: String, force: Bool) -> Bool { + force || target != installed + } +} diff --git a/Sources/ContainerCommands/Upgrade/InstallationMethod.swift b/Sources/ContainerCommands/Upgrade/InstallationMethod.swift new file mode 100644 index 000000000..d9c5c16f7 --- /dev/null +++ b/Sources/ContainerCommands/Upgrade/InstallationMethod.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// How the `container` toolset was installed on this host, which determines +/// how `container upgrade` performs the upgrade. +enum InstallationMethod: Equatable { + /// Installed with the installer package from the GitHub release page; + /// identified by the `com.apple.container-installer` receipt. + case installerPackage + /// Installed with the Homebrew `container` formula; identified by the + /// executable resolving into a Homebrew Cellar. + case homebrew + /// No known installation source, e.g. a build from source. + case unknown + + /// The package identifier recorded by the release-page installer, + /// matching `scripts/uninstall-container.sh`. + static let installerPackageID = "com.apple.container-installer" + + /// Classifies the installation from the symlink-resolved path of the + /// running executable and the presence of the installer receipt. The + /// Cellar check runs first: on Intel Macs Homebrew shares the + /// `/usr/local` prefix with the installer package, and the resolved + /// path identifies which copy is actually running. + static func classify(resolvedExecutablePath: String, hasInstallerReceipt: Bool) -> InstallationMethod { + if resolvedExecutablePath.contains("/Cellar/container/") { + return .homebrew + } + if hasInstallerReceipt { + return .installerPackage + } + return .unknown + } + + /// Detects the installation method of the running executable. + static func detect() -> InstallationMethod { + let executablePath = Bundle.main.executablePath ?? CommandLine.arguments[0] + let resolved = URL(fileURLWithPath: executablePath).resolvingSymlinksInPath().path + return classify(resolvedExecutablePath: resolved, hasInstallerReceipt: hasInstallerReceipt()) + } + + private static func hasInstallerReceipt() -> Bool { + let query = Process() + query.executableURL = URL(fileURLWithPath: "/usr/sbin/pkgutil") + query.arguments = ["--pkg-info", installerPackageID] + query.standardOutput = FileHandle.nullDevice + query.standardError = FileHandle.nullDevice + do { + try query.run() + } catch { + return false + } + query.waitUntilExit() + return query.terminationStatus == 0 + } +} diff --git a/Sources/ContainerCommands/Upgrade/UpgradeCommand.swift b/Sources/ContainerCommands/Upgrade/UpgradeCommand.swift new file mode 100644 index 000000000..6ac643d76 --- /dev/null +++ b/Sources/ContainerCommands/Upgrade/UpgradeCommand.swift @@ -0,0 +1,204 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin +import ContainerVersion +import ContainerizationError +import Foundation + +extension Application { + public struct UpgradeCommand: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "upgrade", + abstract: "Upgrade container to the latest or a specific release" + ) + + /// Launchd prefix of the services that must be stopped before upgrading. + private static let launchdPrefix = "com.apple.container." + + @Option(name: [.customShort("v"), .customLong("release")], help: "Upgrade to a specific release version (defaults to the latest release)") + var release: String? + + @Flag(name: .shortAndLong, help: "Force the upgrade even if the target version is already installed") + var force = false + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + public func run() async throws { + let runningServices = try ServiceManager.enumerate() + .filter { $0.hasPrefix(Self.launchdPrefix) } + guard runningServices.isEmpty else { + throw ContainerizationError( + .invalidState, + message: "`container` is still running. Please ensure the service is stopped by running `container system stop`" + ) + } + + switch InstallationMethod.detect() { + case .installerPackage: + try await upgradeInstallerPackage() + case .homebrew: + try upgradeHomebrew() + case .unknown: + throw ContainerizationError( + .invalidState, + message: + "Cannot determine how container was installed: found neither the \(InstallationMethod.installerPackageID) package receipt nor a Homebrew installation. Upgrade using the method you installed with, e.g. the installer package from https://github.com/apple/container/releases" + ) + } + } + + private func upgradeInstallerPackage() async throws { + let targetRelease = try await fetchRelease() + let target = targetRelease.tagName + let installed = ReleaseVersion.version() + + guard UpgradePolicy.shouldUpgrade(target: target, installed: installed, force: force) else { + if release == nil { + print("Container is already on latest version \(target) (use -f to force update)") + } else { + print("Container is already on version \(target) (use -f to force update)") + } + return + } + if release == nil { + print("Updating to latest version \(target)") + } else { + print("Updating to release version \(target)") + } + + guard let package = targetRelease.installerPackage() else { + throw ContainerizationError(.notFound, message: "No suitable package found") + } + if !package.isSigned { + guard confirmUnsignedPackage() else { + print("Exiting without updating") + return + } + print("NOTE: re-run this command to upgrade to the signed package, when it becomes available") + } + + let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("container-upgrade-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let packageFile = try await download(package: package, to: temporaryDirectory) + try install(packageFile: packageFile) + + print("Updated successfully") + try printInstalledVersion() + } + + private func fetchRelease() async throws -> GitHubRelease { + let endpoint = GitHubRelease.endpoint(forVersion: release) + let failureMessage = + release.map { "Release '\($0)' not found" } ?? "Failed fetching latest release" + + do { + let (body, response) = try await URLSession.shared.data(from: endpoint) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw ContainerizationError(.notFound, message: failureMessage) + } + return try JSONDecoder().decode(GitHubRelease.self, from: body) + } catch let error as ContainerizationError { + throw error + } catch { + throw ContainerizationError(.internalError, message: "\(failureMessage): \(error)") + } + } + + private func upgradeHomebrew() throws { + guard release == nil else { + throw ContainerizationError( + .invalidArgument, + message: + "container was installed with Homebrew, which only upgrades to the latest formula version; re-run without --release, or install the release from https://github.com/apple/container/releases" + ) + } + // `brew reinstall` covers --force: `brew upgrade` is a no-op when + // the installed formula is already the latest version. + let subcommand = force ? "reinstall" : "upgrade" + print("Detected Homebrew installation, running `brew \(subcommand) container`...") + let brew = Process() + brew.executableURL = URL(fileURLWithPath: "/usr/bin/env") + brew.arguments = ["brew", subcommand, "container"] + try brew.run() + brew.waitUntilExit() + guard brew.terminationStatus == 0 else { + throw ContainerizationError(.internalError, message: "brew \(subcommand) failed") + } + try printInstalledVersion() + } + + private func confirmUnsignedPackage() -> Bool { + print("No signed package found. Upgrade using the unsigned package instead? (Y/n): ", terminator: "") + guard let answer = readLine() else { + return false + } + return answer.range(of: "^[yY]([eE][sS])?$", options: .regularExpression) != nil + } + + private func download(package: InstallerPackage, to directory: URL) async throws -> URL { + print("Downloading package from: \(package.url.absoluteString)...") + let (downloadedFile, response) = try await URLSession.shared.download(from: package.url) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw ContainerizationError(.internalError, message: "Failed to download package") + } + let packageFile = directory.appendingPathComponent(package.url.lastPathComponent) + try FileManager.default.moveItem(at: downloadedFile, to: packageFile) + + let attributes = try FileManager.default.attributesOfItem(atPath: packageFile.path) + let size = (attributes[.size] as? Int64) ?? 0 + guard size > 0 else { + throw ContainerizationError(.empty, message: "Downloaded package is empty") + } + return packageFile + } + + private func install(packageFile: URL) throws { + print("Installing package to /usr/local...") + let installer = Process() + installer.executableURL = URL(fileURLWithPath: "/usr/bin/sudo") + installer.arguments = ["installer", "-pkg", packageFile.path, "-target", "/"] + // Silence installer output like the script; stdin stays attached + // so sudo can prompt for the password on the terminal. + installer.standardOutput = FileHandle.nullDevice + installer.standardError = FileHandle.nullDevice + try installer.run() + installer.waitUntilExit() + guard installer.terminationStatus == 0 else { + throw ContainerizationError(.internalError, message: "Installer failed") + } + } + + private func printInstalledVersion() throws { + let verify = Process() + verify.executableURL = URL(fileURLWithPath: "/usr/bin/env") + verify.arguments = ["container", "--version"] + try verify.run() + verify.waitUntilExit() + guard verify.terminationStatus == 0 else { + throw ContainerizationError(.notFound, message: "'container' command not found") + } + } + } +} diff --git a/Tests/ContainerCommandsTests/UpgradeCommandTests.swift b/Tests/ContainerCommandsTests/UpgradeCommandTests.swift new file mode 100644 index 000000000..70f3820ce --- /dev/null +++ b/Tests/ContainerCommandsTests/UpgradeCommandTests.swift @@ -0,0 +1,182 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerCommands + +struct UpgradeCommandTests { + private func asset(_ name: String) -> GitHubReleaseAsset { + GitHubReleaseAsset( + name: name, + browserDownloadURL: URL(string: "https://example.com/\(name)")! + ) + } + + @Test + func endpointForLatestRelease() { + let url = GitHubRelease.endpoint(forVersion: nil) + #expect(url.absoluteString == "https://api.github.com/repos/apple/container/releases/latest") + } + + @Test + func endpointForPinnedRelease() { + let url = GitHubRelease.endpoint(forVersion: "0.6.0") + #expect(url.absoluteString == "https://api.github.com/repos/apple/container/releases/tags/0.6.0") + } + + @Test + func decodesReleaseJSON() throws { + let json = """ + { + "tag_name": "0.6.0", + "assets": [ + { + "name": "container-installer-signed.pkg", + "browser_download_url": "https://example.com/container-installer-signed.pkg" + } + ] + } + """ + let release = try JSONDecoder().decode(GitHubRelease.self, from: Data(json.utf8)) + #expect(release.tagName == "0.6.0") + #expect(release.assets.count == 1) + #expect(release.assets[0].name == "container-installer-signed.pkg") + #expect(release.assets[0].browserDownloadURL.absoluteString == "https://example.com/container-installer-signed.pkg") + } + + @Test + func prefersSignedPrimaryPackage() { + let release = GitHubRelease( + tagName: "0.6.0", + assets: [ + asset("container-installer-unsigned.pkg"), + asset("container-0.6.0-installer-signed.pkg"), + asset("container-installer-signed.pkg"), + ] + ) + let package = release.installerPackage() + #expect(package == InstallerPackage(url: URL(string: "https://example.com/container-installer-signed.pkg")!, isSigned: true)) + } + + @Test + func fallsBackToVersionedSignedPackage() { + let release = GitHubRelease( + tagName: "0.6.0", + assets: [ + asset("container-installer-unsigned.pkg"), + asset("container-0.6.0-installer-signed.pkg"), + ] + ) + let package = release.installerPackage() + #expect(package == InstallerPackage(url: URL(string: "https://example.com/container-0.6.0-installer-signed.pkg")!, isSigned: true)) + } + + @Test + func fallsBackToUnsignedPackages() { + let primary = GitHubRelease( + tagName: "0.6.0", + assets: [asset("container-installer-unsigned.pkg")] + ) + #expect( + primary.installerPackage() + == InstallerPackage(url: URL(string: "https://example.com/container-installer-unsigned.pkg")!, isSigned: false)) + + let fallback = GitHubRelease( + tagName: "0.6.0", + assets: [asset("container-0.6.0-installer-unsigned.pkg")] + ) + #expect( + fallback.installerPackage() + == InstallerPackage(url: URL(string: "https://example.com/container-0.6.0-installer-unsigned.pkg")!, isSigned: false)) + } + + @Test + func returnsNilWhenNoInstallerPackageExists() { + let release = GitHubRelease( + tagName: "0.6.0", + assets: [asset("checksums.txt"), asset("container-0.6.0.tar.gz")] + ) + #expect(release.installerPackage() == nil) + } + + @Test + func upgradePolicyDecisionTable() { + // Same version, no force: do not upgrade. + #expect(!UpgradePolicy.shouldUpgrade(target: "0.6.0", installed: "0.6.0", force: false)) + // Same version, force: upgrade. + #expect(UpgradePolicy.shouldUpgrade(target: "0.6.0", installed: "0.6.0", force: true)) + // Different version: upgrade (string comparison only, matching the script). + #expect(UpgradePolicy.shouldUpgrade(target: "0.7.0", installed: "0.6.0", force: false)) + // "Downgrade" is just a different string: upgrade. + #expect(UpgradePolicy.shouldUpgrade(target: "0.5.0", installed: "0.6.0", force: false)) + } + + @Test + func classifiesHomebrewInstallation() { + // Apple Silicon prefix. + #expect( + InstallationMethod.classify( + resolvedExecutablePath: "/opt/homebrew/Cellar/container/0.6.0/bin/container", + hasInstallerReceipt: false + ) == .homebrew) + // Intel prefix, where /usr/local is shared with the installer + // package: the Cellar path must win even when a receipt exists. + #expect( + InstallationMethod.classify( + resolvedExecutablePath: "/usr/local/Cellar/container/0.6.0/bin/container", + hasInstallerReceipt: true + ) == .homebrew) + } + + @Test + func classifiesInstallerPackageInstallation() { + #expect( + InstallationMethod.classify( + resolvedExecutablePath: "/usr/local/bin/container", + hasInstallerReceipt: true + ) == .installerPackage) + } + + @Test + func classifiesUnknownInstallation() { + #expect( + InstallationMethod.classify( + resolvedExecutablePath: "/Users/dev/container/.build/release/container", + hasInstallerReceipt: false + ) == .unknown) + } + + @Test + func parsesDefaults() throws { + let command = try Application.UpgradeCommand.parse([]) + #expect(command.release == nil) + #expect(command.force == false) + } + + @Test + func parsesReleaseAndForce() throws { + let long = try Application.UpgradeCommand.parse(["--release", "0.6.0", "--force"]) + #expect(long.release == "0.6.0") + #expect(long.force == true) + + let short = try Application.UpgradeCommand.parse(["-v", "0.6.0", "-f"]) + #expect(short.release == "0.6.0") + #expect(short.force == true) + } +} diff --git a/Tests/IntegrationTests/System/TestCLIUpgrade.swift b/Tests/IntegrationTests/System/TestCLIUpgrade.swift new file mode 100644 index 000000000..2094749ab --- /dev/null +++ b/Tests/IntegrationTests/System/TestCLIUpgrade.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// 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 upgrade`. Only the running-service guard is exercised: +/// the integration environment always has container services running, and +/// actually installing a package would modify the host. +@Suite +struct TestCLIUpgrade { + @Test func refusesToRunWhileServicesAreRunning() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["upgrade"]) + #expect(result.status == 1, "upgrade should refuse while services run, stdout: \(result.output)") + #expect( + result.error.contains("container system stop"), + "error should direct the user to stop services, stderr: \(result.error)") + } + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index c7d854806..4febe7473 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1578,3 +1578,40 @@ container system property list # output as JSON for scripting container system property list --format json ``` + +## Software Update + +### `container upgrade` + +Upgrades the installed `container` toolset. The command refuses to run while container services are running; stop them first with `container system stop`. + +The upgrade follows the method used to install `container`: + +* **Installer package** (release page): downloads the signed installer package for the target release (asking for confirmation before falling back to an unsigned package) and installs it with `sudo installer`, which prompts for an administrator password. +* **Homebrew**: runs `brew upgrade container` (`brew reinstall container` with `--force`). The `--release` option is not supported for Homebrew installations, since the formula only offers its latest version. +* Any other installation (for example, a build from source) is not upgraded; the command exits with an error describing how to upgrade manually. + +**Usage** + +```bash +container upgrade [--release ] [--force] [--debug] +``` + +**Options** + +* `-v, --release `: Upgrade to a specific release version (defaults to the latest release) +* `-f, --force`: Force the upgrade even if the target version is already installed + +**Examples** + +```bash +# Upgrade to the latest release +container system stop +container upgrade + +# Upgrade (or downgrade) to a specific release +container upgrade --release 0.6.0 + +# Reinstall the current version +container upgrade --force +```