From 34d16bcba06462f3b94152d0705e4bf9592dadfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Jochum?= Date: Tue, 11 Aug 2026 01:11:11 +0200 Subject: [PATCH 1/3] docs: update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: René Jochum --- README.md | 4 ++-- docs | 2 +- examples/oci-registry-cache/compose.incus.yaml | 12 ++++++------ justfile | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 64fd1fd2..ef5d4611 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Recorded during the beta - the workflow is unchanged in current releases: ## Why incus-compose? -[Incus](https://linuxcontainers.org/incus/) provides powerful system containers and virtual machines with superior security and isolation, but lacks the declarative multi-container orchestration that Docker Compose offers. This tool bridges that gap: +[Incus](https://linuxcontainers.org/incus/) provides system containers and virtual machines with strong isolation, but lacks the declarative multi-container orchestration that Docker Compose offers. This tool bridges that gap: - Use existing `docker-compose.yml` files with Incus containers - Leverage Incus's native OCI registry support for image pulling @@ -35,7 +35,7 @@ Status: **Stable**. - Familiar commands: `up`, `down`, `start`, `stop`, `restart`, `list` (and `ps`), `logs`, `exec`, `config`, plus `build`, `healthd`, `incus` (pass-through), and `self-update` - Compose project parsing via compose-go: `.env` interpolation, profiles, `depends_on`, secrets, and configs - Automatic `compose.incus.yaml` override file - keep the upstream compose file untouched and put Incus tuning next to it [doc](https://docs.incus-compose.org/compose-compatibility#incus-override-file) -- Windows and macOS clients: No Docker Desktop, no WSL, no local Linux VM. `incus-compose` and the `incus` client are portable Go binaries — from a Windows or macOS desktop you drive a remote Incus host over HTTPS and manage OCI containers, LXC system containers, and VMs directly. See [Installing on Windows](https://docs.incus-compose.org/getting-started/windows). +- Windows and macOS clients: No Docker Desktop, no WSL, no local Linux VM. `incus-compose` and the `incus` client are portable Go binaries: from a Windows or macOS desktop you drive a remote Incus host over HTTPS and manage OCI containers, LXC system containers, and VMs directly. See [Installing on Windows](https://docs.incus-compose.org/getting-started/windows). - Configuration via `INCUS_COMPOSE_*` environment variables for every flag, with a configurable parallel worker count [doc](https://docs.incus-compose.org/environment-variables) **Images:** diff --git a/docs b/docs index efa7d10c..3b8da6d1 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit efa7d10cadeb222e7686ddc84f7da2b86bbf13d7 +Subproject commit 3b8da6d150dadffc1ddb2221e7f31123656b0579 diff --git a/examples/oci-registry-cache/compose.incus.yaml b/examples/oci-registry-cache/compose.incus.yaml index ef3f6537..67e48193 100644 --- a/examples/oci-registry-cache/compose.incus.yaml +++ b/examples/oci-registry-cache/compose.incus.yaml @@ -1,36 +1,36 @@ services: docker-registry: healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:5000"] + test: ["CMD", "wget", "-q", "http://127.0.0.1:5000"] interval: 5s timeout: 5s retries: 6 start_period: 30s - start_interval: 1s + start_interval: 5s networks: default: ipv4_address: ${DOCKERIO_IPV4}/${IPV4_NETMASK} ghcr-registry: healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:5000"] + test: ["CMD", "wget", "-q", "http://127.0.0.1:5000"] interval: 5s timeout: 5s retries: 6 start_period: 30s - start_interval: 1s + start_interval: 5s networks: default: ipv4_address: ${GHCRIO_IPV4}/${IPV4_NETMASK} gitlab-registry: healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:5000"] + test: ["CMD", "wget", "-q", "http://127.0.0.1:5000"] interval: 5s timeout: 5s retries: 6 start_period: 30s - start_interval: 1s + start_interval: 5s networks: default: ipv4_address: ${GITLAB_IPV4}/${IPV4_NETMASK} diff --git a/justfile b/justfile index 193793f1..a9afc695 100644 --- a/justfile +++ b/justfile @@ -149,11 +149,11 @@ dev-install container_name="local:ict" listen='127.0.0.1:1443' project='default' # Run commands in the nested incus. incus *args: - @echo "Using remote '${INCUS_REMOTE-"local"}':" - incus {{ args }} + @echo "Using remote '${INCUS_REMOTE-"local"}': incus $*" >&2 + @incus "$@" # Build ic-healthd binary -build-healthd: lint +build-healthd: CGO_ENABLED=0 go build -tags=netgo -ldflags="-w -s -X github.com/lxc/incus-compose/cmd/ic-healthd/version.Version=`git describe --tags --always --long --dirty="-dirty"`" -trimpath -o bin/ic-healthd ./cmd/ic-healthd # Build ic-healthd container image @@ -199,7 +199,7 @@ update-healthd *args="--trace": build-healthd-image just run healthd up {{ args }} # Build a dev binary -build: lint update-healthd +build: update-healthd #!/usr/bin/env bash set -euo pipefail From 0c45b0fce536352c6a4c444c65123146b3bc4419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Jochum?= Date: Wed, 12 Aug 2026 10:09:36 +0200 Subject: [PATCH 2/3] feat(cmd): recreate built services on up --build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: René Jochum --- CHANGELOG.md | 6 ++++ cmd/incus-compose/build_test.go | 53 +++++++++++++++++++++++++++++ cmd/incus-compose/up.go | 60 +++++++++++++++++++++++++++------ cmd/incus-compose/up_test.go | 51 ++++++++++++++++++++++++++++ docs | 2 +- 5 files changed, 161 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90423fc4..2df0b9de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ for correct semver ordering. Headings below preserve each release's announced fo ### Changed +- `up --build` now recreates the instances of the services whose image it + rebuilt, so the new image is what they run. Previously the image was rebuilt + but the existing instances kept the old one until `--recreate` was passed as + well. A service that only consumes an image another service builds is + recreated too; everything else is left alone. (by @jochumdev) + - **library**: the Incus API is reached through `iclient` instead of `github.com/lxc/incus/v7/client`, which cannot be used from several goroutines at once. `Client.Connection`, `Client.GlobalConnection` and diff --git a/cmd/incus-compose/build_test.go b/cmd/incus-compose/build_test.go index 18472032..fd9018f2 100644 --- a/cmd/incus-compose/build_test.go +++ b/cmd/incus-compose/build_test.go @@ -156,6 +156,59 @@ ENV PATH=/opt/bin:/usr/bin require.Equal(t, "xterm", inst.Config["environment.TERM"]) } +// TestE2EUpBuildRecreates pins that --build recreates the instances whose image +// it rebuilt, and leaves every other service running as it was. +func TestE2EUpBuildRecreates(t *testing.T) { + skipE2E(t) + skipLocal(t) + skipIfNoBuilder(t) + t.Parallel() + + ctx := t.Context() + pn := t.Name() + dir := writeTempFiles(t, map[string]string{ + "Dockerfile": `FROM docker.io/alpine:latest +RUN echo "built by incus-compose" +`, + "compose.yaml": `services: + app: + build: + no_cache: true + context: . + plain: + image: images:alpine/edge +`}) + compose := filepath.Join(dir, "compose.yaml") + + t.Cleanup(func() { + _, _ = runCommand(context.Background(), t, pn, "-f", compose, "down", "--project") + }) + + _, err := runCommand(ctx, t, pn, "-f", compose, "up", "--detach", "--no-start", "--no-healthd") + require.NoError(t, err) + + c := projectClient(ctx, t, pn) + conn, err := c.Connection() + require.NoError(t, err) + + uuid := func(name string) string { + inst, _, err := conn.GetInstance(ctx, name, nil) + require.NoError(t, err) + + return inst.Config["volatile.uuid"] + } + + app, plain := uuid("app-1"), uuid("plain-1") + require.NotEmpty(t, app) + require.NotEmpty(t, plain) + + _, err = runCommand(ctx, t, pn, "-f", compose, "up", "--detach", "--no-start", "--no-healthd", "--build") + require.NoError(t, err) + + require.NotEqual(t, app, uuid("app-1"), "--build must recreate the service it rebuilt") + require.Equal(t, plain, uuid("plain-1"), "--build must leave a service it did not rebuild alone") +} + func TestBuildCommandWithNoBuildServices(t *testing.T) { skipLocal(t) t.Parallel() diff --git a/cmd/incus-compose/up.go b/cmd/incus-compose/up.go index 8cb3a8fa..6834de4f 100644 --- a/cmd/incus-compose/up.go +++ b/cmd/incus-compose/up.go @@ -4,6 +4,7 @@ import ( "context" "os" "os/signal" + "slices" "strconv" "strings" "syscall" @@ -207,18 +208,34 @@ func newUpCommand() *cli.Command { runOptions = append(runOptions, client.OptionExternalHealthd()) } - if cmd.Bool("recreate") { + scale := parseScale(cmd.StringSlice("scale")) + args := filterResourcesArgs{ + OnlyServices: cmd.Args().Slice(), + WithDependencies: !cmd.Bool("no-deps"), + } + + // A rebuilt image only reaches an instance created from it again. + recreate := cmd.Bool("recreate") + downServices, downNoDeps := cmd.Args().Slice(), cmd.Bool("no-deps") + if !recreate && buildMode == client.BuildForce { + downServices = builtServices(p, args) + downNoDeps = true + recreate = len(downServices) > 0 + c.LogDebug("Recreating built services", "services", downServices) + } + + if recreate { err = down(ctx, p, rc, downArgs{ Project: cmd.Bool("project"), Volumes: false, Images: false, Timeout: cmd.Duration("timeout"), - NoDeps: cmd.Bool("no-deps"), - NoNetworks: len(cmd.Args().Slice()) != 0, - Services: cmd.Args().Slice(), + NoDeps: downNoDeps, + NoNetworks: len(downServices) != 0, + Services: downServices, Workers: cmd.Root().Int("workers"), Debug: cmd.Root().Bool("debug"), - Scale: parseScale(cmd.StringSlice("scale")), + Scale: scale, Writer: cmd.Root().Writer, Reverse: true, NoHealthd: !usesHealthd, @@ -254,7 +271,6 @@ func newUpCommand() *cli.Command { defer progress.Stop(c) } - scale := parseScale(cmd.StringSlice("scale")) resources, err := p.Resources(c, project.ResourcesScale(scale)) if err != nil { c.LogError("Getting project resources in reCreate", "error", err) @@ -267,10 +283,6 @@ func newUpCommand() *cli.Command { return errLogged.Wrap(err) } - args := filterResourcesArgs{ - OnlyServices: cmd.Args().Slice(), - WithDependencies: !cmd.Bool("no-deps"), - } myResources := filterResources(p, resources, args) stack := client.NewStack(c, client.StackWorkers(cmd.Root().Int("workers")), client.StackFailFast()) @@ -349,6 +361,34 @@ func newUpCommand() *cli.Command { } } +// builtServices returns the in-scope services whose image comes from a build +// config, sorted. A service that only consumes such an image is in there too. +func builtServices(p *project.Project, args filterResourcesArgs) []string { + // filterResources selects by service name; the resources are just payload. + scope := map[string][]client.Resource{} + for name := range p.Services { + scope[name] = nil + } + + images := map[string]bool{} + for _, s := range p.Services { + if s.Build != nil && s.Image != "" { + images[s.Image] = true + } + } + + services := []string{} + for name := range filterResources(p, scope, args) { + s := p.Services[name] + if s.Build != nil || images[s.Image] { + services = append(services, name) + } + } + slices.Sort(services) + + return services +} + // parseScale parses --scale flags of the form "service=num". func parseScale(values []string) map[string]int { scaleOverrides := make(map[string]int) diff --git a/cmd/incus-compose/up_test.go b/cmd/incus-compose/up_test.go index e5602ab0..302edf3e 100644 --- a/cmd/incus-compose/up_test.go +++ b/cmd/incus-compose/up_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -9,6 +10,7 @@ import ( "github.com/urfave/cli/v3" "github.com/lxc/incus-compose/cmd/incus-compose/version" + "github.com/lxc/incus-compose/project" ) func TestVersionCommand(t *testing.T) { @@ -35,6 +37,55 @@ func TestResolveHealthdImage(t *testing.T) { assert.Equal(t, "custom:latest", resolveHealthdImage("custom:latest")) } +func TestBuiltServices(t *testing.T) { + t.Parallel() + + dir := writeTempFiles(t, map[string]string{ + "Dockerfile": "FROM docker.io/alpine:latest\n", + "compose.yaml": `name: built +services: + app: + image: localhost/app:latest + build: + context: . + consumer: + image: localhost/app:latest + plain: + image: docker.io/alpine:edge + dependent: + image: docker.io/alpine:edge + depends_on: + - app +`}) + + p, err := project.New().Load(t.Context(), project.LoadFiles([]string{filepath.Join(dir, "compose.yaml")})) + require.NoError(t, err) + + tests := []struct { + name string + args filterResourcesArgs + want []string + }{ + {name: "whole project", args: filterResourcesArgs{}, want: []string{"app", "consumer"}}, + {name: "the builder", args: filterResourcesArgs{OnlyServices: []string{"app"}}, want: []string{"app"}}, + {name: "a consumer of the built image", args: filterResourcesArgs{OnlyServices: []string{"consumer"}}, want: []string{"consumer"}}, + {name: "nothing built in scope", args: filterResourcesArgs{OnlyServices: []string{"plain"}}, want: []string{}}, + { + name: "a dependency is in scope", + args: filterResourcesArgs{OnlyServices: []string{"dependent"}, WithDependencies: true}, + want: []string{"app"}, + }, + {name: "no-deps drops it again", args: filterResourcesArgs{OnlyServices: []string{"dependent"}}, want: []string{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, builtServices(p, tt.args)) + }) + } +} + func TestParseScale(t *testing.T) { t.Parallel() diff --git a/docs b/docs index 3b8da6d1..d1037586 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 3b8da6d150dadffc1ddb2221e7f31123656b0579 +Subproject commit d1037586f3811ec869411b779800d14593525879 From afd3630616a14cd360222e0358683eb73c128b8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Jochum?= Date: Wed, 12 Aug 2026 10:15:50 +0200 Subject: [PATCH 3/3] fix(cmd): up --no-start implies --detach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: René Jochum --- CHANGELOG.md | 4 ++++ cmd/incus-compose/up.go | 5 +++-- docs | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df0b9de..8d218aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ for correct semver ordering. Headings below preserve each release's announced fo ### Changed +- `up --no-start` now returns once the containers are created, as `--detach` + does. It used to go on to follow logs from instances it never started, and the + interrupt that ended that stream tore them down again. (by @jochumdev) + - `up --build` now recreates the instances of the services whose image it rebuilt, so the new image is what they run. Previously the image was rebuilt but the existing instances kept the old one until `--recreate` was passed as diff --git a/cmd/incus-compose/up.go b/cmd/incus-compose/up.go index 6834de4f..3b70f960 100644 --- a/cmd/incus-compose/up.go +++ b/cmd/incus-compose/up.go @@ -30,7 +30,7 @@ func newUpCommand() *cli.Command { }, &cli.BoolFlag{ Name: "no-start", - Usage: "Don't start containers after creating", + Usage: "Don't start containers after creating (implies --detach)", Sources: cli.EnvVars("INCUS_COMPOSE_UP_NO_START"), }, &cli.DurationFlag{ @@ -324,7 +324,8 @@ func newUpCommand() *cli.Command { } } - if cmd.Bool("detach") { + // Nothing was started, so there is nothing to stream logs from. + if cmd.Bool("detach") || cmd.Bool("no-start") { _ = c.Done() return nil } diff --git a/docs b/docs index d1037586..d4e9bbdc 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit d1037586f3811ec869411b779800d14593525879 +Subproject commit d4e9bbdcdc691879dcbff3b0e48d0939edfbf5da