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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:**
Expand Down
53 changes: 53 additions & 0 deletions cmd/incus-compose/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
65 changes: 53 additions & 12 deletions cmd/incus-compose/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"os"
"os/signal"
"slices"
"strconv"
"strings"
"syscall"
Expand All @@ -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{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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())
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions cmd/incus-compose/up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ package main

import (
"bytes"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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) {
Expand All @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion docs
12 changes: 6 additions & 6 deletions examples/oci-registry-cache/compose.incus.yaml
Original file line number Diff line number Diff line change
@@ -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}
Expand Down
8 changes: 4 additions & 4 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down