Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Sources/ContainerCommands/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,13 +225,15 @@ public struct Application: AsyncLoggableCommand {
return [
BuilderCommand.self,
SystemCommand.self,
UpgradeCommand.self,
]
}

return [
BuilderCommand.self,
NetworkCommand.self,
SystemCommand.self,
UpgradeCommand.self,
]
}

Expand Down
89 changes: 89 additions & 0 deletions Sources/ContainerCommands/Upgrade/GitHubRelease.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
71 changes: 71 additions & 0 deletions Sources/ContainerCommands/Upgrade/InstallationMethod.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
204 changes: 204 additions & 0 deletions Sources/ContainerCommands/Upgrade/UpgradeCommand.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
}
Loading