Skip to content
Merged
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: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:

# Default tests use captured CLI fixtures and tolerate a missing binary.
# The opt-in live smoke test stays ignored here; it needs a running
# container service with virtualization. See docs/container-1.4.1.md.
# container service with virtualization. See docs/container-1.5.0.md.
- name: cargo test
run: cargo test --locked
working-directory: src-tauri
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ app does, you could have typed at the prompt yourself.
|---|---|
| **Mac** | Apple silicon. `container` does not run on Intel. |
| **macOS** | 26 or newer. Apple does not support `container` below that. |
| **`container` CLI** | 1.4.1 recommended and tested. |
| **`container` CLI** | 1.5.0 recommended and tested. 1.4.1 is also supported. |
| **To build** | Node.js 20+ and Rust via [rustup](https://rustup.rs). |

Get the CLI from [apple/container releases](https://github.com/apple/container/releases),
Expand Down Expand Up @@ -229,7 +229,7 @@ A banner across the top tells you whether the container system is running and of
start or stop it. If the status check fails, it shows the reason and offers a retry.

Compatibility notes and the live test procedure are in
[docs/container-1.4.1.md](docs/container-1.4.1.md).
[docs/container-1.5.0.md](docs/container-1.5.0.md).

## Development

Expand Down
51 changes: 38 additions & 13 deletions docs/container-1.4.1.md → docs/container-1.5.0.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
# Container 1.4.1 compatibility
# Container 1.5.0 compatibility

The desktop app invokes the installed CLI; it does not embed Apple's Swift packages.
Use `container --version` to confirm the CLI version. Version 1.4.1 is the tested and
recommended version. Legacy JSON shapes remain covered where supported, but older CLI
releases have not been rerun through the live suite for this update.
Use `container --version` to confirm the CLI version. Version 1.5.0 is the tested and
recommended version; 1.4.1 remains supported. Legacy JSON shapes remain covered where
supported, but releases before 1.4.1 have not been rerun through the live suite.

## Changes
## 1.5.0

No app code changes were needed. The JSON the app reads from `system status`, `ls`,
`stats`, `image ls`, `machine ls`, `volume ls`, `network ls`, and `builder status` has
the same structure as 1.4.1. Unknown commands still exit nonzero (now 64 with a longer
message), so the error path is unchanged.

Apple's [1.5.0 release notes](https://github.com/apple/container/releases/tag/1.5.0)
list one breaking CLI change, the removal of `container k8s start`. The app does not
use the Kubernetes plugin. The other changes (commands running with an empty image
entrypoint, egress after localhost DNS changes, a kubeconfig security fix) need no app
changes.

The captured `image ls` output lists every platform variant of the multi-arch Alpine
index, including `unknown/unknown` attestation entries. The app still shows the arm64
variant's size.

## 1.4.1

- Recognize tmpfs and named-volume mount types before checking host bind directories.
Both legacy blank tmpfs sources and the current `"tmpfs"` source work.
Expand Down Expand Up @@ -42,22 +59,23 @@ cargo test --locked
cargo build --locked
```

Fixtures under `src/test/fixtures/container-1.4.1/` were captured from the installed
1.4.1 CLI on Apple silicon. Personal paths and disposable resource names are replaced
Fixtures under `src/test/fixtures/container-1.5.0/` and
`src/test/fixtures/container-1.4.1/` were captured from the installed CLI of each
version on Apple silicon. The frontend and Rust fixture tests run against both. Personal paths and disposable resource names are replaced
with test values. They cover system status, containers, stats, images, machines,
volumes, networks, and an existing stopped builder. Container and machine fixtures
come from disposable Alpine resources. The fixture tests run without the CLI in CI.

## Live smoke test

Use an Apple silicon Mac with container 1.4.1, virtualization support, network access
Use an Apple silicon Mac with container 1.5.0 or 1.4.1, virtualization support, network access
for image pulls, and enough memory for the test container, machine, and builder.
After building the frontend:

```sh
container system start
cd src-tauri
cargo test --locked --test cli_compatibility live_1_4_1_smoke -- --ignored --nocapture
cargo test --locked --test cli_compatibility live_smoke -- --ignored --nocapture
```

The test exercises the app's Rust command functions for pull, container lifecycle,
Expand All @@ -77,17 +95,24 @@ and the settings preview and recreation flow using a disposable container.

## Upstream machine initialization limitation

In 1.4.1, the first noninteractive `machine run` immediately after creating a machine
can fail with `Operation not supported by device` or `Inappropriate ioctl for device`.
In 1.4.1 and 1.5.0, the first noninteractive `machine run` immediately after creating a
machine can fail with `Operation not supported by device` or `Inappropriate ioctl for device`.
The CLI's [boot helper](https://github.com/apple/container/blob/1.4.1/Sources/ContainerCommands/Machine/MachineHelpers.swift)
requests a terminal when its snapshot still reports first-time user setup as incomplete.
Stopping the newly created machine before its first shell command lets the next boot
load the persisted setup state. This workaround passed the live test; the smoke test
includes that stop explicitly. The app surfaces CLI errors and does not automatically
load the persisted setup state. The problem still occurs in 1.5.0. This workaround
passed the live test; the smoke test includes that stop explicitly. The app surfaces CLI errors and does not automatically
retry shell commands, which could execute user work twice.

## Validation record

Validated September 30, 2026 on Apple silicon, macOS 27.0, container client/server 1.5.0:
303 frontend tests, 117 default Rust tests, formatting, Clippy with warnings denied,
frontend/native builds, and the opt-in live smoke test passed. The live test includes
the machine initialization workaround above.

Earlier record for 1.4.1:

Validated September 17, 2026 on Apple silicon, macOS 27.0, container client/server 1.4.1:
295 frontend tests, 117 default Rust tests, formatting, Clippy with warnings denied,
frontend/native builds, and the opt-in live smoke test passed. The live test includes
Expand Down
21 changes: 11 additions & 10 deletions src-tauri/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,17 @@ mod tests {
use serde_json::json;

#[test]
fn reads_captured_1_4_1_builder_status() {
let value = serde_json::from_str(include_str!(
"../../src/test/fixtures/container-1.4.1/builder.json"
))
.unwrap();
let state = parse_state(&value);
assert!(state.exists);
assert!(!state.running);
assert_eq!(state.cpus, Some(2));
assert_eq!(state.memory_mb, Some(2048));
fn reads_captured_builder_status() {
for payload in [
include_str!("../../src/test/fixtures/container-1.4.1/builder.json"),
include_str!("../../src/test/fixtures/container-1.5.0/builder.json"),
] {
let state = parse_state(&serde_json::from_str(payload).unwrap());
assert!(state.exists);
assert!(!state.running);
assert_eq!(state.cpus, Some(2));
assert_eq!(state.memory_mb, Some(2048));
}
}

fn builder(state: &str) -> Value {
Expand Down
20 changes: 15 additions & 5 deletions src-tauri/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,21 @@ mod tests {

#[test]
fn expanded_running_status_is_supported() {
let payload = include_str!("../../src/test/fixtures/container-1.4.1/system-running.json");
let result = parse_output(&["system", "status"], &output(0, payload, "")).unwrap();
assert_eq!(result["status"], "running");
assert_eq!(result["client"]["version"], "1.4.1");
assert_eq!(result["server"]["version"], "1.4.1");
for (version, payload) in [
(
"1.4.1",
include_str!("../../src/test/fixtures/container-1.4.1/system-running.json"),
),
(
"1.5.0",
include_str!("../../src/test/fixtures/container-1.5.0/system-running.json"),
),
] {
let result = parse_output(&["system", "status"], &output(0, payload, "")).unwrap();
assert_eq!(result["status"], "running");
assert_eq!(result["client"]["version"], version);
assert_eq!(result["server"]["version"], version);
}
}

#[test]
Expand Down
75 changes: 48 additions & 27 deletions src-tauri/tests/cli_compatibility.rs
Original file line number Diff line number Diff line change
@@ -1,38 +1,59 @@
use applecontainerdesktop_lib::{builder, cli::run_cmd, containers::*, recreate};
use serde_json::{json, Value};

/// CLI versions with captured fixtures and a passing live smoke test.
const SUPPORTED_VERSIONS: [&str; 2] = ["1.4.1", "1.5.0"];

#[test]
fn recreation_preserves_captured_1_4_1_configuration() {
let raw: Value = serde_json::from_str(include_str!(
"../../src/test/fixtures/container-1.4.1/containers.json"
))
.unwrap();
let plan = recreate::build_run_args(recreate::config_of(&raw).unwrap(), &json!({"cpus": "2"}));
for pair in [
["--cpus", "2"],
["--memory", "536870912"],
["--tmpfs", "/scratch"],
["-v", "/tmp/acd-compat/bind:/bind:ro"],
["-v", "compat-test:/data"],
["-p", "127.0.0.1:18741:8080"],
["-e", "COMPAT_TEST=preserved"],
["-l", "compat.version=1.4.1"],
["--network", "compat-test,mtu=1280"],
["--entrypoint", "/bin/sh"],
["-w", "/tmp"],
fn recreation_preserves_captured_configuration() {
for (version, payload) in [
(
"1.4.1",
include_str!("../../src/test/fixtures/container-1.4.1/containers.json"),
),
(
"1.5.0",
include_str!("../../src/test/fixtures/container-1.5.0/containers.json"),
),
] {
assert!(plan.args.windows(2).any(|w| w == pair), "missing {pair:?}");
let raw: Value = serde_json::from_str(payload).unwrap();
let plan =
recreate::build_run_args(recreate::config_of(&raw).unwrap(), &json!({"cpus": "2"}));
let label = format!("compat.version={version}");
for pair in [
["--cpus", "2"],
["--memory", "536870912"],
["--tmpfs", "/scratch"],
["-v", "/tmp/acd-compat/bind:/bind:ro"],
["-v", "compat-test:/data"],
["-p", "127.0.0.1:18741:8080"],
["-e", "COMPAT_TEST=preserved"],
["-l", &label],
["--network", "compat-test,mtu=1280"],
["--entrypoint", "/bin/sh"],
["-w", "/tmp"],
] {
assert!(
plan.args.windows(2).any(|w| w == pair),
"{version}: missing {pair:?}"
);
}
assert!(plan.unsupported.is_empty(), "{version}");
}
assert!(plan.unsupported.is_empty());
}

/// Opt-in: needs a running container 1.4.1 service and Apple silicon. Creates
/// only uniquely named disposable resources; never prunes existing resources.
/// Opt-in: needs a running container service at a supported version and Apple
/// silicon. Creates only uniquely named disposable resources; never prunes
/// existing resources.
#[test]
#[ignore = "requires a live container service; see docs/container-1.4.1.md"]
fn live_1_4_1_smoke() {
#[ignore = "requires a live container service; see docs/container-1.5.0.md"]
fn live_smoke() {
let version = run_cmd(&["--version"]).unwrap();
assert!(version.as_str().unwrap().contains("version 1.4.1 "));
let version = SUPPORTED_VERSIONS
.into_iter()
.find(|v| version.as_str().unwrap().contains(&format!("version {v} ")))
.unwrap_or_else(|| panic!("unsupported CLI: {version}"));
eprintln!("Testing container {version}");
assert_eq!(check_system_status().unwrap()["status"], "running");
let name = format!(
"acd-smoke-{}",
Expand Down Expand Up @@ -84,7 +105,7 @@ fn live_1_4_1_smoke() {
"-e",
"COMPAT_TEST=preserved",
"-l",
"compat.version=1.4.1",
&format!("compat.version={version}"),
"-w",
"/tmp",
"--entrypoint",
Expand Down Expand Up @@ -199,7 +220,7 @@ fn live_1_4_1_smoke() {
.unwrap();
assert_eq!(vm["cpus"], 2);
assert_eq!(vm["memory"], 2147483648_u64);
// 1.4.1 can retain an uninitialized snapshot after first-time user setup.
// 1.4.1 and 1.5.0 can retain an uninitialized snapshot after first-time user setup.
// A fresh boot loads the persisted setup state. Without this, machine run
// may try to initialize with a TTY despite a noninteractive command.
// Keep this upstream limitation explicit; never replay user commands.
Expand Down
29 changes: 16 additions & 13 deletions src/test/compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import { checkSystemStatus, listContainers, listImages, listMachines, listNetworks, listVolumes } from "../api";
import containers from "./fixtures/container-1.4.1/containers.json";
import images from "./fixtures/container-1.4.1/images.json";
import machines from "./fixtures/container-1.4.1/machines.json";
import networks from "./fixtures/container-1.4.1/networks.json";
import volumes from "./fixtures/container-1.4.1/volumes.json";
import status from "./fixtures/container-1.4.1/system-running.json";

const mock = vi.mocked(invoke);

describe("captured container 1.4.1 JSON", () => {
// Each captured CLI version lives in fixtures/container-<version>/.
const fixtures = import.meta.glob<unknown>("./fixtures/container-*/*.json", { eager: true, import: "default" });
const versions = [...new Set(Object.keys(fixtures).map(path => path.split("/")[2].slice("container-".length)))];
const fixture = (version: string, name: string) => fixtures[`./fixtures/container-${version}/${name}.json`];

it("covers every supported CLI version", () => {
expect(versions).toEqual(expect.arrayContaining(["1.4.1", "1.5.0"]));
});

describe.each(versions)("captured container %s JSON", version => {
beforeEach(() => vi.clearAllMocks());

it("reads container state and published ports", async () => {
mock.mockResolvedValue(containers);
mock.mockResolvedValue(fixture(version, "containers"));
expect((await listContainers())[0]).toMatchObject({
id: "compat-test", status: "running", image: "docker.io/library/alpine:latest", ports: "18741→8080",
});
});

it("reads native image size and reference", async () => {
mock.mockResolvedValue(images);
mock.mockResolvedValue(fixture(version, "images"));
expect((await listImages())[0]).toMatchObject({ reference: "docker.io/library/alpine:latest", size: "4 MB" });
});

it("reads flat machine resource fields", async () => {
mock.mockResolvedValue(machines);
mock.mockResolvedValue(fixture(version, "machines"));
expect((await listMachines())[0]).toMatchObject({
name: "compat-test-vm", cpus: 2, memoryMB: 2048, isDefault: true, status: "running",
});
});

it("reads volume capacity and source", async () => {
mock.mockResolvedValue(volumes);
mock.mockResolvedValue(fixture(version, "volumes"));
expect((await listVolumes())[0]).toMatchObject({ name: "compat-test", provisioned: "1.1 GB", format: "ext4" });
});

it("reads network configuration and addressing", async () => {
mock.mockResolvedValue(networks);
mock.mockResolvedValue(fixture(version, "networks"));
expect((await listNetworks()).find(n => n.name === "default")).toMatchObject({
mode: "nat", subnet: "192.168.64.0/24", gateway: "192.168.64.1",
});
});

it("accepts the expanded system status payload", async () => {
mock.mockResolvedValue(status);
mock.mockResolvedValue(fixture(version, "system-running"));
expect((await checkSystemStatus()).status).toBe("running");
});
});
Loading
Loading