diff --git a/CHANGELOG.md b/CHANGELOG.md index 90423fc..8d218aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ 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 + 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/README.md b/README.md index 64fd1fd..ef5d461 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/cmd/incus-compose/build_test.go b/cmd/incus-compose/build_test.go index 1847203..fd9018f 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 8cb3a8f..3b70f96 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" @@ -29,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{ @@ -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()) @@ -312,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 } @@ -349,6 +362,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 e5602ab..302edf3 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 3b8da6d..d4e9bbd 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 3b8da6d150dadffc1ddb2221e7f31123656b0579 +Subproject commit d4e9bbdcdc691879dcbff3b0e48d0939edfbf5da diff --git a/examples/oci-registry-cache/compose.incus.yaml b/examples/oci-registry-cache/compose.incus.yaml index ef3f653..67e4819 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 193793f..a9afc69 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