diff --git a/incus/.gitignore b/incus/.gitignore new file mode 100644 index 0000000..90a05eb --- /dev/null +++ b/incus/.gitignore @@ -0,0 +1,3 @@ +incusImporter +incusExporter +*.ptar diff --git a/incus/LICENSE b/incus/LICENSE new file mode 100644 index 0000000..0b3f678 --- /dev/null +++ b/incus/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Antoine Dheygers + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/incus/Makefile b/incus/Makefile new file mode 100644 index 0000000..227fabc --- /dev/null +++ b/incus/Makefile @@ -0,0 +1,31 @@ +GO=go +PLAKAR=plakar +VERSION?=$(shell git describe --tags --always) +GOLANGCI_LINT_VERSION=v2.12.2 + +all: build + +build: + ${GO} build -v -o incusImporter ./plugin/importer + ${GO} build -v -o incusExporter ./plugin/exporter + +pkg: build + ${PLAKAR} pkg create ./manifest.yaml ${VERSION} + +install: pkg + -${PLAKAR} pkg rm incus + ${PLAKAR} pkg add ./incus_${VERSION}_*.ptar + +test: + ${GO} test ./... + +# Needs a reachable local Incus daemon (skips otherwise) and rights on +# its socket (incus-admin group). +test-integration: + ${GO} test -tags integration -count=1 -timeout 20m ./... + +lint: + ${GO} run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${GOLANGCI_LINT_VERSION} run --build-tags integration ./... + +clean: + rm -f incusImporter incusExporter *.ptar diff --git a/incus/README.md b/incus/README.md new file mode 100644 index 0000000..55744e3 --- /dev/null +++ b/incus/README.md @@ -0,0 +1,255 @@ +# Plakar Incus Integration + +Backup and restore Incus containers as browsable file-level snapshots. + +**Containers only.** Virtual machines are refused at backup time with a +clear error: a VM export is a single monolithic disk image, so per-file +browsing and deduplication — the point of this integration — do not +apply. Use `incus export` for VMs. + +## Requirements + +- **An Incus daemon to talk to.** By default the plugin connects to the + local daemon over its unix socket (`/var/lib/incus/unix.socket`); + alternatively it can reach a remote daemon over HTTPS with a client + certificate — see [Remote servers](#remote-servers-https--client-certificate). +- **Socket access** (local mode). The user running `plakar` must be able + to read and write the socket — in practice, membership in the + `incus-admin` group (or root): + + ```bash + sudo usermod -aG incus-admin "$USER" + # log out/in (or `newgrp incus-admin`) for the group to take effect + ``` + + The importer creates and deletes server-side backups, which is why + `incus-admin` is needed rather than the more limited `incus` group. +- A working [plakar](https://plakar.io) installation with a repository + (`plakar create` / `plakar server`, see plakar's own docs). + +## Installation + +```bash +git clone https://github.com/PlakarKorp/integration-incus +cd integration-incus +make install # builds both connectors, packages them, runs `plakar pkg add` +``` + +`make install` versions the package from `git describe`; use +`make pkg VERSION=v1.2.3` to override. To remove: `plakar pkg rm incus`. + +## Usage end to end + +Back up a container named `web-1`: + +```bash +plakar backup incus://web-1 +``` + +Every file of the container's root filesystem becomes an individually +browsable, deduplicated entry in the repository: + +```bash +plakar ls # list snapshots; note the snapshot id +plakar ls :/etc # browse the captured rootfs +plakar cat :/etc/os-release # read a single file straight from the backup +``` + +Restore to a **new** instance name (the exporter refuses to overwrite an +existing instance — restore to another name or delete it first): + +```bash +plakar restore -to incus://web-1-restored +incus start web-1-restored +``` + +The instance is recreated natively by Incus (config, profiles and +device entries included), on the storage pool recorded in the backup +unless overridden with `-o pool=...`. + +### Options + +Passed with `-o key=value` on the `plakar backup` / `plakar restore` +command line. + +Both connectors accept: + +| Option | Default | Description | +|---|---|---| +| `socket` | `/var/lib/incus/unix.socket` | Path to the Incus unix socket (mutually exclusive with `url`) | +| `url` | — | Remote Incus server, `https://host:8443`; requires `tls_client_cert` + `tls_client_key` (mutually exclusive with `socket`) | +| `tls_client_cert` | — | Path to the PEM client certificate trusted by the remote server (remote only) | +| `tls_client_key` | — | Path to the PEM client key matching `tls_client_cert` (remote only) | +| `tls_server_cert` | system CA | Path to the remote server's PEM certificate, to pin a self-signed server (remote only) | +| `tls_ca` | — | Path to the PEM CA certificate, for servers running in PKI mode (remote only) | +| `project` | `default` | Incus project to operate in; required for any instance living outside the `default` project | + +When `project` is set, the snapshot origin is qualified as +`project/instance` so same-named instances from different projects stay +distinguishable in listings; without it the origin is the bare instance +name. + +The importer also accepts (Go durations, e.g. `45m`, `12h`): + +| Option | Default | Description | +|---|---|---| +| `backup_ttl` | `6h` | Expiry of the temporary server-side backup. A safety net: if the plugin dies without cleaning up, Incus expires the backup on its own. Raise it if a backup of a very large instance could outlive 6 hours. | +| `cleanup_timeout` | `2m` | Maximum wait for the server-side backup deletion after the export. | + +The exporter also accepts: + +| Option | Default | Description | +|---|---|---| +| `pool` | pool referenced by the backup | Target storage pool for the restored instance | +| `target` | scheduler placement | Cluster member to create the restored instance on (clusters only) | + +Examples: + +```bash +# Instance in a non-default project, over a custom socket path +plakar backup -o project=customer-a -o socket=/run/incus/unix.socket incus://web-1 + +# Big instance on a slow server: give the temporary backup more headroom +plakar backup -o backup_ttl=24h -o cleanup_timeout=10m incus://data-warehouse + +# Restore into another project, onto a faster pool +plakar restore -to incus://web-1-restored -o project=customer-a -o pool=fast +``` + +### Remote servers (HTTPS + client certificate) + +The plugin can back up and restore instances of a **remote** Incus +server, e.g. from a dedicated backup machine, using Incus' native TLS +client-certificate authentication. + +One-time setup — expose the server and trust a client certificate: + +```bash +# On the Incus server +incus config set core.https_address :8443 + +# On the backup machine: generate a client certificate… +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:secp384r1 \ + -sha384 -keyout plakar-key.pem -out plakar-cert.pem \ + -nodes -subj "/CN=plakar-backup" -days 3650 + +# …and trust it on the server (or use `incus config trust add` tokens) +incus config trust add-certificate plakar-cert.pem + +# Pin the server's certificate on the backup machine (self-signed by default) +openssl s_client -connect incus.example:8443 /dev/null \ + | openssl x509 > incus-server.pem +``` + +Then point the connectors at the server: + +```bash +plakar backup \ + -o url=https://incus.example:8443 \ + -o tls_client_cert=/etc/plakar/plakar-cert.pem \ + -o tls_client_key=/etc/plakar/plakar-key.pem \ + -o tls_server_cert=/etc/plakar/incus-server.pem \ + incus://web-1 +``` + +Notes: + +- `tls_server_cert` pins the server's (usually self-signed) certificate. + Omit it only if the server's certificate is signed by a CA the system + trusts. For servers running in PKI mode, pass the CA with `tls_ca`. +- The trusted client certificate has full API access; there is no + insecure "skip verification" option, by design. +- Remote mode replaces the `incus-admin` group requirement — permissions + are the certificate trust, granted server-side. + +## What is NOT backed up + +A backup covers the instance's **root filesystem only** (the export is +created with `InstanceOnly: true`). Explicitly excluded: + +- **Incus snapshots** of the instance — they are not part of the export. + A restored instance starts with an empty snapshot list. +- **Custom storage volumes and host mounts** attached to the instance — + any `disk`-type device other than the root disk (e.g. + `incus storage volume attach ...` or a `source=/host/path` mount). + The device *entry* survives in the instance config, but the volume's + **content is not in the backup**; back those volumes up separately. + At backup time the importer inspects the instance's devices and + prints a `WARNING` on stderr for every such device so exclusions are + visible, not silent. +- Anything outside the instance: profiles' definitions, networks, + images, other project-level objects. + +A restore to Incus recreates the instance config as exported, including +device entries pointing at custom volumes — if those volumes no longer +exist on the target, Incus will refuse to start (or create) the +instance until they are recreated or the device is removed. + +## Server-side disk usage + +`plakar backup` asks Incus for a temporary export +(`CreateInstanceBackup`): the server **materializes the complete, +uncompressed tarball on its own disk before the plugin can stream +it**. During a backup the Incus host therefore temporarily holds a +second copy of the instance's root filesystem, under the server's +backups path — `/var/lib/incus/backups` by default, or the custom +volume named by the server config key `storage.backups_volume`. The +copy is deleted as soon as the stream completes, and expires after +`backup_ttl` (default 6h) even if the plugin dies first. + +Make sure that location has at least the instance's rootfs size free. +If a backup fails with `no space left on device`, free up space there +or move backup staging to a roomier pool: + +```bash +incus storage volume create backups-staging +incus config set storage.backups_volume /backups-staging +``` + +## Caveats + +- **Incus snapshots and attached volumes are excluded** — see + [What is NOT backed up](#what-is-not-backed-up) above. +- **Extended attributes / file capabilities: captured, not yet + restored.** The importer records every tar entry's PAX + `SCHILY.xattr.*` attributes (including `security.capability`, what + `getcap` reads) as kloset xattr records, and the exporter is ready to + reinject them into the rebuilt tar. However the current kloset + `Snapshot.Export()` does not hand xattr records to exporters, so + **restored files lose their xattrs and file capabilities** (e.g. + `ping`'s `cap_net_raw`) until that is fixed upstream — reapply with + `setcap` after restore where needed. The plugin side is done and will + light up with the kloset fix, tracked in this repo's TODO. +- **Hardlink identity.** Hardlinks round-trip as true hardlinks + incus-to-incus. Restoring to a non-Incus destination materializes + them as relative symlinks instead (true hardlink identity is not + representable in the connector protocol). Inode numbers themselves + are never preserved — only link relationships are. +- **Restore never overwrites.** Restoring to an instance name that + already exists fails fast (before the tarball is uploaded) with + `instance "X" already exists`; restore to another name or delete the + instance first. +- **Backup of a running instance is not point-in-time.** The export + reflects the rootfs while the container keeps running; stop the + instance (or accept the drift) for a quiescent capture. + +## Tested versions + +- Built against the official Incus Go client `lxc/incus/v6 v6.23.0` + (Incus 6.x API); older Incus servers missing the instance-type field + are treated as container-only. +- Validated end to end with plakar v1.1.3 against an Incus 6.x cluster + (LINSTOR storage pool, Debian containers) — see below. +- An automated live round-trip test exists under `e2e/` + (`make test-integration`): real Alpine container → backup → restore + under another name → boot + setuid/uid-gid/hardlink/symlink/fifo/ + getcap assertions + full rootfs manifest diff. It skips cleanly when + no local Incus daemon is reachable. + +## Validated + +2026-07-08, on the target infrastructure (plakar v1.1.3, Incus cluster, LINSTOR pool, Debian containers): + +- `plakar backup incus://` — 353 MiB container imported file-by-file in 18s; snapshot tree browsable down to individual rootfs files. +- Deduplication — second backup of the same instance: **+0 MB** on-disk repository growth (3.2 MiB written), 2x faster (9s). +- `plakar restore -to incus:// ` — instance recreated natively by Incus in 19s, boots to `systemd running`; spot-check md5 diff of /etc/passwd, /etc/fstab, /usr/bin/env, /etc/os-release against the source: identical. diff --git a/incus/e2e/roundtrip_test.go b/incus/e2e/roundtrip_test.go new file mode 100644 index 0000000..17b25f3 --- /dev/null +++ b/incus/e2e/roundtrip_test.go @@ -0,0 +1,460 @@ +//go:build integration + +/* + * Copyright (c) 2026 Antoine Dheygers + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +// Package e2e exercises the full backup/restore roundtrip against a real +// Incus daemon: a real Alpine container is created, backed up through the +// plugin importer, restored through the plugin exporter under another +// name, booted, and its rootfs compared against the source. +// +// Run with: +// +// go test -tags integration -count=1 -timeout 20m ./e2e +// +// The test skips when no Incus socket is present (same convention as +// TestIncusSourcePing). It needs permission to talk to the socket +// (typically membership in the incus-admin group) and network access on +// the Incus host to download the Alpine image the first time. +package e2e + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/PlakarKorp/kloset/connectors" + incus "github.com/lxc/incus/v6/client" + "github.com/lxc/incus/v6/shared/api" + + pexporter "github.com/PlakarKorp/integrations/incus/exporter" + pimporter "github.com/PlakarKorp/integrations/incus/importer" +) + +const socketPath = "/var/lib/incus/unix.socket" + +// testImage returns the image alias and simplestreams server used to +// create the source container, overridable for hosts with a local +// mirror or a pre-seeded image. +func testImage() (alias, server string) { + alias = os.Getenv("PLAKAR_E2E_IMAGE") + if alias == "" { + alias = "alpine/3.21" + } + server = os.Getenv("PLAKAR_E2E_IMAGE_SERVER") + if server == "" { + server = "https://images.linuxcontainers.org" + } + return alias, server +} + +func TestE2EBackupRestoreRoundtrip(t *testing.T) { + if _, err := os.Stat(socketPath); err != nil { + t.Skip("no incus socket on this machine") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + // Socket present but daemon stopped or socket not readable (not in + // incus-admin): treat like "no Incus here" rather than a failure, so + // the suite stays green on dev machines with a dormant install. + server, err := incus.ConnectIncusUnix(socketPath, nil) + if err != nil { + t.Skipf("incus socket present but unusable: %v", err) + } + srv, _, err := server.GetServer() + if err != nil { + t.Skipf("incus daemon unreachable: %v", err) + } + + // On a cluster, pin both instances to the local member. Left to the + // scheduler they can land on another node, where two things break the + // assertions (not the plugin): exec output streamed through the local + // unix socket from a remote member arrives empty/truncated, and the + // test containers may get no network there. The plugin roundtrip + // itself is cross-node capable (first live run proved it against an + // instance living on another member). + localMember := "" + if srv.Environment.ServerClustered { + localMember = srv.Environment.ServerName + t.Logf("clustered server: pinning instances to member %q", localMember) + } + + suffix := time.Now().Unix() + srcName := fmt.Sprintf("plakar-e2e-src-%d", suffix) + dstName := fmt.Sprintf("plakar-e2e-restored-%d", suffix) + t.Cleanup(func() { + deleteForce(server, srcName) + deleteForce(server, dstName) + }) + + // --- Source instance: create, boot, plant fixtures. --- + alias, imageServer := testImage() + t.Logf("creating %s from %s (%s)", srcName, alias, imageServer) + createServer := server + if localMember != "" { + createServer = server.UseTarget(localMember) + } + createOp, err := createServer.CreateInstance(api.InstancesPost{ + Name: srcName, + Type: api.InstanceTypeContainer, + Source: api.InstanceSource{ + Type: "image", + Alias: alias, + Server: imageServer, + Protocol: "simplestreams", + }, + }) + if err != nil { + t.Fatalf("create %s: %v", srcName, err) + } + if err := createOp.WaitContext(ctx); err != nil { + t.Fatalf("create %s: %v", srcName, err) + } + startAndWait(ctx, t, server, srcName) + + // Fixtures covering the fidelity claims of the connector: + // setuid bit + non-root uid/gid, a hardlink pair, a symlink and a + // fifo, all under /root so they sit in stable rootfs territory. + // chown BEFORE chmod: on Linux chown clears the setuid/setgid bits, + // so the reverse order silently plants a 0755 fixture and the setuid + // assertion after restore tests nothing. + // + // The final touch pins an integer-second mtime: a freshly written + // file carries a sub-second mtime, and Incus's backup unpacking + // rounds those up to the next full second (run 3: source 975.x + // stat'ed as 975, restore as 976 — every image file, with + // integer-second mtimes, round-tripped exactly). Pinning keeps the + // manifest's mtime comparison meaningful without tripping on that + // server-side rounding, which is outside the plugin's control. + mustExec(ctx, t, server, srcName, ` + printf fixture-data > /root/fixture && + chown 1234:5678 /root/fixture && + chmod 4755 /root/fixture && + ln /root/fixture /root/fixture-hl && + ln -s ../root/fixture /root/fixture-sym && + mkfifo /root/fixture-fifo && + touch -d @1700000000 /root/fixture /root/fixture-fifo + `) + + // File-capability fixture (security.capability xattr, what getcap + // reads). setcap ships in Alpine's libcap tooling and needs network + // to install, so this part is best-effort: without network the rest + // of the roundtrip is still fully asserted. + haveCaps := false + capScript := ` + i=0; while [ $i -lt 5 ]; do + apk add --no-cache libcap >/dev/null 2>&1 && break + i=$((i+1)); sleep 2 + done + command -v setcap >/dev/null 2>&1 || apk add --no-cache libcap-utils >/dev/null 2>&1 + command -v setcap >/dev/null 2>&1 || exit 42 + cp /bin/busybox /root/capbin && + setcap cap_net_raw+ep /root/capbin && + getcap /root/capbin + ` + if out, code := execIn(ctx, t, server, srcName, capScript); code == 0 && strings.Contains(out, "cap_net_raw") { + haveCaps = true + } else { + t.Logf("capability fixture unavailable (no network for apk?): rc=%d out=%q — skipping getcap assertion", code, out) + } + + srcManifest := mustExec(ctx, t, server, srcName, manifestScript) + + // Backup of a stopped instance: quiescent rootfs, nothing drifts + // between manifest collection and the tar stream. + stopAndWait(ctx, t, server, srcName) + + // --- Roundtrip through the plugin code paths. --- + t.Logf("backup %s -> restore %s via importer/exporter", srcName, dstName) + roundtrip(ctx, t, srcName, dstName, localMember) + + // --- Restored instance: boot and assert. --- + dst, _, err := server.GetInstance(dstName) + if err != nil { + t.Fatalf("restored instance %s not found: %v", dstName, err) + } + t.Logf("restored instance %s lives on member %q", dstName, dst.Location) + startAndWait(ctx, t, server, dstName) + + // uid/gid/mode incl. setuid bit. + if out := mustExec(ctx, t, server, dstName, `stat -c '%a %u %g' /root/fixture`); strings.TrimSpace(out) != "4755 1234 5678" { + t.Errorf("fixture mode/owner: got %q, want \"4755 1234 5678\"", strings.TrimSpace(out)) + } + + // Hardlink pair: same inode, link count 2, same content. + hl := mustExec(ctx, t, server, dstName, ` + a=$(stat -c '%i %h' /root/fixture) + b=$(stat -c '%i %h' /root/fixture-hl) + echo "$a|$b|$(cat /root/fixture)|$(cat /root/fixture-hl)" + `) + parts := strings.Split(strings.TrimSpace(hl), "|") + if len(parts) != 4 || parts[0] != parts[1] || parts[2] != "fixture-data" || parts[3] != "fixture-data" { + t.Errorf("hardlink pair broken after restore: %q", hl) + } + if !strings.HasSuffix(parts[0], " 2") { + t.Errorf("hardlink count: got %q, want inode with 2 links", parts[0]) + } + + // Symlink target and fifo type. + if out := mustExec(ctx, t, server, dstName, `readlink /root/fixture-sym`); strings.TrimSpace(out) != "../root/fixture" { + t.Errorf("symlink target: got %q, want \"../root/fixture\"", strings.TrimSpace(out)) + } + mustExec(ctx, t, server, dstName, `test -p /root/fixture-fifo`) + + // File capabilities survive (security.capability xattr). + if haveCaps { + if out := mustExec(ctx, t, server, dstName, `getcap /root/capbin`); !strings.Contains(out, "cap_net_raw") { + t.Errorf("file capability lost on restore: getcap output %q", out) + } + } + + // Full-manifest comparison over the stable rootfs subtrees. + dstManifest := mustExec(ctx, t, server, dstName, manifestScript) + if diff := manifestDiff(srcManifest, dstManifest); diff != "" { + t.Errorf("rootfs manifest differs between source and restore:\n%s", diff) + } +} + +// manifestScript prints one line per rootfs entry (type, mode, owner, +// and size+mtime for regular files) over the subtrees that do not +// change while an instance idles. /etc/hostname, resolv.conf and hosts +// are managed per-instance and legitimately differ. /etc/inittab is +// rewritten by Incus's image templates at instance creation, so it +// carries a fresh sub-second mtime that the backup pipeline rounds to +// the next second server-side (see the fixture touch above) — content +// still round-trips, only its mtime second is unstable. +const manifestScript = ` + find /bin /sbin /lib /usr /root /etc -xdev 2>/dev/null | sort | while read f; do + case "$f" in /etc/hostname|/etc/resolv.conf|/etc/hosts|/etc/inittab) continue;; esac + if [ -L "$f" ]; then echo "L $f -> $(readlink "$f")" + elif [ -f "$f" ]; then echo "F $(stat -c '%a %u %g %s %Y' "$f") $f" + elif [ -d "$f" ]; then echo "D $(stat -c '%a %u %g' "$f") $f" + else echo "O $(stat -c '%a %u %g' "$f") $f" + fi + done +` + +// manifestDiff returns a human-readable diff of the two manifests, empty +// when identical. Order-insensitive on top of the in-container sort. +func manifestDiff(src, dst string) string { + index := func(s string) map[string]struct{} { + m := make(map[string]struct{}) + for _, l := range strings.Split(s, "\n") { + if l = strings.TrimSpace(l); l != "" { + m[l] = struct{}{} + } + } + return m + } + srcSet, dstSet := index(src), index(dst) + var b strings.Builder + report := func(prefix string, have, other map[string]struct{}) { + n := 0 + for l := range have { + if _, ok := other[l]; !ok { + if n < 20 { + fmt.Fprintf(&b, "%s %s\n", prefix, l) + } + n++ + } + } + if n > 20 { + fmt.Fprintf(&b, "%s ... and %d more\n", prefix, n-20) + } + } + report("- only in source: ", srcSet, dstSet) + report("+ only in restore:", dstSet, srcSet) + return b.String() +} + +// roundtrip streams srcName through the plugin importer and pipes every +// record into the plugin exporter targeting dstName — the same record +// protocol kloset drives, minus the repository in the middle. kloset +// acks each importer record only after persisting its body, then +// replays records with repo-backed readers; the shim reproduces that +// contract by buffering each body before acking, so the importer's +// sequential tar reader is never invalidated while the exporter still +// holds the record pending. +func roundtrip(ctx context.Context, t *testing.T, srcName, dstName, targetMember string) { + t.Helper() + + imp, err := pimporter.NewImporter(ctx, &connectors.Options{}, "incus", + map[string]string{"location": "incus://" + srcName, "socket": socketPath}) + if err != nil { + t.Fatalf("NewImporter: %v", err) + } + defer func() { _ = imp.Close(ctx) }() + expConfig := map[string]string{"location": "incus://" + dstName, "socket": socketPath} + if targetMember != "" { + // Exercises the exporter's cluster `target` option, and keeps the + // restored instance on the local member so the exec-based + // assertions below see reliable output (see the clustered note in + // the test body). + expConfig["target"] = targetMember + } + exp, err := pexporter.NewExporter(ctx, &connectors.Options{}, "incus", expConfig) + if err != nil { + t.Fatalf("NewExporter: %v", err) + } + defer func() { _ = exp.Close(ctx) }() + + impRecords := make(chan *connectors.Record) + impResults := make(chan *connectors.Result) + expRecords := make(chan *connectors.Record) + expResults := make(chan *connectors.Result) + + impDone := make(chan error, 1) + go func() { impDone <- imp.Import(ctx, impRecords, impResults) }() + expDone := make(chan error, 1) + go func() { expDone <- exp.Export(ctx, expRecords, expResults) }() + + var expErrs []string + drained := make(chan struct{}) + go func() { + defer close(drained) + for res := range expResults { + if res.Err != nil { + expErrs = append(expErrs, fmt.Sprintf("%s: %v", res.Record.Pathname, res.Err)) + } + } + }() + + for rec := range impRecords { + fwd := *rec + if rec.Reader != nil { + data, err := io.ReadAll(rec.Reader) + if err != nil { + t.Fatalf("read record %s: %v", rec.Pathname, err) + } + fwd.Reader = io.NopCloser(bytes.NewReader(data)) + } + expRecords <- &fwd + impResults <- rec.Ok() + } + close(expRecords) + + if err := <-impDone; err != nil { + t.Fatalf("import %s: %v", srcName, err) + } + if err := <-expDone; err != nil { + t.Fatalf("export/restore %s: %v", dstName, err) + } + <-drained + if len(expErrs) > 0 { + t.Fatalf("exporter rejected %d record(s):\n%s", len(expErrs), strings.Join(expErrs, "\n")) + } +} + +// execIn runs a shell script in the instance and returns stdout+stderr +// and the exit code. Stdout and stderr MUST be distinct buffers: the +// incus client pumps them from two goroutines, and sharing one +// (non-thread-safe) bytes.Buffer corrupts or drops output at random — +// exactly what run 2 of the live E2E produced (empty multi-line +// results, manifests with ~25% of their lines missing). +func execIn(ctx context.Context, t *testing.T, server incus.InstanceServer, name, script string) (string, int) { + t.Helper() + var stdout, stderr bytes.Buffer + dataDone := make(chan bool) + op, err := server.ExecInstance(name, api.InstanceExecPost{ + Command: []string{"/bin/sh", "-c", script}, + WaitForWS: true, + Environment: map[string]string{ + "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "HOME": "/root", + }, + }, &incus.InstanceExecArgs{Stdout: &stdout, Stderr: &stderr, DataDone: dataDone}) + if err != nil { + t.Fatalf("exec in %s: %v", name, err) + } + if err := op.WaitContext(ctx); err != nil { + t.Fatalf("exec in %s: %v", name, err) + } + <-dataDone + code, ok := op.Get().Metadata["return"].(float64) + if !ok { + t.Fatalf("exec in %s: no return code in operation metadata", name) + } + return stdout.String() + stderr.String(), int(code) +} + +// mustExec is execIn failing the test on a non-zero exit code. +func mustExec(ctx context.Context, t *testing.T, server incus.InstanceServer, name, script string) string { + t.Helper() + out, code := execIn(ctx, t, server, name, script) + if code != 0 { + t.Fatalf("exec in %s: exit %d\nscript: %s\noutput: %s", name, code, script, out) + } + return out +} + +func startAndWait(ctx context.Context, t *testing.T, server incus.InstanceServer, name string) { + t.Helper() + op, err := server.UpdateInstanceState(name, api.InstanceStatePut{Action: "start", Timeout: -1}, "") + if err != nil { + t.Fatalf("start %s: %v", name, err) + } + if err := op.WaitContext(ctx); err != nil { + t.Fatalf("start %s: %v", name, err) + } + // Running != exec-ready: poll until PID 1 answers. + deadline := time.Now().Add(90 * time.Second) + for { + if _, code := execIn(ctx, t, server, name, "true"); code == 0 { + return + } + if time.Now().After(deadline) { + t.Fatalf("instance %s did not become exec-ready within 90s", name) + } + time.Sleep(2 * time.Second) + } +} + +func stopAndWait(ctx context.Context, t *testing.T, server incus.InstanceServer, name string) { + t.Helper() + op, err := server.UpdateInstanceState(name, api.InstanceStatePut{Action: "stop", Timeout: 30, Force: false}, "") + if err != nil { + t.Fatalf("stop %s: %v", name, err) + } + if err := op.WaitContext(ctx); err != nil { + // Graceful shutdown timed out; a forced stop still gives a + // consistent (if abrupt) rootfs for backup purposes. + op, err = server.UpdateInstanceState(name, api.InstanceStatePut{Action: "stop", Force: true}, "") + if err != nil { + t.Fatalf("force stop %s: %v", name, err) + } + if err := op.WaitContext(ctx); err != nil { + t.Fatalf("force stop %s: %v", name, err) + } + } +} + +// deleteForce is best-effort teardown: stop hard, delete, ignore errors +// (the instance may never have been created if the test failed early). +func deleteForce(server incus.InstanceServer, name string) { + if op, err := server.UpdateInstanceState(name, api.InstanceStatePut{Action: "stop", Force: true}, ""); err == nil { + _ = op.Wait() + } + if op, err := server.DeleteInstance(name); err == nil { + _ = op.Wait() + } +} diff --git a/incus/exporter/header.go b/incus/exporter/header.go new file mode 100644 index 0000000..1a2f7e1 --- /dev/null +++ b/incus/exporter/header.go @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Antoine Dheygers + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +package exporter + +import ( + "archive/tar" + "fmt" + "io/fs" + "path" + "strings" + + "github.com/PlakarKorp/kloset/connectors" +) + +// tarHeader rebuilds the tar entry for a record, inverse of the +// importer mapping. +func tarHeader(rec *connectors.Record) (*tar.Header, error) { + name := strings.TrimPrefix(rec.Pathname, "/") + if name == "" { + return nil, fmt.Errorf("empty pathname") + } + fi := rec.FileInfo + + // Rebuild the raw tar mode from the portable fs.FileMode flags: + // permission bits verbatim, plus setuid/setgid/sticky translated + // back to their 0o4000/0o2000/0o1000 tar bits. + mode := int64(fi.Lmode & fs.ModePerm) + if fi.Lmode&fs.ModeSetuid != 0 { + mode |= 0o4000 + } + if fi.Lmode&fs.ModeSetgid != 0 { + mode |= 0o2000 + } + if fi.Lmode&fs.ModeSticky != 0 { + mode |= 0o1000 + } + // Legacy compat: snapshots taken before this fix stored the raw + // tar special bits directly in Lmode instead of the Go + // fs.ModeSetuid/Setgid/Sticky flags. OR them back in here too - + // harmless overlap when both forms are present, but required so + // exporting a pre-fix snapshot doesn't silently drop + // setuid/setgid/sticky. + mode |= int64(fi.Lmode) & 0o7000 + + hdr := &tar.Header{ + Name: name, + Mode: mode, + Uid: int(fi.Luid), + Gid: int(fi.Lgid), + ModTime: fi.LmodTime, + } + + switch { + case fi.Lmode.IsDir(): + hdr.Typeflag = tar.TypeDir + hdr.Name += "/" + case fi.Lmode&fs.ModeSymlink != 0: + if fi.Lnlink > 1 { + // Hardlink (see importer/finfo.go's linkTarget): Target + // is the relative path from the link's own directory to + // the linked file, chosen so that generic exporters + // materialize a working relative symlink. Tar wants + // hardlink Linknames relative to the tar root instead: + // resolve Target against the record's directory + // (path.Join also collapses any "../") and drop the + // leading "/" to match how entry names are written. + hdr.Typeflag = tar.TypeLink + hdr.Linkname = strings.TrimPrefix(path.Join(path.Dir(rec.Pathname), rec.Target), "/") + } else { + hdr.Typeflag = tar.TypeSymlink + hdr.Linkname = rec.Target + } + case fi.Lmode&fs.ModeCharDevice != 0: + hdr.Typeflag = tar.TypeChar + // Reverse of the Ldev encoding documented in + // importer/finfo.go: devmajor in the high 32 bits, devminor + // in the low 32 bits. + hdr.Devmajor = int64(fi.Ldev >> 32) + hdr.Devminor = int64(fi.Ldev & 0xffffffff) + case fi.Lmode&fs.ModeDevice != 0: + hdr.Typeflag = tar.TypeBlock + // Same Ldev encoding as tar.TypeChar above. + hdr.Devmajor = int64(fi.Ldev >> 32) + hdr.Devminor = int64(fi.Ldev & 0xffffffff) + case fi.Lmode&fs.ModeNamedPipe != 0: + hdr.Typeflag = tar.TypeFifo + default: + hdr.Typeflag = tar.TypeReg + hdr.Size = fi.Lsize + } + + return hdr, nil +} diff --git a/incus/exporter/header_test.go b/incus/exporter/header_test.go new file mode 100644 index 0000000..af6bc18 --- /dev/null +++ b/incus/exporter/header_test.go @@ -0,0 +1,134 @@ +package exporter + +import ( + "archive/tar" + "io/fs" + "testing" + "time" + + "github.com/PlakarKorp/kloset/connectors" + "github.com/PlakarKorp/kloset/objects" +) + +func rec(path string, fi objects.FileInfo, target string) *connectors.Record { + return &connectors.Record{Pathname: path, Target: target, FileInfo: fi} +} + +func TestTarHeaderRegular(t *testing.T) { + fi := objects.FileInfo{Lname: "hostname", Lsize: 5, Lmode: fs.FileMode(0644), + LmodTime: time.Unix(1750000000, 0), Luid: 0, Lgid: 0} + hdr, err := tarHeader(rec("/backup/container/rootfs/etc/hostname", fi, "")) + if err != nil { + t.Fatal(err) + } + if hdr.Name != "backup/container/rootfs/etc/hostname" || hdr.Typeflag != tar.TypeReg { + t.Fatalf("bad header: %+v", hdr) + } + if hdr.Size != 5 || hdr.Mode != 0644 { + t.Fatalf("bad size/mode: %+v", hdr) + } +} + +func TestTarHeaderDir(t *testing.T) { + fi := objects.FileInfo{Lname: "etc", Lmode: fs.ModeDir | 0755} + hdr, err := tarHeader(rec("/backup/container/rootfs/etc", fi, "")) + if err != nil { + t.Fatal(err) + } + if hdr.Typeflag != tar.TypeDir || hdr.Name != "backup/container/rootfs/etc/" { + t.Fatalf("bad dir header: %+v", hdr) + } + if hdr.Size != 0 { + t.Fatalf("dir size must be 0: %+v", hdr) + } +} + +// TestTarHeaderSpecialBitsLegacy covers pre-fix snapshots: Lmode carries the +// raw tar special bits directly (no fs.ModeSetuid/Setgid/Sticky flag set). +// The legacy compat OR in tarHeader must still reproduce the original mode. +func TestTarHeaderSpecialBitsLegacy(t *testing.T) { + fi := objects.FileInfo{Lname: "sudo", Lsize: 3, Lmode: fs.FileMode(0o4755), + LmodTime: time.Unix(1750000000, 0), Luid: 0, Lgid: 0} + hdr, err := tarHeader(rec("/backup/container/rootfs/usr/bin/sudo", fi, "")) + if err != nil { + t.Fatal(err) + } + if hdr.Mode != 0o4755 { + t.Fatalf("setuid bit dropped (legacy raw form): got mode %o, want %o", hdr.Mode, 0o4755) + } +} + +// TestTarHeaderSpecialBitsNewForm covers snapshots taken after the fix: +// Lmode carries the portable fs.ModeSetuid flag plus permission bits. +func TestTarHeaderSpecialBitsNewForm(t *testing.T) { + fi := objects.FileInfo{Lname: "sudo", Lsize: 3, Lmode: fs.ModeSetuid | fs.FileMode(0o755), + LmodTime: time.Unix(1750000000, 0), Luid: 0, Lgid: 0} + hdr, err := tarHeader(rec("/backup/container/rootfs/usr/bin/sudo", fi, "")) + if err != nil { + t.Fatal(err) + } + if hdr.Mode != 0o4755 { + t.Fatalf("setuid bit not converted (new form): got mode %o, want %o", hdr.Mode, 0o4755) + } +} + +func TestTarHeaderSymlink(t *testing.T) { + fi := objects.FileInfo{Lname: "bin", Lmode: fs.ModeSymlink | 0777} + hdr, err := tarHeader(rec("/backup/container/rootfs/bin", fi, "usr/bin")) + if err != nil { + t.Fatal(err) + } + if hdr.Typeflag != tar.TypeSymlink || hdr.Linkname != "usr/bin" || hdr.Size != 0 { + t.Fatalf("bad symlink header: %+v", hdr) + } +} + +// TestTarHeaderHardlink mirrors what importer.finfo/linkTarget produce for a +// tar.TypeLink entry: Lmode has ModeSymlink set, Lnlink==2, and Target is +// RELATIVE to the record's own directory. The exporter must re-emit +// tar.TypeLink with a tar-root-relative Linkname, not a broken TypeSymlink. +func TestTarHeaderHardlink(t *testing.T) { + fi := objects.FileInfo{Lname: "ls", Lmode: fs.ModeSymlink | 0777, Lnlink: 2} + hdr, err := tarHeader(rec("/backup/container/rootfs/bin/ls", fi, "busybox")) + if err != nil { + t.Fatal(err) + } + if hdr.Typeflag != tar.TypeLink { + t.Fatalf("hardlink must round-trip as tar.TypeLink, got %v", hdr.Typeflag) + } + want := "backup/container/rootfs/bin/busybox" + if hdr.Linkname != want { + t.Fatalf("hardlink Linkname: got %q, want %q", hdr.Linkname, want) + } +} + +// TestTarHeaderHardlinkCrossDir checks that a "../"-style relative Target is +// resolved against the record's directory back to the exact tar-root path. +func TestTarHeaderHardlinkCrossDir(t *testing.T) { + fi := objects.FileInfo{Lname: "ls", Lmode: fs.ModeSymlink | 0777, Lnlink: 2} + hdr, err := tarHeader(rec("/backup/container/rootfs/bin/ls", fi, "../sbin/tool")) + if err != nil { + t.Fatal(err) + } + if hdr.Typeflag != tar.TypeLink { + t.Fatalf("hardlink must round-trip as tar.TypeLink, got %v", hdr.Typeflag) + } + want := "backup/container/rootfs/sbin/tool" + if hdr.Linkname != want { + t.Fatalf("cross-dir hardlink Linkname: got %q, want %q", hdr.Linkname, want) + } +} + +func TestTarHeaderDevice(t *testing.T) { + fi := objects.FileInfo{Lname: "null", Lmode: fs.ModeCharDevice | 0666, Ldev: uint64(1)<<32 | 3} + hdr, err := tarHeader(rec("/backup/container/rootfs/dev/null", fi, "")) + if err != nil { + t.Fatal(err) + } + if hdr.Typeflag != tar.TypeChar { + t.Fatalf("device typeflag: got %v, want TypeChar", hdr.Typeflag) + } + if hdr.Devmajor != 1 || hdr.Devminor != 3 { + t.Fatalf("device major/minor: got %d:%d, want 1:3", hdr.Devmajor, hdr.Devminor) + } +} diff --git a/incus/exporter/incus.go b/incus/exporter/incus.go new file mode 100644 index 0000000..a28349c --- /dev/null +++ b/incus/exporter/incus.go @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2026 Antoine Dheygers + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +package exporter + +import ( + "archive/tar" + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/PlakarKorp/kloset/connectors" + "github.com/PlakarKorp/kloset/connectors/exporter" + "github.com/PlakarKorp/kloset/location" +) + +func init() { + _ = exporter.Register("incus", 0, NewExporter) +} + +// restoreSink abstracts the Incus restore API for testability. +type restoreSink interface { + Ping(ctx context.Context) error + // Restore consumes a portable backup tar stream and creates the + // named instance from it. + Restore(ctx context.Context, instance string, tarStream io.Reader) error +} + +type Exporter struct { + instance string + project string + sink restoreSink +} + +func NewExporter(ctx context.Context, opts *connectors.Options, proto string, config map[string]string) (exporter.Exporter, error) { + instance := strings.TrimPrefix(config["location"], "incus://") + if instance == "" || strings.Contains(instance, "/") { + return nil, fmt.Errorf("incus: invalid instance name %q", instance) + } + sink, err := newIncusSink(config) + if err != nil { + return nil, err + } + exp := newExporterWithSink(instance, sink) + exp.project = config["project"] + return exp, nil +} + +func newExporterWithSink(instance string, sink restoreSink) *Exporter { + return &Exporter{instance: instance, sink: sink} +} + +func (p *Exporter) Type() string { return "incus" } +func (p *Exporter) Root() string { return "/" } +func (p *Exporter) Flags() location.Flags { return 0 } + +func (p *Exporter) Origin() string { + // Same qualification rule as the importer: instance names are only + // unique within a project, so a non-default project prefixes the + // origin to keep it unambiguous. + if p.project != "" { + return p.project + "/" + p.instance + } + return p.instance +} + +func (p *Exporter) Ping(ctx context.Context) error { return p.sink.Ping(ctx) } + +func (p *Exporter) Export(ctx context.Context, records <-chan *connectors.Record, results chan<- *connectors.Result) (ret error) { + defer close(results) + + pr, pw := io.Pipe() + sinkDone := make(chan error, 1) + go func() { + err := p.sink.Restore(ctx, p.instance, pr) + // Always close the read end, even when Restore returns early + // without draining the stream: otherwise the tar writer below + // stays blocked forever on pw.Write, deadlocking Export when + // the sink rejects the upload instead of consuming it fully. + pr.CloseWithError(err) + sinkDone <- err + }() + + tw := tar.NewWriter(pw) + + // The tar stream is sequential by nature: records are written in + // the order they arrive, one at a time. + // + // The rebuilt tar preserves record ARRIVAL ORDER, and Incus expects + // backup/index.yaml first. Correctness here relies on kloset replaying + // records in import order (true today, beta-validated); if kloset ever + // reorders records, this needs revisiting. + // + // xattr records (rec.IsXattr) arrive right after the file record they + // belong to (same Pathname) - that's the ordering the incus importer + // emits (mirroring plakar-integrations/fs/importer) and the one this + // exporter's "replay in import order" guarantee above depends on. So a + // file record can't be written immediately: it must be held ("pending") + // until either the next non-xattr record or end-of-stream tells us no + // more xattrs are coming for it, accumulating any xattrs seen in the + // meantime into its tar header's PAXRecords. Holding the record is + // cheap and safe: its Reader is repo-backed/lazy, not a live resource + // that needs immediate draining. + var ( + pending *connectors.Record + pendingXattrs map[string]string + ) + + // flushPending writes the held record (if any) as a tar entry, with + // any accumulated xattrs folded into its PAXRecords, and acks it. + // It returns a non-nil error only for failures that corrupt the tar + // stream itself (WriteHeader/body copy) - those abort Export. Header + // build errors and the nil-reader guard are per-record and reported + // via an Error result without aborting the rest of the stream, exactly + // as before this change. + flushPending := func() error { + if pending == nil { + return nil + } + p, xattrs := pending, pendingXattrs + pending, pendingXattrs = nil, nil + + hdr, err := tarHeader(p) + if err != nil { + results <- p.Error(err) + return nil + } + if len(xattrs) > 0 { + hdr.PAXRecords = xattrs + } + if hdr.Typeflag == tar.TypeReg && hdr.Size > 0 && p.Reader == nil { + // Writing this header would promise hdr.Size bytes of + // body that we have nothing to supply, corrupting the + // tar stream for every entry that follows. Reject the + // record instead of emitting a header with no body. + results <- p.Error(fmt.Errorf("incus: record %q has size %d but no reader", p.Pathname, hdr.Size)) + return nil + } + if err := tw.WriteHeader(hdr); err != nil { + results <- p.Error(err) + return err + } + if hdr.Typeflag == tar.TypeReg && p.Reader != nil { + n, err := io.Copy(tw, p.Reader) + if err != nil { + results <- p.Error(err) + return err + } + if n != hdr.Size { + // The tar stream is already corrupted at this point (the + // header promised hdr.Size bytes of body): a short read + // here would otherwise surface as a cryptic error on the + // *next* WriteHeader call. Fail this record now with a + // clear message instead. + err := fmt.Errorf("incus: %s: short read: got %d bytes, want %d", p.Pathname, n, hdr.Size) + results <- p.Error(err) + return err + } + } + results <- p.Ok() + return nil + } + +loop: + for { + select { + case <-ctx.Done(): + ret = ctx.Err() + if pending != nil { + results <- pending.Error(ret) + pending, pendingXattrs = nil, nil + } + break loop + + case rec, ok := <-records: + if !ok { + break loop + } + if rec.Err != nil { + if err := flushPending(); err != nil { + results <- rec.Error(err) + ret = err + break loop + } + results <- rec.Ok() + continue + } + if rec.IsXattr { + if pending == nil || pending.Pathname != rec.Pathname { + // Protocol violation: an xattr record must follow + // its owning file record with the same Pathname. + results <- rec.Error(fmt.Errorf("incus: xattr record %q (%s) has no matching pending file record", rec.Pathname, rec.XattrName)) + continue + } + value, err := io.ReadAll(rec.Reader) + if err != nil { + results <- rec.Error(err) + continue + } + if pendingXattrs == nil { + pendingXattrs = make(map[string]string) + } + pendingXattrs["SCHILY.xattr."+rec.XattrName] = string(value) + results <- rec.Ok() + continue + } + + // A new file/dir/symlink record: whatever was pending has + // now seen all of its xattrs (if any), flush it. + if err := flushPending(); err != nil { + results <- rec.Error(err) + ret = err + break loop + } + pending = rec + } + } + + if pending != nil { + if err := flushPending(); err != nil && ret == nil { + ret = err + } + } + + if err := tw.Close(); err != nil && ret == nil { + ret = err + } + if ret != nil { + pw.CloseWithError(ret) + } else { + _ = pw.Close() + } + + // The sink's error is the actionable one: it explains *why* the pipe + // broke. When the write loop only observed the pipe closing (nil, or + // the unhelpful io.ErrClosedPipe), surface the sink's error instead of + // masking it. + if sinkErr := <-sinkDone; sinkErr != nil && (ret == nil || errors.Is(ret, io.ErrClosedPipe)) { + ret = fmt.Errorf("incus: restore %s: %w", p.instance, sinkErr) + } + return ret +} + +func (p *Exporter) Close(ctx context.Context) error { return nil } diff --git a/incus/exporter/incus_api.go b/incus/exporter/incus_api.go new file mode 100644 index 0000000..4e9a15e --- /dev/null +++ b/incus/exporter/incus_api.go @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Antoine Dheygers + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +package exporter + +import ( + "context" + "fmt" + "io" + + "github.com/PlakarKorp/integrations/incus/internal/conn" + incus "github.com/lxc/incus/v6/client" +) + +type incusSink struct { + server incus.InstanceServer + pool string + project string + target string +} + +func newIncusSink(config map[string]string) (restoreSink, error) { + // conn.Connect handles the transport (unix socket or HTTPS with + // client certificate) and the project scoping. + server, err := conn.Connect(config) + if err != nil { + return nil, err + } + return &incusSink{ + server: server, + pool: config["pool"], + project: config["project"], + target: config["target"], + }, nil +} + +func (s *incusSink) Ping(ctx context.Context) error { + _, _, err := s.server.GetServer() + return err +} + +func (s *incusSink) Restore(ctx context.Context, instance string, tarStream io.Reader) error { + // CreateInstanceFromBackup against a taken name fails only after the + // full tarball has been uploaded and unpacked server-side. Fail fast + // with a clear message instead. Best-effort: any pre-check failure + // other than "exists" falls through to the create call, which reports + // the real problem if there is one. + if _, _, err := s.server.GetInstance(instance); err == nil { + where := "" + if s.project != "" { + where = fmt.Sprintf(" in project %q", s.project) + } + return fmt.Errorf("instance %q already exists%s, restore to another name or delete it first", instance, where) + } + + server := s.server + if s.target != "" { + // On a cluster, pin the restored instance to the given member; + // without it the scheduler picks one. UseTarget only influences + // placement-creating calls, so the pre-check above is unaffected. + server = server.UseTarget(s.target) + } + op, err := server.CreateInstanceFromBackup(incus.InstanceBackupArgs{ + BackupFile: tarStream, + Name: instance, + PoolName: s.pool, + }) + if err != nil { + return err + } + return op.WaitContext(ctx) +} diff --git a/incus/exporter/incus_api_test.go b/incus/exporter/incus_api_test.go new file mode 100644 index 0000000..5f9e0f5 --- /dev/null +++ b/incus/exporter/incus_api_test.go @@ -0,0 +1,130 @@ +package exporter + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + incus "github.com/lxc/incus/v6/client" + "github.com/lxc/incus/v6/shared/api" +) + +// fakeInstanceServer stubs the two InstanceServer methods Restore touches. +// Every other method panics via the embedded nil interface, which is exactly +// what we want: the test fails loudly if Restore starts calling anything new. +type fakeInstanceServer struct { + incus.InstanceServer + + getInstanceErr error // returned by GetInstance (nil = instance exists) + createErr error // returned by CreateInstanceFromBackup + createCalled bool + targetUsed string // member passed to UseTarget, if any +} + +func (f *fakeInstanceServer) UseTarget(name string) incus.InstanceServer { + f.targetUsed = name + return f +} + +func (f *fakeInstanceServer) GetInstance(name string) (*api.Instance, string, error) { + if f.getInstanceErr != nil { + return nil, "", f.getInstanceErr + } + return &api.Instance{Name: name}, "etag", nil +} + +func (f *fakeInstanceServer) CreateInstanceFromBackup(args incus.InstanceBackupArgs) (incus.Operation, error) { + f.createCalled = true + return nil, f.createErr +} + +func TestRestoreRejectsExistingInstanceName(t *testing.T) { + fake := &fakeInstanceServer{} // GetInstance succeeds: name is taken + sink := &incusSink{server: fake} + + err := sink.Restore(context.Background(), "web-1", strings.NewReader("")) + if err == nil { + t.Fatal("Restore on an existing instance name: got nil error, want error") + } + if !strings.Contains(err.Error(), "web-1") || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("error %q should name the instance and say it already exists", err) + } + if fake.createCalled { + t.Fatal("CreateInstanceFromBackup was called despite the name being taken: the whole tarball would be uploaded for nothing") + } +} + +func TestRestoreExistingInstanceErrorMentionsProject(t *testing.T) { + fake := &fakeInstanceServer{} + sink := &incusSink{server: fake, project: "tenant-a"} + + err := sink.Restore(context.Background(), "web-1", strings.NewReader("")) + if err == nil { + t.Fatal("Restore on an existing instance name: got nil error, want error") + } + if !strings.Contains(err.Error(), "tenant-a") { + t.Fatalf("error %q should mention the project when one is configured", err) + } +} + +func TestRestoreProceedsWhenInstanceNotFound(t *testing.T) { + sentinel := errors.New("create failed") + fake := &fakeInstanceServer{ + getInstanceErr: api.StatusErrorf(http.StatusNotFound, "Instance not found"), + createErr: sentinel, + } + sink := &incusSink{server: fake} + + err := sink.Restore(context.Background(), "web-1", strings.NewReader("")) + if !fake.createCalled { + t.Fatal("CreateInstanceFromBackup was not called after a clean not-found pre-check") + } + if !errors.Is(err, sentinel) { + t.Fatalf("Restore should surface CreateInstanceFromBackup's error, got %v", err) + } +} + +// The target option must pin the create call to the given cluster +// member, and stay out of the way when unset. +func TestRestoreUsesTargetMember(t *testing.T) { + notFound := api.StatusErrorf(http.StatusNotFound, "Instance not found") + // A non-nil create error makes the fake's (nil) Operation unused, so + // Restore returns right after the call under test. + sentinel := errors.New("create failed") + + fake := &fakeInstanceServer{getInstanceErr: notFound, createErr: sentinel} + sink := &incusSink{server: fake, target: "ha1"} + _ = sink.Restore(context.Background(), "web-1", strings.NewReader("")) + if !fake.createCalled || fake.targetUsed != "ha1" { + t.Fatalf("create called=%v target=%q, want create via UseTarget(\"ha1\")", fake.createCalled, fake.targetUsed) + } + + fake = &fakeInstanceServer{getInstanceErr: notFound, createErr: sentinel} + sink = &incusSink{server: fake} + _ = sink.Restore(context.Background(), "web-1", strings.NewReader("")) + if !fake.createCalled || fake.targetUsed != "" { + t.Fatalf("create called=%v target=%q, want create without UseTarget", fake.createCalled, fake.targetUsed) + } +} + +func TestRestoreProceedsWhenPreCheckFails(t *testing.T) { + // A pre-check failure other than 404 (permissions, transient API error) + // must not block the restore: it is best-effort only, the create call + // will report the real problem if there is one. + sentinel := errors.New("create failed") + fake := &fakeInstanceServer{ + getInstanceErr: api.StatusErrorf(http.StatusInternalServerError, "boom"), + createErr: sentinel, + } + sink := &incusSink{server: fake} + + err := sink.Restore(context.Background(), "web-1", strings.NewReader("")) + if !fake.createCalled { + t.Fatal("CreateInstanceFromBackup was not called after a non-404 pre-check failure") + } + if !errors.Is(err, sentinel) { + t.Fatalf("Restore should surface CreateInstanceFromBackup's error, got %v", err) + } +} diff --git a/incus/exporter/incus_test.go b/incus/exporter/incus_test.go new file mode 100644 index 0000000..f11e293 --- /dev/null +++ b/incus/exporter/incus_test.go @@ -0,0 +1,468 @@ +package exporter + +import ( + "archive/tar" + "bytes" + "context" + "errors" + "io" + "io/fs" + "strings" + "testing" + "time" + + "github.com/PlakarKorp/kloset/connectors" + "github.com/PlakarKorp/kloset/objects" +) + +// A broken remote config (url without the client certificate pair, or +// TLS options without url) must fail NewExporter with the config error, +// before any connection is attempted. +func TestNewExporterRejectsBadRemoteConfig(t *testing.T) { + for _, cfg := range []map[string]string{ + {"location": "incus://web", "url": "https://incus.example:8443"}, + {"location": "incus://web", "tls_client_cert": "/some/cert.pem"}, + } { + if _, err := NewExporter(context.Background(), &connectors.Options{}, "incus", cfg); err == nil { + t.Fatalf("config %v: want error, got nil", cfg) + } else if !strings.Contains(err.Error(), "tls_client_cert") { + t.Fatalf("config %v: error should name the tls option, got %v", cfg, err) + } + } +} + +type fakeSink struct { + instance string + tarball bytes.Buffer +} + +func (f *fakeSink) Ping(ctx context.Context) error { return nil } + +func (f *fakeSink) Restore(ctx context.Context, instance string, tarStream io.Reader) error { + f.instance = instance + _, err := io.Copy(&f.tarball, tarStream) + return err +} + +func TestExportRebuildsTar(t *testing.T) { + sink := &fakeSink{} + exp := newExporterWithSink("restored-1", sink) + + records := make(chan *connectors.Record) + results := make(chan *connectors.Result, 8) + done := make(chan error, 1) + go func() { done <- exp.Export(context.Background(), records, results) }() + + feed := []*connectors.Record{ + {Pathname: "/backup/index.yaml", + FileInfo: objects.FileInfo{Lname: "index.yaml", Lsize: 11, Lmode: 0644, LmodTime: time.Unix(1750000000, 0)}, + Reader: io.NopCloser(strings.NewReader("name: test\n"))}, + {Pathname: "/backup/container/rootfs/etc", + FileInfo: objects.FileInfo{Lname: "etc", Lmode: fs.ModeDir | 0755}}, + {Pathname: "/backup/container/rootfs/etc/hostname", + FileInfo: objects.FileInfo{Lname: "hostname", Lsize: 5, Lmode: 0644}, + Reader: io.NopCloser(strings.NewReader("test\n"))}, + {Pathname: "/backup/container/rootfs/bin", Target: "usr/bin", + FileInfo: objects.FileInfo{Lname: "bin", Lmode: fs.ModeSymlink | 0777}}, + } + for _, r := range feed { + records <- r + } + close(records) + if err := <-done; err != nil { + t.Fatalf("Export: %v", err) + } + + if sink.instance != "restored-1" { + t.Fatalf("instance: %q", sink.instance) + } + + // results: un ack par record (le contenu de Result est opaque au SDK ; + // l'erreur globale d'Export est le vrai signal) + for range len(feed) { + <-results + } + + // re-lire le tar produit + tr := tar.NewReader(bytes.NewReader(sink.tarball.Bytes())) + var names []string + var hostname string + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + names = append(names, hdr.Name) + if hdr.Name == "backup/container/rootfs/etc/hostname" { + b, _ := io.ReadAll(tr) + hostname = string(b) + } + if hdr.Name == "backup/container/rootfs/bin" && hdr.Linkname != "usr/bin" { + t.Fatalf("symlink lost: %+v", hdr) + } + } + want := []string{"backup/index.yaml", "backup/container/rootfs/etc/", "backup/container/rootfs/etc/hostname", "backup/container/rootfs/bin"} + if strings.Join(names, ",") != strings.Join(want, ",") { + t.Fatalf("entries: %v", names) + } + if hostname != "test\n" { + t.Fatalf("hostname content: %q", hostname) + } +} + +// earlyFailSink emulates a real Incus rejecting an upload: it reads only a +// small prefix of the tar stream (e.g. just enough to inspect a header or +// fail an early sanity check) and returns without draining the rest. +type earlyFailSink struct { + readN int64 + err error +} + +func (s *earlyFailSink) Ping(ctx context.Context) error { return nil } + +func (s *earlyFailSink) Restore(ctx context.Context, instance string, tarStream io.Reader) error { + _, _ = io.CopyN(io.Discard, tarStream, s.readN) + return s.err +} + +// TestExportSinkFailsEarly reproduces the deadlock found in review: when the +// restore sink returns before fully draining the tar stream, the io.Pipe +// writer side must be unblocked (via pr.CloseWithError in the sink +// goroutine), otherwise Export blocks forever on a pending pipe Write. +func TestExportSinkFailsEarly(t *testing.T) { + wantErr := errors.New("sink rejected upload") + sink := &earlyFailSink{readN: 512, err: wantErr} + exp := newExporterWithSink("restored-2", sink) + + records := make(chan *connectors.Record) + results := make(chan *connectors.Result, 16) + done := make(chan error, 1) + stop := make(chan struct{}) + go func() { + err := exp.Export(context.Background(), records, results) + done <- err + close(stop) + }() + + // Records large enough (128KiB each) to guarantee that, once the sink + // stops reading, a pending body write blocks on the (unbuffered) + // io.Pipe instead of completing instantly. + zeros := make([]byte, 128*1024) + feed := []*connectors.Record{ + {Pathname: "/backup/container/rootfs/big1", + FileInfo: objects.FileInfo{Lname: "big1", Lsize: int64(len(zeros)), Lmode: 0644}, + Reader: io.NopCloser(bytes.NewReader(zeros))}, + {Pathname: "/backup/container/rootfs/big2", + FileInfo: objects.FileInfo{Lname: "big2", Lsize: int64(len(zeros)), Lmode: 0644}, + Reader: io.NopCloser(bytes.NewReader(zeros))}, + {Pathname: "/backup/container/rootfs/big3", + FileInfo: objects.FileInfo{Lname: "big3", Lsize: int64(len(zeros)), Lmode: 0644}, + Reader: io.NopCloser(bytes.NewReader(zeros))}, + } + + sent := 0 + feedDone := make(chan struct{}) + go func() { + defer close(feedDone) + for _, r := range feed { + select { + case records <- r: + sent++ + case <-stop: + // Export gave up (already returned): stop feeding + // instead of blocking forever on a channel nobody + // reads from anymore. + return + } + } + close(records) + }() + + select { + case err := <-done: + if err == nil { + t.Fatal("Export: expected a non-nil error, got nil") + } + if !errors.Is(err, wantErr) && !strings.Contains(err.Error(), wantErr.Error()) { + t.Fatalf("Export error = %v, want it to contain %v", err, wantErr) + } + case <-time.After(10 * time.Second): + t.Fatal("Export deadlocked: a sink failing early must not block forever") + } + + select { + case <-feedDone: + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for the feeder goroutine to unblock") + } + + got := 0 + for range results { + got++ + } + if got != sent { + t.Fatalf("results delivered = %d, want %d (one per record actually read by Export)", got, sent) + } + if sent == 0 { + t.Fatal("no record was ever handed to Export; test is not exercising the deadlock path") + } +} + +// TestExportNilReaderRegular ensures a malformed record (a regular file +// announcing a non-zero size but carrying no Reader) is rejected cleanly +// instead of writing a tar header promising a body that never arrives - +// which would silently corrupt every entry written after it. +func TestExportNilReaderRegular(t *testing.T) { + sink := &fakeSink{} + exp := newExporterWithSink("restored-3", sink) + + records := make(chan *connectors.Record) + results := make(chan *connectors.Result, 8) + done := make(chan error, 1) + go func() { done <- exp.Export(context.Background(), records, results) }() + + feed := []*connectors.Record{ + {Pathname: "/backup/index.yaml", + FileInfo: objects.FileInfo{Lname: "index.yaml", Lsize: 11, Lmode: 0644}, + Reader: io.NopCloser(strings.NewReader("name: test\n"))}, + {Pathname: "/backup/container/rootfs/broken", + FileInfo: objects.FileInfo{Lname: "broken", Lsize: 42, Lmode: 0644}, + Reader: nil}, + {Pathname: "/backup/container/rootfs/etc/hostname", + FileInfo: objects.FileInfo{Lname: "hostname", Lsize: 5, Lmode: 0644}, + Reader: io.NopCloser(strings.NewReader("test\n"))}, + } + for _, r := range feed { + records <- r + } + close(records) + + select { + case err := <-done: + if err != nil { + t.Fatalf("Export: unexpected error: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("Export deadlocked") + } + + var gotErr, gotOk int + for range len(feed) { + res := <-results + if res.Record.Pathname == "/backup/container/rootfs/broken" { + if res.Err == nil { + t.Fatal("broken record: expected an Error result, got Ok") + } + gotErr++ + continue + } + if res.Err != nil { + t.Fatalf("record %q: unexpected error result: %v", res.Record.Pathname, res.Err) + } + gotOk++ + } + if gotErr != 1 || gotOk != 2 { + t.Fatalf("results: gotErr=%d gotOk=%d, want 1 and 2", gotErr, gotOk) + } + + tr := tar.NewReader(bytes.NewReader(sink.tarball.Bytes())) + var names []string + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + names = append(names, hdr.Name) + } + want := []string{"backup/index.yaml", "backup/container/rootfs/etc/hostname"} + if strings.Join(names, ",") != strings.Join(want, ",") { + t.Fatalf("entries: %v, want %v (broken record must not appear)", names, want) + } +} + +// TestExportShortReadDetected covers audit finding "Mineurs" #3: a record +// whose Reader yields fewer bytes than its declared FileInfo.Lsize must be +// reported as a clear, actionable error (naming the pathname and both byte +// counts) instead of silently producing a truncated tar entry that would +// only surface as a cryptic failure on a later WriteHeader call. +func TestExportShortReadDetected(t *testing.T) { + sink := &fakeSink{} + exp := newExporterWithSink("restored-6", sink) + + records := make(chan *connectors.Record) + results := make(chan *connectors.Result, 8) + done := make(chan error, 1) + go func() { done <- exp.Export(context.Background(), records, results) }() + + feed := []*connectors.Record{ + {Pathname: "/backup/container/rootfs/short", + FileInfo: objects.FileInfo{Lname: "short", Lsize: 10, Lmode: 0644}, + Reader: io.NopCloser(strings.NewReader("shrt"))}, // 4 bytes, declared 10 + } + for _, r := range feed { + records <- r + } + close(records) + + select { + case err := <-done: + if err == nil { + t.Fatal("Export: expected a non-nil error, got nil") + } + if !strings.Contains(err.Error(), "/backup/container/rootfs/short") || + !strings.Contains(err.Error(), "got 4 bytes") || + !strings.Contains(err.Error(), "want 10") { + t.Fatalf("Export error = %q, want it to mention the pathname, got count and want count", err) + } + case <-time.After(10 * time.Second): + t.Fatal("Export deadlocked") + } + + res := <-results + if res.Err == nil { + t.Fatal("short-read record: expected an Error result, got Ok") + } +} + +// TestExportReinjectsXattrIntoPAXRecords covers audit finding #2: an +// xattr record fed in protocol order (immediately after its owning file +// record, same Pathname - see incus.go's Export doc comment) must be +// folded into that file's tar header PAXRecords, byte-identical to the +// value carried by the record's Reader. +func TestExportReinjectsXattrIntoPAXRecords(t *testing.T) { + sink := &fakeSink{} + exp := newExporterWithSink("restored-4", sink) + + records := make(chan *connectors.Record) + results := make(chan *connectors.Result, 8) + done := make(chan error, 1) + go func() { done <- exp.Export(context.Background(), records, results) }() + + capability := "\x01\x00\x00\x02\x00\x20\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + feed := []*connectors.Record{ + {Pathname: "/backup/container/rootfs/bin/ping", + FileInfo: objects.FileInfo{Lname: "ping", Lsize: 4, Lmode: 0755}, + Reader: io.NopCloser(strings.NewReader("elf\n"))}, + {Pathname: "/backup/container/rootfs/bin/ping", + IsXattr: true, + XattrName: "security.capability", + XattrType: objects.AttributeExtended, + Reader: io.NopCloser(strings.NewReader(capability))}, + } + for _, r := range feed { + records <- r + } + close(records) + if err := <-done; err != nil { + t.Fatalf("Export: %v", err) + } + for range len(feed) { + <-results + } + + tr := tar.NewReader(bytes.NewReader(sink.tarball.Bytes())) + hdr, err := tr.Next() + if err != nil { + t.Fatal(err) + } + if hdr.Name != "backup/container/rootfs/bin/ping" { + t.Fatalf("entry name: %q", hdr.Name) + } + got, ok := hdr.PAXRecords["SCHILY.xattr.security.capability"] + if !ok { + t.Fatalf("PAXRecords missing SCHILY.xattr.security.capability: %+v", hdr.PAXRecords) + } + if got != capability { + t.Fatalf("xattr value: got %q, want %q", got, capability) + } + if _, err := tr.Next(); err != io.EOF { + t.Fatalf("want a single tar entry (xattr record must not appear as its own entry), got err=%v", err) + } +} + +// TestRoundtripXattrSurvivesImportExport is the end-to-end sanity check +// requested for audit finding #2: a tar with a PAX xattr, run through +// the importer to produce records, then those records run through the +// exporter, must reproduce the same PAXRecords in the rebuilt tar. +func TestRoundtripXattrSurvivesImportExport(t *testing.T) { + // Build the source tar the same way the importer test does, kept + // self-contained here to avoid an import-package dependency. + var srcBuf bytes.Buffer + stw := tar.NewWriter(&srcBuf) + capability := "\x01\x00\x00\x02\x00\x20\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + body := "#!/bin/true\n" + srcHdr := tar.Header{ + Name: "backup/container/rootfs/bin/ping", + Mode: 0755, + Typeflag: tar.TypeReg, + Size: int64(len(body)), + PAXRecords: map[string]string{ + "SCHILY.xattr.security.capability": capability, + }, + } + if err := stw.WriteHeader(&srcHdr); err != nil { + t.Fatal(err) + } + if _, err := stw.Write([]byte(body)); err != nil { + t.Fatal(err) + } + if err := stw.Close(); err != nil { + t.Fatal(err) + } + + // Read it back as plain tar records + PAX-derived xattr record, the + // same shape the incus importer produces (see importer/incus.go). + str := tar.NewReader(bytes.NewReader(srcBuf.Bytes())) + srcHdrRead, err := str.Next() + if err != nil { + t.Fatal(err) + } + bodyBytes, err := io.ReadAll(str) + if err != nil { + t.Fatal(err) + } + + sink := &fakeSink{} + exp := newExporterWithSink("restored-5", sink) + records := make(chan *connectors.Record) + results := make(chan *connectors.Result, 8) + done := make(chan error, 1) + go func() { done <- exp.Export(context.Background(), records, results) }() + + feed := []*connectors.Record{ + {Pathname: "/" + srcHdrRead.Name, + FileInfo: objects.FileInfo{Lname: "ping", Lsize: int64(len(bodyBytes)), Lmode: 0755}, + Reader: io.NopCloser(bytes.NewReader(bodyBytes))}, + {Pathname: "/" + srcHdrRead.Name, + IsXattr: true, + XattrName: "security.capability", + XattrType: objects.AttributeExtended, + Reader: io.NopCloser(strings.NewReader(srcHdrRead.PAXRecords["SCHILY.xattr.security.capability"]))}, + } + for _, r := range feed { + records <- r + } + close(records) + if err := <-done; err != nil { + t.Fatalf("Export: %v", err) + } + for range len(feed) { + <-results + } + + outTr := tar.NewReader(bytes.NewReader(sink.tarball.Bytes())) + outHdr, err := outTr.Next() + if err != nil { + t.Fatal(err) + } + if outHdr.PAXRecords["SCHILY.xattr.security.capability"] != capability { + t.Fatalf("roundtrip xattr value: got %q, want %q", + outHdr.PAXRecords["SCHILY.xattr.security.capability"], capability) + } +} diff --git a/incus/exporter/schema.json b/incus/exporter/schema.json new file mode 100644 index 0000000..54d6a85 --- /dev/null +++ b/incus/exporter/schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Incus exporter config", + "type": "object", + "additionalProperties": false, + "required": ["location"], + "properties": { + "location": { + "type": "string", + "minLength": 1, + "description": "Incus instance to create on restore: incus://", + "pattern": "^incus://[A-Za-z]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$" + }, + "socket": { + "type": "string", + "default": "/var/lib/incus/unix.socket", + "description": "Path to the Incus unix socket (mutually exclusive with url)" + }, + "url": { + "type": "string", + "pattern": "^https://.+", + "description": "Remote Incus server URL (https://host:8443); requires tls_client_cert and tls_client_key, mutually exclusive with socket" + }, + "tls_client_cert": { + "type": "string", + "minLength": 1, + "description": "Path to the PEM client certificate trusted by the remote server (remote only)" + }, + "tls_client_key": { + "type": "string", + "minLength": 1, + "description": "Path to the PEM client key matching tls_client_cert (remote only)" + }, + "tls_server_cert": { + "type": "string", + "minLength": 1, + "description": "Path to the remote server's PEM certificate to pin a self-signed server; without it the system CA store must trust the server (remote only)" + }, + "tls_ca": { + "type": "string", + "minLength": 1, + "description": "Path to the PEM CA certificate for servers running in PKI mode (remote only)" + }, + "pool": { + "type": "string", + "description": "Target storage pool (defaults to the pool referenced by the backup)" + }, + "project": { + "type": "string", + "minLength": 1, + "description": "Incus project to create the instance in (defaults to \"default\")" + }, + "target": { + "type": "string", + "minLength": 1, + "description": "Cluster member to create the instance on (clusters only; defaults to scheduler placement)" + } + } +} diff --git a/incus/go.mod b/incus/go.mod new file mode 100644 index 0000000..9b2f904 --- /dev/null +++ b/incus/go.mod @@ -0,0 +1,80 @@ +module github.com/PlakarKorp/integrations/incus + +go 1.25.6 + +require ( + github.com/PlakarKorp/go-kloset-sdk v1.1.0 + github.com/PlakarKorp/kloset v1.1.0 + github.com/lxc/incus/v6 v6.23.0 +) + +require ( + github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect + github.com/PlakarKorp/integration-grpc v1.1.0 // indirect + github.com/apex/log v1.9.0 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/pgzip v1.2.6 // indirect + github.com/kr/fs v0.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/nickball/go-aes-key-wrap v0.0.0-20170929221519-1c3aa3e4dfc5 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/opencontainers/runtime-spec v1.3.0 // indirect + github.com/opencontainers/umoci v0.6.1-0.20251213054154-70fc5ee1f4df // indirect + github.com/pierrec/lz4/v4 v4.1.27 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/sftp v1.13.10 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rootless-containers/proto/go-proto v0.0.0-20260207013450-f6ee952d53d9 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tink-crypto/tink-go/v2 v2.6.0 // indirect + github.com/urfave/cli v1.22.17 // indirect + github.com/vbatts/go-mtree v0.7.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + github.com/zitadel/logging v0.7.0 // indirect + github.com/zitadel/oidc/v3 v3.45.5 // indirect + github.com/zitadel/schema v1.3.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.51.0 // indirect +) diff --git a/incus/go.sum b/incus/go.sum new file mode 100644 index 0000000..bf26455 --- /dev/null +++ b/incus/go.sum @@ -0,0 +1,338 @@ +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= +github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/NickBall/go-aes-key-wrap v0.0.0-20170929221519-1c3aa3e4dfc5 h1:5BIUS5hwyLM298mOf8e8TEgD3cCYqc86uaJdQCYZo/o= +github.com/NickBall/go-aes-key-wrap v0.0.0-20170929221519-1c3aa3e4dfc5/go.mod h1:w5D10RxC0NmPYxmQ438CC1S07zaC1zpvuNW7s5sUk2Q= +github.com/PlakarKorp/go-cdc-chunkers v1.0.3 h1:6ozBFcNMHvGe6IsbPcAZUUEAExCgcNx3aa8xiCA6+Qw= +github.com/PlakarKorp/go-cdc-chunkers v1.0.3/go.mod h1:y7ag92JABKPBDoSOPwedssQ5NIOgjRm4Mu6yTBpmUMY= +github.com/PlakarKorp/go-kloset-sdk v1.1.0 h1:0wB66dyTwFp3Ky0TLAqehiOVwBDhQr3cTiitCXqQ2hg= +github.com/PlakarKorp/go-kloset-sdk v1.1.0/go.mod h1:AOdpltJ9LlbmnKxxrnh48vNttHa/dOQG0FVRzK4z0BQ= +github.com/PlakarKorp/integration-grpc v1.1.0 h1:ZUa/4m/pZWOmB7XzBfeAA/pOAxev3zbkVHrdi0tqtB4= +github.com/PlakarKorp/integration-grpc v1.1.0/go.mod h1:QZ55M9kXa3DntUvS9njOLoFY1S09bYbHKR/3kflDi5o= +github.com/PlakarKorp/kloset v1.1.0 h1:Yc76wQUbwkrLLx8ZR9XGaVf+s3dslIXKXH/V66rBAoM= +github.com/PlakarKorp/kloset v1.1.0/go.mod h1:t/KqLqbp4zHviF0pA4e63Gex/h5P2fdMTdwFBFQKHpg= +github.com/RaduBerinde/axisds v0.1.0 h1:YItk/RmU5nvlsv/awo2Fjx97Mfpt4JfgtEVAGPrLdz8= +github.com/RaduBerinde/axisds v0.1.0/go.mod h1:UHGJonU9z4YYGKJxSaC6/TNcLOBptpmM5m2Cksbnw0Y= +github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54 h1:bsU8Tzxr/PNz75ayvCnxKZWEYdLMPDkUgticP4a4Bvk= +github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54/go.mod h1:0tr7FllbE9gJkHq7CVeeDDFAFKQVy5RnCSSNBOvdqbc= +github.com/apex/log v1.9.0 h1:FHtw/xuaM8AgmvDDTI9fiwoAL25Sq2cxojnZICUU8l0= +github.com/apex/log v1.9.0/go.mod h1:m82fZlWIuiWzWP04XCTXmnX0xRkYYbCdYn8jbJeLBEA= +github.com/apex/logs v1.0.0/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo= +github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE= +github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys= +github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/crlib v0.0.0-20250110162118-b7c9be99e911 h1:X+r2Lb1qj0APqrxM8NhBD3X3JDM1Fe+u+WAvhvKuLdM= +github.com/cockroachdb/crlib v0.0.0-20250110162118-b7c9be99e911/go.mod h1:Gq51ZeKaFCXk6QwuGM0w1dnaOqc/F5zKT2zA9D6Xeac= +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= +github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 h1:ASDL+UJcILMqgNeV5jiqR4j+sTuvQNHdf2chuKj1M5k= +github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506/go.mod h1:Mw7HqKr2kdtu6aYGn3tPmAftiP3QPX63LdK/zcariIo= +github.com/cockroachdb/pebble/v2 v2.1.6 h1:GDo7Z2+LgFZ7LJLdLmBXhDeTVIwgSPGxIT15hE7vGqM= +github.com/cockroachdb/pebble/v2 v2.1.6/go.mod h1:Reo1RTniv1UjVTAu/Fv74y5i3kJ5gmVrPhO9UtFiKn8= +github.com/cockroachdb/redact v1.1.6 h1:zXJBwDZ84xJNlHl1rMyCojqyIxv+7YUpQiJLQ7n4314= +github.com/cockroachdb/redact v1.1.6/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b h1:VXvSNzmr8hMj8XTuY0PT9Ane9qZGul/p67vGYwl9BFI= +github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/getsentry/sentry-go v0.31.1 h1:ELVc0h7gwyhnXHDouXkhqTFSO5oslsRDk0++eyE0KJ4= +github.com/getsentry/sentry-go v0.31.1/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= +github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lxc/incus/v6 v6.23.0 h1:2/AONSg7XUkNTSRXqyP4jd2kYpafRvKctcCIXPsw1ws= +github.com/lxc/incus/v6 v6.23.0/go.mod h1:efEbxmSexfg8VyYQnBgNQz0dZZLci3s90xcU+VXoCYc= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882 h1:0lgqHvJWHLGW5TuObJrfyEi6+ASTKDBWikGvPqy9Yiw= +github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nickball/go-aes-key-wrap v0.0.0-20170929221519-1c3aa3e4dfc5 h1:eQr2od6dyd9gCLYHgMX2TlAYQtMUpxK7S0nsZXyH0L8= +github.com/nickball/go-aes-key-wrap v0.0.0-20170929221519-1c3aa3e4dfc5/go.mod h1:1VYCE0dvZM9Y2q8kcAHdXZB6YwfrCUQDeSJ2DuIiA4k= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg= +github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/umoci v0.6.1-0.20251213054154-70fc5ee1f4df h1:9hvwN64VeuL1L0Jgp8bxTPmd5IZQoHmeXGWrVqsEhN0= +github.com/opencontainers/umoci v0.6.1-0.20251213054154-70fc5ee1f4df/go.mod h1:s6d/s4QJAZTF92hEU6ozuHjE0+VRc6kVe1QIWfvL7KY= +github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= +github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rootless-containers/proto/go-proto v0.0.0-20260207013450-f6ee952d53d9 h1:3w2GInbYbp08pUeQoM3qI1L4v8htpwHQN9AkfILlUSw= +github.com/rootless-containers/proto/go-proto v0.0.0-20260207013450-f6ee952d53d9/go.mod h1:LLjEAc6zmycfeN7/1fxIphWQPjHpTt7ElqT7eVf8e4A= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= +github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= +github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4= +github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= +github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= +github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= +github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk= +github.com/tj/go-buffer v1.1.0/go.mod h1:iyiJpfFcR2B9sXu7KvjbT9fpM4mOelRSDTbntVj52Uc= +github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0= +github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao= +github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4= +github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= +github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= +github.com/vbatts/go-mtree v0.7.0 h1:ytmOc3MTRidZiBi9VBCyZ2BHe4fZS47L5v7BVXDWW4E= +github.com/vbatts/go-mtree v0.7.0/go.mod h1:EjdpFC+LZy1TXbRGNa1MKKgjQ+7ew3foMFJK8o4/TdY= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +github.com/zitadel/logging v0.7.0 h1:eugftwMM95Wgqwftsvj81isL0JK/hoScVqp/7iA2adQ= +github.com/zitadel/logging v0.7.0/go.mod h1:9A6h9feBF/3u0IhA4uffdzSDY7mBaf7RE78H5sFMINQ= +github.com/zitadel/oidc/v3 v3.45.5 h1:CubfcXQiqtysk+FZyIcvj1+1ayvdSV89v5xWu5asrDQ= +github.com/zitadel/oidc/v3 v3.45.5/go.mod h1:MKHUazeiNX/jxRc6HD/Dv9qhL/wNuzrJAadBEGXiBeE= +github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI= +github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U= +modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/incus/importer/finfo.go b/incus/importer/finfo.go new file mode 100644 index 0000000..a6068f3 --- /dev/null +++ b/incus/importer/finfo.go @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026 Antoine Dheygers + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +package importer + +import ( + "archive/tar" + "io/fs" + "path" + "strings" + + "github.com/PlakarKorp/kloset/objects" +) + +// finfo maps a tar header to a kloset FileInfo (same approach as the +// docker integration). +func finfo(hdr *tar.Header) objects.FileInfo { + // Raw tar mode bits carry setuid/setgid/sticky (0o4000/0o2000/0o1000) + // alongside the permission bits. Convert them to Go's abstract + // fs.ModeSetuid/Setgid/Sticky flags (the same conversion + // hdr.FileInfo().Mode() performs) so they survive round-tripping + // through connectors other than incus<->incus, which only + // understand fs.FileMode's portable flag bits, not raw tar bits. + mode := fs.FileMode(hdr.Mode) & fs.ModePerm + if hdr.Mode&0o4000 != 0 { + mode |= fs.ModeSetuid + } + if hdr.Mode&0o2000 != 0 { + mode |= fs.ModeSetgid + } + if hdr.Mode&0o1000 != 0 { + mode |= fs.ModeSticky + } + + f := objects.FileInfo{ + Lname: path.Base(path.Clean(hdr.Name)), + Lsize: hdr.Size, + Lmode: mode, + LmodTime: hdr.ModTime, + Luid: uint64(hdr.Uid), + Lgid: uint64(hdr.Gid), + Lnlink: 1, + } + + switch hdr.Typeflag { + case tar.TypeSymlink: + f.Lmode |= fs.ModeSymlink + case tar.TypeLink: + // hardlinks are mapped to symlinks: kloset has no hardlink + // notion. Lnlink>1 flags the entry as a hardlink so the + // exporter can tell it apart from a plain symlink and + // re-emit tar.TypeLink instead of TypeSymlink (see + // linkTarget below for how Target is normalized). + f.Lmode |= fs.ModeSymlink + f.Lnlink = 2 + case tar.TypeChar: + f.Lmode |= fs.ModeCharDevice + // objects.FileInfo has no dedicated rdev field: pack + // devmajor into the high 32 bits and devminor into the low + // 32 bits of Ldev. The exporter reverses this encoding. + f.Ldev = uint64(hdr.Devmajor)<<32 | uint64(hdr.Devminor)&0xffffffff + case tar.TypeBlock: + f.Lmode |= fs.ModeDevice + // Same Ldev encoding as tar.TypeChar above. + f.Ldev = uint64(hdr.Devmajor)<<32 | uint64(hdr.Devminor)&0xffffffff + case tar.TypeDir: + f.Lmode |= fs.ModeDir + case tar.TypeFifo: + f.Lmode |= fs.ModeNamedPipe + } + + return f +} + +// linkTarget returns the target to record for a symlink/hardlink tar +// entry. Plain symlinks keep hdr.Linkname verbatim: it is resolved +// relative to the entry's own directory at restore time. Hardlinks +// (tar.TypeLink) encode Linkname relative to the tar root instead +// (e.g. "backup/container/rootfs/bin/busybox"): recorded verbatim, a +// generic exporter (e.g. kloset's fs exporter) would materialize a +// symlink whose target never resolves. So the target is rewritten as +// the RELATIVE path from the link's parent directory to the linked +// file, computed in tar-name space. Any restore destination then +// produces a relative symlink that resolves correctly inside the +// restored tree, and the incus exporter can rebuild the +// tar-root-relative Linkname by resolving Target against the record's +// own directory. +func linkTarget(hdr *tar.Header) string { + if hdr.Typeflag == tar.TypeLink { + name := path.Join("/", hdr.Name) + link := path.Join("/", hdr.Linkname) + return relPath(path.Dir(name), link) + } + return hdr.Linkname +} + +// relPath computes the relative slash-path from directory fromDir to +// the path to. Both arguments are absolute slash-paths in tar-name +// space; this is deliberately pure string/slash logic, never OS +// filepath semantics. +func relPath(fromDir, to string) string { + from := splitPath(fromDir) + target := splitPath(to) + common := 0 + for common < len(from) && common < len(target) && from[common] == target[common] { + common++ + } + parts := make([]string, 0, len(from)-common+len(target)-common) + for range from[common:] { + parts = append(parts, "..") + } + parts = append(parts, target[common:]...) + return strings.Join(parts, "/") +} + +// splitPath cleans an absolute slash-path and splits it into its +// components; the root "/" yields nil. +func splitPath(p string) []string { + p = strings.TrimPrefix(path.Clean(p), "/") + if p == "" { + return nil + } + return strings.Split(p, "/") +} + +// recordPath normalizes a tar entry name into an absolute record path. +func recordPath(hdr *tar.Header) string { + return path.Join("/", hdr.Name) +} diff --git a/incus/importer/finfo_test.go b/incus/importer/finfo_test.go new file mode 100644 index 0000000..31fb95b --- /dev/null +++ b/incus/importer/finfo_test.go @@ -0,0 +1,131 @@ +package importer + +import ( + "archive/tar" + "io/fs" + "testing" + "time" +) + +func TestFinfoRegular(t *testing.T) { + hdr := &tar.Header{ + Name: "backup/container/rootfs/etc/hostname", + Size: 7, Mode: 0644, Uid: 0, Gid: 0, + ModTime: time.Unix(1750000000, 0), Typeflag: tar.TypeReg, + } + fi := finfo(hdr) + if fi.Lname != "hostname" || fi.Lsize != 7 || fi.Lmode != fs.FileMode(0644) { + t.Fatalf("bad fileinfo: %+v", fi) + } + if fi.Luid != 0 || fi.Lgid != 0 || !fi.LmodTime.Equal(time.Unix(1750000000, 0)) { + t.Fatalf("bad owner/time: %+v", fi) + } +} + +func TestFinfoSpecialBits(t *testing.T) { + hdr := &tar.Header{ + Name: "backup/container/rootfs/usr/bin/sudo", + Size: 3, Mode: 0o4755, Typeflag: tar.TypeReg, + } + fi := finfo(hdr) + want := fs.ModeSetuid | fs.FileMode(0o755) + if fi.Lmode != want { + t.Fatalf("setuid bit not converted: got mode %o (%v), want %o (%v)", fi.Lmode, fi.Lmode, want, want) + } +} + +func TestFinfoDir(t *testing.T) { + hdr := &tar.Header{Name: "backup/container/rootfs/etc/", Mode: 0755, Typeflag: tar.TypeDir} + if fi := finfo(hdr); fi.Lmode&fs.ModeDir == 0 { + t.Fatalf("dir flag missing: %v", fi.Lmode) + } +} + +func TestFinfoSymlink(t *testing.T) { + hdr := &tar.Header{Name: "backup/container/rootfs/bin", Linkname: "usr/bin", Typeflag: tar.TypeSymlink} + fi := finfo(hdr) + if fi.Lmode&fs.ModeSymlink == 0 { + t.Fatalf("symlink flag missing: %v", fi.Lmode) + } + if fi.Lnlink > 1 { + t.Fatalf("plain symlink must not be flagged as hardlink: Lnlink=%d", fi.Lnlink) + } + if got := linkTarget(hdr); got != "usr/bin" { + t.Fatalf("plain symlink target must stay untouched: got %q, want %q", got, "usr/bin") + } +} + +func TestFinfoHardlink(t *testing.T) { + hdr := &tar.Header{ + Typeflag: tar.TypeLink, + Name: "backup/container/rootfs/bin/ls", + Linkname: "backup/container/rootfs/bin/busybox", + } + fi := finfo(hdr) + if fi.Lmode&fs.ModeSymlink == 0 { + t.Fatalf("hardlink must still map to fs.ModeSymlink: %v", fi.Lmode) + } + if fi.Lnlink != 2 { + t.Fatalf("hardlink must be flagged via Lnlink=2, got %d", fi.Lnlink) + } + // Target must be relative to the link's own directory so that any + // generic exporter materializes a symlink resolving inside the + // restored tree. + if got := linkTarget(hdr); got != "busybox" { + t.Fatalf("hardlink target: got %q, want %q (relative to link dir)", got, "busybox") + } +} + +func TestFinfoHardlinkCrossDir(t *testing.T) { + hdr := &tar.Header{ + Typeflag: tar.TypeLink, + Name: "backup/container/rootfs/bin/ls", + Linkname: "backup/container/rootfs/sbin/tool", + } + want := "../sbin/tool" + if got := linkTarget(hdr); got != want { + t.Fatalf("cross-dir hardlink target: got %q, want %q", got, want) + } +} + +func TestRelPath(t *testing.T) { + cases := []struct { + fromDir, to, want string + }{ + {"/backup/container/rootfs/bin", "/backup/container/rootfs/bin/busybox", "busybox"}, + {"/backup/container/rootfs/bin", "/backup/container/rootfs/sbin/tool", "../sbin/tool"}, + {"/backup/container/rootfs/usr/bin", "/backup/container/rootfs/bin/sh", "../../bin/sh"}, + {"/", "/backup/index.yaml", "backup/index.yaml"}, + } + for _, c := range cases { + if got := relPath(c.fromDir, c.to); got != c.want { + t.Fatalf("relPath(%q, %q) = %q, want %q", c.fromDir, c.to, got, c.want) + } + } +} + +func TestFinfoDevice(t *testing.T) { + hdr := &tar.Header{ + Name: "backup/container/rootfs/dev/null", Typeflag: tar.TypeChar, + Mode: 0666, Devmajor: 1, Devminor: 3, + } + fi := finfo(hdr) + if fi.Lmode&fs.ModeCharDevice == 0 { + t.Fatalf("char device flag missing: %v", fi.Lmode) + } + wantDev := uint64(1)<<32 | 3 + if fi.Ldev != wantDev { + t.Fatalf("device major/minor: got Ldev=%#x, want %#x", fi.Ldev, wantDev) + } +} + +func TestRecordPath(t *testing.T) { + hdr := &tar.Header{Name: "backup/index.yaml"} + if p := recordPath(hdr); p != "/backup/index.yaml" { + t.Fatalf("got %q", p) + } + hdr = &tar.Header{Name: "./backup/container/rootfs/etc/"} + if p := recordPath(hdr); p != "/backup/container/rootfs/etc" { + t.Fatalf("got %q", p) + } +} diff --git a/incus/importer/incus.go b/incus/importer/incus.go new file mode 100644 index 0000000..1194582 --- /dev/null +++ b/incus/importer/incus.go @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2026 Antoine Dheygers + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +package importer + +import ( + "archive/tar" + "context" + "errors" + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/PlakarKorp/kloset/connectors" + "github.com/PlakarKorp/kloset/connectors/importer" + "github.com/PlakarKorp/kloset/location" + "github.com/PlakarKorp/kloset/objects" +) + +// Defaults for the configurable timeouts. The backup TTL is a safety +// net: should the plugin die without running its cleanup, the server +// expires the temporary backup on its own. The cleanup timeout bounds +// the wait on the server-side backup deletion (which has no caller +// context). A big instance on a slow server may need larger values. +const ( + defaultBackupTTL = 6 * time.Hour + defaultCleanupTimeout = 2 * time.Minute +) + +// durationOption reads a Go duration (e.g. "45m", "12h") from the +// config map, falling back to def when the key is absent. Zero and +// negative durations are rejected: they would make the backup expire +// immediately or disable the cleanup bound. +func durationOption(config map[string]string, key string, def time.Duration) (time.Duration, error) { + raw, ok := config[key] + if !ok { + return def, nil + } + d, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("incus: invalid %s %q: %w", key, raw, err) + } + if d <= 0 { + return 0, fmt.Errorf("incus: invalid %s %q: must be positive", key, raw) + } + return d, nil +} + +// paxXattrPrefix is how GNU/BSD tar encode a POSIX extended attribute +// (e.g. security.capability) as a PAX extended header record: the key +// is this prefix plus the xattr name, the value is the raw xattr +// value. archive/tar exposes these via hdr.PAXRecords (hdr.Xattrs is +// the older, deprecated "SCHILY.xattr." accessor and is not populated +// for records read this way in modern archive/tar; PAXRecords is the +// one to use). +const paxXattrPrefix = "SCHILY.xattr." + +// tar streaming is strictly sequential: each record must be acked +// before reading the next entry. +const flags = location.FLAG_STREAM | location.FLAG_NEEDACK + +func init() { + _ = importer.Register("incus", flags, NewImporter) +} + +// backupSource abstracts the Incus API so the import pipeline is +// testable without a running daemon. +type backupSource interface { + Ping(ctx context.Context) error + // Open creates a fresh portable backup of the instance and + // returns its tar stream plus a cleanup func deleting the + // server-side backup. + Open(ctx context.Context, instance string) (io.ReadCloser, func() error, error) + // Inspect returns the instance's type and its disk devices other + // than the root disk (custom volumes, host mounts): content the + // backup does NOT include (InstanceOnly:true). + Inspect(ctx context.Context, instance string) (instanceInfo, error) +} + +// instanceInfo is what Inspect reports from a single GetInstance call: +// the instance type gates VM refusal, the extra disks feed the +// exclusion warnings. +type instanceInfo struct { + Type string + ExtraDisks []string +} + +type Importer struct { + instance string + project string + src backupSource + stderr io.Writer +} + +func NewImporter(ctx context.Context, opts *connectors.Options, proto string, config map[string]string) (importer.Importer, error) { + instance := strings.TrimPrefix(config["location"], "incus://") + if instance == "" || strings.Contains(instance, "/") { + return nil, fmt.Errorf("incus: invalid instance name %q", instance) + } + backupTTL, err := durationOption(config, "backup_ttl", defaultBackupTTL) + if err != nil { + return nil, err + } + cleanupTimeout, err := durationOption(config, "cleanup_timeout", defaultCleanupTimeout) + if err != nil { + return nil, err + } + src, err := newIncusSource(config, backupTTL, cleanupTimeout) + if err != nil { + return nil, err + } + imp := newImporterWithSource(instance, src) + imp.project = config["project"] + if opts.Stderr != nil { + // Stderr is msgpack:"-": nil when the plugin runs out of + // process and Options crossed the wire. + imp.stderr = opts.Stderr + } + return imp, nil +} + +func newImporterWithSource(instance string, src backupSource) *Importer { + return &Importer{instance: instance, src: src, stderr: io.Discard} +} + +func (p *Importer) Type() string { return "incus" } +func (p *Importer) Root() string { return "/" } +func (p *Importer) Flags() location.Flags { return flags } + +func (p *Importer) Origin() string { + // The snapshot origin is the instance itself, not the node the + // plugin happens to run on: it names snapshots in listings/UI. + // Instance names are only unique within a project, so a non-default + // project qualifies the origin ("project/instance") to keep + // same-named instances from different projects distinguishable. + // Without the project option the origin is unchanged, so snapshots + // taken before this option existed keep their naming. + if p.project != "" { + return p.project + "/" + p.instance + } + return p.instance +} + +func (p *Importer) Ping(ctx context.Context) error { return p.src.Ping(ctx) } + +func (p *Importer) Import(ctx context.Context, records chan<- *connectors.Record, results <-chan *connectors.Result) error { + defer close(records) + + if err := p.preflight(ctx); err != nil { + return err + } + + stream, cleanup, err := p.src.Open(ctx, p.instance) + if err != nil { + return err + } + defer func() { + _ = stream.Close() + if cleanup != nil { + _ = cleanup() + } + }() + + tr := tar.NewReader(stream) + for { + hdr, err := tr.Next() + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + + select { + case records <- &connectors.Record{ + Pathname: recordPath(hdr), + Target: linkTarget(hdr), + FileInfo: finfo(hdr), + Reader: io.NopCloser(tr), + }: + case <-ctx.Done(): + return ctx.Err() + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-results: + // sequential stream: wait for the ack before advancing. + } + + if err := p.emitXattrs(ctx, hdr, records, results); err != nil { + return err + } + } +} + +// emitXattrs replays a tar entry's extended attributes as one kloset +// xattr Record per attribute, emitted right after the owning file +// record (same Pathname) and acked the same sequential way - mirroring +// the reference "fs" importer (plakar-integrations/fs/importer/walkdir.go), +// which emits its file record first and then one connectors.NewXattr +// per attribute. The incus exporter's tar rebuild relies on kloset +// replaying records in import order, so this ordering is load-bearing: +// an xattr record must arrive immediately after the file record it +// belongs to, not before and not batched separately. +func (p *Importer) emitXattrs(ctx context.Context, hdr *tar.Header, records chan<- *connectors.Record, results <-chan *connectors.Result) error { + if len(hdr.PAXRecords) == 0 { + return nil + } + + names := make([]string, 0, len(hdr.PAXRecords)) + for k := range hdr.PAXRecords { + if name, ok := strings.CutPrefix(k, paxXattrPrefix); ok && name != "" { + names = append(names, name) + } + } + if len(names) == 0 { + return nil + } + sort.Strings(names) // deterministic emission order; map iteration isn't + + path := recordPath(hdr) + for _, name := range names { + value := hdr.PAXRecords[paxXattrPrefix+name] + select { + case records <- &connectors.Record{ + Pathname: path, + IsXattr: true, + XattrName: name, + XattrType: objects.AttributeExtended, + Reader: io.NopCloser(strings.NewReader(value)), + }: + case <-ctx.Done(): + return ctx.Err() + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-results: + // sequential stream: wait for the ack before advancing. + } + } + return nil +} + +// preflight inspects the instance before any server-side backup is +// created: it refuses virtual machines (a VM export is one monolithic +// disk image inside the tar — per-file browsing and dedup are +// meaningless on it and the path is untested, TODO-PROD #5) and warns, +// on stderr, about attached disk devices (custom volumes, host mounts) +// the backup will NOT contain (InstanceOnly:true covers the root +// filesystem only). An empty type means a legacy server predating the +// Type field and is treated as a container. The inspection itself is +// best-effort: a failure must not fail the backup (any real +// connectivity problem resurfaces in Open), so it is reported as a +// note rather than returned. +func (p *Importer) preflight(ctx context.Context) error { + info, err := p.src.Inspect(ctx, p.instance) + if err != nil { + _, _ = fmt.Fprintf(p.stderr, "incus: note: could not inspect instance %s: %v\n", p.instance, err) + return nil + } + if info.Type != "" && info.Type != "container" { + return fmt.Errorf("incus: instance %s has type %q: only containers are supported (a %s backup would be a single monolithic disk image, with no per-file browsing or useful deduplication); use incus export for it instead", p.instance, info.Type, info.Type) + } + for _, name := range info.ExtraDisks { + _, _ = fmt.Fprintf(p.stderr, "incus: WARNING: disk device %q of instance %s is attached but its content is NOT included in this backup (instance root filesystem only)\n", name, p.instance) + } + return nil +} + +func (p *Importer) Close(ctx context.Context) error { return nil } + +// newIncusSource is implemented in incus_api.go (real Incus client). diff --git a/incus/importer/incus_api.go b/incus/importer/incus_api.go new file mode 100644 index 0000000..af738cb --- /dev/null +++ b/incus/importer/incus_api.go @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2026 Antoine Dheygers + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +package importer + +import ( + "context" + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/PlakarKorp/integrations/incus/internal/conn" + incus "github.com/lxc/incus/v6/client" + "github.com/lxc/incus/v6/shared/api" + "github.com/lxc/incus/v6/shared/cancel" +) + +type incusSource struct { + server incus.InstanceServer + // backupTTL is the ExpiresAt horizon of the temporary server-side + // backup; cleanupTimeout bounds the wait on its deletion. Both come + // from the config map (backup_ttl, cleanup_timeout), validated in + // NewImporter. + backupTTL time.Duration + cleanupTimeout time.Duration +} + +func newIncusSource(config map[string]string, backupTTL, cleanupTimeout time.Duration) (backupSource, error) { + // conn.Connect handles the transport (unix socket or HTTPS with + // client certificate) and the project scoping. + server, err := conn.Connect(config) + if err != nil { + return nil, err + } + return &incusSource{server: server, backupTTL: backupTTL, cleanupTimeout: cleanupTimeout}, nil +} + +func (s *incusSource) Ping(ctx context.Context) error { + // GetServer has no ctx-aware variant in the incus client (verified via + // `go doc`); a frozen daemon can therefore hang this call indefinitely. + // This is a limitation of the upstream client, not something callable + // from here. + _, _, err := s.server.GetServer() + return err +} + +func (s *incusSource) Inspect(ctx context.Context, instance string) (instanceInfo, error) { + // GetInstance has no ctx-aware variant either (same upstream + // limitation as GetServer above). + inst, _, err := s.server.GetInstance(instance) + if err != nil { + return instanceInfo{}, err + } + return instanceInfo{ + Type: inst.Type, + ExtraDisks: extraDiskDevices(inst.ExpandedDevices), + }, nil +} + +// extraDiskDevices returns the names of disk devices other than the +// root disk (path "/"). ExpandedDevices must be used rather than +// Devices: the root disk and attached volumes may come from a profile. +// Those devices are exactly what a backup with InstanceOnly:true does +// NOT include, hence the warning the importer emits from this list. +// +// Virtual disks whose content Incus regenerates from the instance +// config (cloud-init seed ISO, VM agent drive) carry no data to lose +// and would be pure warning noise, so they are skipped. +func extraDiskDevices(devices map[string]map[string]string) []string { + var names []string + for name, dev := range devices { + if dev["type"] != "disk" || dev["path"] == "/" { + continue + } + if dev["source"] == "cloud-init:config" || dev["source"] == "agent:config" { + continue + } + names = append(names, name) + } + sort.Strings(names) // deterministic warning order; map iteration isn't + return names +} + +func (s *incusSource) Open(ctx context.Context, instance string) (io.ReadCloser, func() error, error) { + backupName := fmt.Sprintf("plakar-%d", time.Now().UnixNano()) + + op, err := s.server.CreateInstanceBackup(instance, api.InstanceBackupsPost{ + Name: backupName, + ExpiresAt: time.Now().Add(s.backupTTL), + InstanceOnly: true, + OptimizedStorage: false, + CompressionAlgorithm: "none", + }) + if err != nil { + return nil, nil, fmt.Errorf("incus: create backup: %w", err) + } + cleanup := func() error { + delOp, err := s.server.DeleteInstanceBackup(instance, backupName) + if err != nil { + return err + } + // cleanup has no caller-supplied ctx (it runs from a deferred + // closure after Open returns), so a frozen daemon must not be + // allowed to hang it forever: bound the wait with our own timeout. + waitCtx, cancel := context.WithTimeout(context.Background(), s.cleanupTimeout) + defer cancel() + return delOp.WaitContext(waitCtx) + } + + if err := op.WaitContext(ctx); err != nil { + // The POST was accepted, so the server-side backup object may + // already exist even though we're erroring out (e.g. ctx was + // cancelled while waiting). Best-effort delete it now instead + // of relying solely on the ExpiresAt TTL; the contract is + // "error return => nil cleanup", so any delete failure here is + // deliberately swallowed. + _ = cleanup() + return nil, nil, wrapBackupError(instance, err) + } + + // The InstanceServer interface exposed by github.com/lxc/incus/v6/client + // has no raw DoHTTP escape hatch (verified via `go doc`), so the export + // endpoint can't be streamed directly from an *http.Response body as + // earlier drafted. Instead, GetInstanceBackupFile pumps the tarball into + // an io.WriteSeeker synchronously; bridge that to the io.ReadCloser this + // method must return via an io.Pipe, with the write side driven from a + // goroutine. + pr, pw := io.Pipe() + sink := &pipeWriteSeeker{w: pw} + + // GetInstanceBackupFile blocks on a synchronous HTTP GET with no ctx + // parameter of its own; wire ctx cancellation through the request's + // Canceler so a caller-side cancel (or a stalled daemon connection) + // actually interrupts the download instead of hanging the goroutine + // below forever. + canceller := cancel.NewHTTPRequestCanceller() + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = canceller.Cancel() + case <-done: + } + }() + + go func() { + _, err := s.server.GetInstanceBackupFile(instance, backupName, &incus.BackupFileRequest{ + BackupFile: sink, + Canceler: canceller, + }) + close(done) + // CloseWithError(nil) is equivalent to a plain Close: the reader + // observes a clean io.EOF once buffered data is drained. + _ = pw.CloseWithError(err) + }() + + return pr, cleanup, nil +} + +// wrapBackupError turns a failed server-side backup creation into an +// actionable error when the cause is exhausted disk space. Creating +// the backup is where Incus materializes the complete tarball on the +// server's own disk before we can stream it (see Open), so on a tight +// host this is the operation that hits ENOSPC first — and the raw +// Incus error says neither where that space is needed nor how to get +// more of it. Matching on the error text is the only option: the API +// returns a flat string, not a typed errno. +func wrapBackupError(instance string, err error) error { + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "no space left on device") || strings.Contains(msg, "disk quota exceeded") { + return fmt.Errorf("incus: backup %s: %w (Incus stages the full backup tarball on the server before streaming it: free up space under the server's backups path — /var/lib/incus/backups by default — or point the server config key storage.backups_volume at a storage pool with enough room, then retry)", instance, err) + } + return fmt.Errorf("incus: backup %s: %w", instance, err) +} + +// pipeWriteSeeker adapts an *io.PipeWriter to the io.WriteSeeker interface +// required by BackupFileRequest.BackupFile. The Incus client only ever +// queries the current write offset (Seek(0, io.SeekCurrent)) to report +// download progress; it never seeks backwards or forwards over a pipe, +// so any other seek is rejected. +type pipeWriteSeeker struct { + w *io.PipeWriter + offset int64 +} + +func (p *pipeWriteSeeker) Write(b []byte) (int, error) { + n, err := p.w.Write(b) + p.offset += int64(n) + return n, err +} + +func (p *pipeWriteSeeker) Seek(offset int64, whence int) (int64, error) { + if whence == io.SeekCurrent && offset == 0 { + return p.offset, nil + } + return 0, fmt.Errorf("incus: unsupported seek (offset=%d whence=%d)", offset, whence) +} diff --git a/incus/importer/incus_api_integration_test.go b/incus/importer/incus_api_integration_test.go new file mode 100644 index 0000000..2e32048 --- /dev/null +++ b/incus/importer/incus_api_integration_test.go @@ -0,0 +1,24 @@ +//go:build integration + +package importer + +import ( + "context" + "os" + "testing" + + "github.com/PlakarKorp/integrations/incus/internal/conn" +) + +func TestIncusSourcePing(t *testing.T) { + if _, err := os.Stat(conn.DefaultSocket); err != nil { + t.Skip("no incus socket on this machine") + } + src, err := newIncusSource(map[string]string{"socket": conn.DefaultSocket}, defaultBackupTTL, defaultCleanupTimeout) + if err != nil { + t.Skipf("incus socket present but unusable: %v", err) + } + if err := src.Ping(context.Background()); err != nil { + t.Skipf("incus daemon unreachable: %v", err) + } +} diff --git a/incus/importer/incus_api_test.go b/incus/importer/incus_api_test.go new file mode 100644 index 0000000..52eb7e1 --- /dev/null +++ b/incus/importer/incus_api_test.go @@ -0,0 +1,103 @@ +package importer + +import ( + "bytes" + "io" + "testing" +) + +// newTestPipeWriteSeeker wires a pipeWriteSeeker to an in-memory io.Reader +// via an io.Pipe, draining the reader in the background so Write calls on +// the sink never block. The returned closeAndWait func closes the pipe and +// blocks until the drain goroutine has copied everything into got, so the +// buffer is safe to inspect once it returns. +func newTestPipeWriteSeeker(t *testing.T) (sink *pipeWriteSeeker, got *bytes.Buffer, closeAndWait func()) { + t.Helper() + + pr, pw := io.Pipe() + sink = &pipeWriteSeeker{w: pw} + got = &bytes.Buffer{} + + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = io.Copy(got, pr) + }() + + closeAndWait = func() { + _ = pw.Close() + <-done + } + t.Cleanup(closeAndWait) + + return sink, got, closeAndWait +} + +func TestPipeWriteSeekerWriteAdvancesOffset(t *testing.T) { + sink, got, closeAndWait := newTestPipeWriteSeeker(t) + + n, err := sink.Write([]byte("hello")) + if err != nil { + t.Fatalf("Write: %v", err) + } + if n != 5 { + t.Fatalf("Write returned n=%d, want 5", n) + } + if sink.offset != 5 { + t.Fatalf("offset after first write = %d, want 5", sink.offset) + } + + n, err = sink.Write([]byte(" world")) + if err != nil { + t.Fatalf("Write: %v", err) + } + if n != 6 { + t.Fatalf("Write returned n=%d, want 6", n) + } + if sink.offset != 11 { + t.Fatalf("offset after second write = %d, want 11", sink.offset) + } + + closeAndWait() + if want := "hello world"; got.String() != want { + t.Fatalf("underlying writer got %q, want %q", got.String(), want) + } +} + +func TestPipeWriteSeekerSeekCurrentReturnsOffset(t *testing.T) { + sink, _, _ := newTestPipeWriteSeeker(t) + + if _, err := sink.Write([]byte("abcd")); err != nil { + t.Fatalf("Write: %v", err) + } + + off, err := sink.Seek(0, io.SeekCurrent) + if err != nil { + t.Fatalf("Seek(0, io.SeekCurrent): unexpected error: %v", err) + } + if off != 4 { + t.Fatalf("Seek(0, io.SeekCurrent) = %d, want 4", off) + } +} + +func TestPipeWriteSeekerRejectsUnsupportedSeeks(t *testing.T) { + cases := []struct { + name string + offset int64 + whence int + }{ + {"SeekStart zero", 0, io.SeekStart}, + {"SeekCurrent nonzero", 5, io.SeekCurrent}, + {"SeekEnd zero", 0, io.SeekEnd}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sink, _, _ := newTestPipeWriteSeeker(t) + + if _, err := sink.Seek(tc.offset, tc.whence); err == nil { + t.Fatalf("Seek(%d, %d) = nil error, want error", tc.offset, tc.whence) + } + }) + } +} diff --git a/incus/importer/incus_test.go b/incus/importer/incus_test.go new file mode 100644 index 0000000..a3823e3 --- /dev/null +++ b/incus/importer/incus_test.go @@ -0,0 +1,410 @@ +package importer + +import ( + "archive/tar" + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/PlakarKorp/kloset/connectors" + "github.com/PlakarKorp/kloset/objects" +) + +type fakeSource struct { + tarball []byte + cleaned bool + pinged bool + opened bool + instType string + extraDisks []string + inspectErr error +} + +func (f *fakeSource) Ping(ctx context.Context) error { f.pinged = true; return nil } + +func (f *fakeSource) Inspect(ctx context.Context, instance string) (instanceInfo, error) { + if f.inspectErr != nil { + return instanceInfo{}, f.inspectErr + } + return instanceInfo{Type: f.instType, ExtraDisks: f.extraDisks}, nil +} + +func (f *fakeSource) Open(ctx context.Context, instance string) (io.ReadCloser, func() error, error) { + f.opened = true + return io.NopCloser(bytes.NewReader(f.tarball)), func() error { f.cleaned = true; return nil }, nil +} + +// makeTar builds an incus-export-shaped tarball in memory. +func makeTar(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + entries := []struct { + hdr tar.Header + body string + }{ + {tar.Header{Name: "backup/index.yaml", Mode: 0644, Typeflag: tar.TypeReg, Size: 12}, "name: test\n_"}, + {tar.Header{Name: "backup/container/rootfs/etc/", Mode: 0755, Typeflag: tar.TypeDir}, ""}, + {tar.Header{Name: "backup/container/rootfs/etc/hostname", Mode: 0644, Typeflag: tar.TypeReg, Size: 5}, "test\n"}, + {tar.Header{Name: "backup/container/rootfs/bin", Linkname: "usr/bin", Typeflag: tar.TypeSymlink}, ""}, + } + for _, e := range entries { + if err := tw.WriteHeader(&e.hdr); err != nil { + t.Fatal(err) + } + if e.body != "" { + if _, err := tw.Write([]byte(e.body)); err != nil { + t.Fatal(err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestImportEmitsRecords(t *testing.T) { + src := &fakeSource{tarball: makeTar(t)} + imp := newImporterWithSource("test", src) + + records := make(chan *connectors.Record) + results := make(chan *connectors.Result) + done := make(chan error, 1) + go func() { done <- imp.Import(context.Background(), records, results) }() + + var got []*connectors.Record + var contents []string + for rec := range records { + got = append(got, rec) + if rec.Reader != nil && rec.FileInfo.Lmode.IsRegular() { + b, err := io.ReadAll(rec.Reader) + if err != nil { + t.Fatal(err) + } + contents = append(contents, string(b)) + } + results <- rec.Ok() // ack, comme le fait kloset + } + if err := <-done; err != nil { + t.Fatalf("Import: %v", err) + } + + if len(got) != 4 { + t.Fatalf("want 4 records, got %d", len(got)) + } + if got[0].Pathname != "/backup/index.yaml" { + t.Fatalf("first record: %q", got[0].Pathname) + } + if got[3].Target != "usr/bin" { + t.Fatalf("symlink target: %q", got[3].Target) + } + if len(contents) != 2 || contents[1] != "test\n" { + t.Fatalf("contents: %q", contents) + } + if !src.cleaned { + t.Fatal("incus backup not cleaned up") + } +} + +// drainImport runs Import to completion, acking every record, and +// returns Import's error. +func drainImport(t *testing.T, imp *Importer) error { + t.Helper() + records := make(chan *connectors.Record) + results := make(chan *connectors.Result) + done := make(chan error, 1) + go func() { done <- imp.Import(context.Background(), records, results) }() + for rec := range records { + results <- rec.Ok() + } + return <-done +} + +// TestImportWarnsAboutExtraDisks covers TODO-PROD #4: attached disk +// devices beyond the root filesystem (custom volumes, host mounts) are +// excluded by InstanceOnly:true, so the backup must say so loudly. +func TestImportWarnsAboutExtraDisks(t *testing.T) { + src := &fakeSource{tarball: makeTar(t), extraDisks: []string{"data", "logs"}} + imp := newImporterWithSource("test", src) + var stderr bytes.Buffer + imp.stderr = &stderr + + if err := drainImport(t, imp); err != nil { + t.Fatalf("Import: %v", err) + } + + out := stderr.String() + for _, want := range []string{`disk device "data"`, `disk device "logs"`, "NOT included"} { + if !strings.Contains(out, want) { + t.Fatalf("stderr missing %q:\n%s", want, out) + } + } +} + +// A detection failure must not fail the backup, only leave a note. +func TestImportExtraDiskDetectionFailureIsNonFatal(t *testing.T) { + src := &fakeSource{tarball: makeTar(t), inspectErr: errors.New("boom")} + imp := newImporterWithSource("test", src) + var stderr bytes.Buffer + imp.stderr = &stderr + + if err := drainImport(t, imp); err != nil { + t.Fatalf("Import must succeed despite detection failure: %v", err) + } + if !strings.Contains(stderr.String(), "could not inspect instance") { + t.Fatalf("stderr missing detection-failure note:\n%s", stderr.String()) + } + if !src.cleaned { + t.Fatal("incus backup not cleaned up") + } +} + +// TestImportRefusesVirtualMachine covers TODO-PROD #5: a VM export is a +// single monolithic disk image inside the tar — per-file browsing and +// dedup are meaningless on it and the path is untested — so the importer +// must refuse VMs with a clear error before creating any server-side +// backup, instead of silently producing a useless snapshot. +func TestImportRefusesVirtualMachine(t *testing.T) { + src := &fakeSource{tarball: makeTar(t), instType: "virtual-machine"} + imp := newImporterWithSource("vm-1", src) + + err := drainImport(t, imp) + if err == nil { + t.Fatal("Import of a virtual-machine must fail") + } + for _, want := range []string{"vm-1", "virtual-machine", "containers"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error must mention %q, got: %v", want, err) + } + } + if src.opened { + t.Fatal("no server-side backup must be created for a refused VM") + } +} + +// Explicit "container" and empty type (legacy servers predating the +// Type field) must both pass the VM gate. +func TestImportAcceptsContainerTypes(t *testing.T) { + for _, typ := range []string{"container", ""} { + src := &fakeSource{tarball: makeTar(t), instType: typ} + imp := newImporterWithSource("test", src) + if err := drainImport(t, imp); err != nil { + t.Fatalf("type %q: Import: %v", typ, err) + } + } +} + +func TestExtraDiskDevices(t *testing.T) { + devices := map[string]map[string]string{ + "root": {"type": "disk", "path": "/", "pool": "default"}, + "data": {"type": "disk", "path": "/srv/data", "pool": "default", "source": "vol-data"}, + "cache": {"type": "disk", "path": "/var/cache", "source": "/host/cache"}, + "eth0": {"type": "nic", "network": "incusbr0"}, + // Regenerated from config at start: no data to lose, must + // not be warned about (TODO-PROD #4b). + "cloud-init": {"type": "disk", "source": "cloud-init:config"}, + "agent": {"type": "disk", "source": "agent:config"}, + } + got := extraDiskDevices(devices) + if len(got) != 2 || got[0] != "cache" || got[1] != "data" { + t.Fatalf("extraDiskDevices: %v", got) + } + if got := extraDiskDevices(nil); len(got) != 0 { + t.Fatalf("nil devices: %v", got) + } +} + +// TestOriginQualifiedByProject covers TODO-PROD #3b: instance names are +// only unique within a project, so a non-default project must qualify +// the snapshot origin; without a project the origin stays the bare +// instance name (existing snapshots keep their naming). +func TestOriginQualifiedByProject(t *testing.T) { + imp := newImporterWithSource("web-1", &fakeSource{}) + if got := imp.Origin(); got != "web-1" { + t.Fatalf("origin without project: got %q, want %q", got, "web-1") + } + imp.project = "customer-a" + if got := imp.Origin(); got != "customer-a/web-1" { + t.Fatalf("origin with project: got %q, want %q", got, "customer-a/web-1") + } +} + +// TestWrapBackupError covers TODO-PROD #6: CreateInstanceBackup +// materializes the whole tarball on the server before we can stream +// it, so a "no space left" failure there must come back with guidance +// (where the space is needed, how to move it), not just the raw Incus +// error. Any other failure must stay untouched: no misleading disk +// advice on unrelated errors. +func TestWrapBackupError(t *testing.T) { + for _, raw := range []string{ + "write /var/lib/incus/backups/instances/web-1: no space left on device", + "Failed to write backup file: Disk quota exceeded", + } { + base := errors.New(raw) + err := wrapBackupError("web-1", base) + if !errors.Is(err, base) { + t.Fatalf("%q: must wrap the original error, got %v", raw, err) + } + for _, want := range []string{"web-1", "storage.backups_volume", "/var/lib/incus/backups"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("%q: enriched error must mention %q, got %v", raw, want, err) + } + } + } + + plain := errors.New("Instance not found") + err := wrapBackupError("web-1", plain) + if !errors.Is(err, plain) { + t.Fatalf("must wrap the original error, got %v", err) + } + if strings.Contains(err.Error(), "storage.backups_volume") { + t.Fatalf("unrelated error must not get disk-space advice, got %v", err) + } + if !strings.Contains(err.Error(), "web-1") { + t.Fatalf("error must name the instance, got %v", err) + } +} + +// TestDurationOption covers TODO-PROD #12: the backup TTL and cleanup +// timeout must be configurable, with the historical hardcoded values as +// defaults, and garbage or non-positive durations rejected up front. +func TestDurationOption(t *testing.T) { + def := 6 * time.Hour + + got, err := durationOption(map[string]string{}, "backup_ttl", def) + if err != nil || got != def { + t.Fatalf("absent key: got %v, %v; want %v, nil", got, err, def) + } + + got, err = durationOption(map[string]string{"backup_ttl": "30m"}, "backup_ttl", def) + if err != nil || got != 30*time.Minute { + t.Fatalf("valid value: got %v, %v; want 30m, nil", got, err) + } + + for _, bad := range []string{"bogus", "0", "-1h", "0s"} { + if _, err := durationOption(map[string]string{"backup_ttl": bad}, "backup_ttl", def); err == nil { + t.Fatalf("value %q: want error, got nil", bad) + } else if !strings.Contains(err.Error(), "backup_ttl") { + t.Fatalf("value %q: error must name the option, got %v", bad, err) + } + } +} + +// A bad duration must fail NewImporter before any daemon connection is +// attempted, so the user sees the config error rather than a dial error. +func TestNewImporterRejectsInvalidTimeouts(t *testing.T) { + for _, cfg := range []map[string]string{ + {"location": "incus://web", "backup_ttl": "bogus"}, + {"location": "incus://web", "cleanup_timeout": "-1m"}, + } { + if _, err := NewImporter(context.Background(), &connectors.Options{}, "incus", cfg); err == nil { + t.Fatalf("config %v: want error, got nil", cfg) + } + } +} + +// A broken remote config (url without the client certificate pair, or +// TLS options without url) must fail NewImporter with the config error, +// before any connection is attempted. +func TestNewImporterRejectsBadRemoteConfig(t *testing.T) { + for _, cfg := range []map[string]string{ + {"location": "incus://web", "url": "https://incus.example:8443"}, + {"location": "incus://web", "tls_client_cert": "/some/cert.pem"}, + } { + if _, err := NewImporter(context.Background(), &connectors.Options{}, "incus", cfg); err == nil { + t.Fatalf("config %v: want error, got nil", cfg) + } else if !strings.Contains(err.Error(), "tls_client_cert") { + t.Fatalf("config %v: error should name the tls option, got %v", cfg, err) + } + } +} + +// makeTarWithXattr builds a single-file tarball whose entry carries a +// binary-ish PAX xattr record, as GNU/BSD tar would encode +// security.capability. +func makeTarWithXattr(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + body := "#!/bin/true\n" + hdr := tar.Header{ + Name: "backup/container/rootfs/bin/ping", + Mode: 0755, + Typeflag: tar.TypeReg, + Size: int64(len(body)), + PAXRecords: map[string]string{ + "SCHILY.xattr.security.capability": "\x01\x00\x00\x02\x00\x20\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + }, + } + if err := tw.WriteHeader(&hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(body)); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// TestImportEmitsXattrRecord covers audit finding #2: a PAX +// "SCHILY.xattr.*" record on a tar entry must be replayed as its own +// kloset xattr Record, immediately after the owning file record and +// sharing its Pathname (see emitXattrs's doc comment for why this +// order is load-bearing). +func TestImportEmitsXattrRecord(t *testing.T) { + src := &fakeSource{tarball: makeTarWithXattr(t)} + imp := newImporterWithSource("test", src) + + records := make(chan *connectors.Record) + results := make(chan *connectors.Result) + done := make(chan error, 1) + go func() { done <- imp.Import(context.Background(), records, results) }() + + var got []*connectors.Record + for rec := range records { + got = append(got, rec) + results <- rec.Ok() + } + if err := <-done; err != nil { + t.Fatalf("Import: %v", err) + } + + if len(got) != 2 { + t.Fatalf("want 2 records (file + xattr), got %d", len(got)) + } + + file, xattr := got[0], got[1] + if file.IsXattr { + t.Fatalf("first record must be the file record, got IsXattr=true: %+v", file) + } + if !xattr.IsXattr { + t.Fatalf("second record must be the xattr record, got IsXattr=false: %+v", xattr) + } + if xattr.Pathname != file.Pathname { + t.Fatalf("xattr record pathname %q != file record pathname %q", xattr.Pathname, file.Pathname) + } + if want := "/backup/container/rootfs/bin/ping"; file.Pathname != want { + t.Fatalf("file pathname: got %q, want %q", file.Pathname, want) + } + if xattr.XattrName != "security.capability" { + t.Fatalf("xattr name: got %q, want %q", xattr.XattrName, "security.capability") + } + if xattr.XattrType != objects.AttributeExtended { + t.Fatalf("xattr type: got %v, want AttributeExtended", xattr.XattrType) + } + value, err := io.ReadAll(xattr.Reader) + if err != nil { + t.Fatal(err) + } + want := "\x01\x00\x00\x02\x00\x20\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + if string(value) != want { + t.Fatalf("xattr value: got %q, want %q", value, want) + } +} diff --git a/incus/importer/schema.json b/incus/importer/schema.json new file mode 100644 index 0000000..8f185c5 --- /dev/null +++ b/incus/importer/schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Incus importer config", + "type": "object", + "additionalProperties": false, + "required": ["location"], + "properties": { + "location": { + "type": "string", + "minLength": 1, + "description": "Incus instance to back up: incus://", + "pattern": "^incus://[A-Za-z]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$" + }, + "socket": { + "type": "string", + "default": "/var/lib/incus/unix.socket", + "description": "Path to the Incus unix socket (mutually exclusive with url)" + }, + "url": { + "type": "string", + "pattern": "^https://.+", + "description": "Remote Incus server URL (https://host:8443); requires tls_client_cert and tls_client_key, mutually exclusive with socket" + }, + "tls_client_cert": { + "type": "string", + "minLength": 1, + "description": "Path to the PEM client certificate trusted by the remote server (remote only)" + }, + "tls_client_key": { + "type": "string", + "minLength": 1, + "description": "Path to the PEM client key matching tls_client_cert (remote only)" + }, + "tls_server_cert": { + "type": "string", + "minLength": 1, + "description": "Path to the remote server's PEM certificate to pin a self-signed server; without it the system CA store must trust the server (remote only)" + }, + "tls_ca": { + "type": "string", + "minLength": 1, + "description": "Path to the PEM CA certificate for servers running in PKI mode (remote only)" + }, + "project": { + "type": "string", + "minLength": 1, + "description": "Incus project the instance lives in (defaults to \"default\")" + }, + "backup_ttl": { + "type": "string", + "default": "6h", + "description": "Expiry (Go duration) of the temporary server-side backup, a safety net if cleanup never runs", + "pattern": "^([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$" + }, + "cleanup_timeout": { + "type": "string", + "default": "2m", + "description": "Maximum wait (Go duration) for the server-side backup deletion after the export", + "pattern": "^([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$" + } + } +} diff --git a/incus/internal/conn/conn.go b/incus/internal/conn/conn.go new file mode 100644 index 0000000..f87c89f --- /dev/null +++ b/incus/internal/conn/conn.go @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2026 Antoine Dheygers + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +// Package conn dials the Incus daemon for both the importer and the +// exporter: over the local unix socket by default, or over HTTPS with +// TLS client-certificate authentication when the "url" option is set. +package conn + +import ( + "crypto/tls" + "errors" + "fmt" + "os" + "strings" + + incus "github.com/lxc/incus/v6/client" +) + +// DefaultSocket is where the Incus daemon listens locally. +const DefaultSocket = "/var/lib/incus/unix.socket" + +// tlsOptions are the config keys that only make sense together with +// "url": on the unix-socket path they would be silently ignored, which +// hides a config mistake, so Connect rejects them instead. +var tlsOptions = []string{"tls_client_cert", "tls_client_key", "tls_server_cert", "tls_ca"} + +// Connect returns an InstanceServer for the daemon described by the +// config map, scoped to config["project"] when non-empty. All option +// validation happens before any dial attempt. +func Connect(config map[string]string) (incus.InstanceServer, error) { + var server incus.InstanceServer + if url := config["url"]; url != "" { + args, err := remoteArgs(config) + if err != nil { + return nil, err + } + server, err = incus.ConnectIncus(url, args) + if err != nil { + return nil, fmt.Errorf("incus: connect %s: %w", url, err) + } + } else { + for _, key := range tlsOptions { + if config[key] != "" { + return nil, fmt.Errorf("incus: option %s is only valid together with url", key) + } + } + socket := config["socket"] + if socket == "" { + socket = DefaultSocket + } + var err error + server, err = incus.ConnectIncusUnix(socket, nil) + if err != nil { + return nil, fmt.Errorf("incus: connect %s: %w", socket, err) + } + } + if project := config["project"]; project != "" { + // Scope every subsequent API call to the given Incus project; + // without this only the "default" project is visible. + server = server.UseProject(project) + } + return server, nil +} + +// remoteArgs validates the remote-connection options and loads the PEM +// material referenced by the tls_* paths. The client certificate/key +// pair is checked here so a broken pair fails with the option names +// rather than as an opaque TLS handshake error later. +func remoteArgs(config map[string]string) (*incus.ConnectionArgs, error) { + url := config["url"] + if !strings.HasPrefix(url, "https://") { + return nil, fmt.Errorf("incus: invalid url %q: must start with https://", url) + } + if config["socket"] != "" { + return nil, errors.New(`incus: options "socket" and "url" are mutually exclusive`) + } + if config["tls_client_cert"] == "" || config["tls_client_key"] == "" { + return nil, errors.New("incus: url requires both tls_client_cert and tls_client_key (paths to the PEM client certificate and key trusted by the remote daemon)") + } + cert, err := readPEM(config, "tls_client_cert") + if err != nil { + return nil, err + } + key, err := readPEM(config, "tls_client_key") + if err != nil { + return nil, err + } + if _, err := tls.X509KeyPair(cert, key); err != nil { + return nil, fmt.Errorf("incus: tls_client_cert/tls_client_key do not form a valid pair: %w", err) + } + args := &incus.ConnectionArgs{ + TLSClientCert: string(cert), + TLSClientKey: string(key), + } + // Optional: pin the (usually self-signed) server certificate; without + // it the system CA store must trust the server. + if serverCert, err := readPEM(config, "tls_server_cert"); err != nil { + return nil, err + } else if serverCert != nil { + args.TLSServerCert = string(serverCert) + } + // Optional: CA certificate for daemons running in PKI mode. + if ca, err := readPEM(config, "tls_ca"); err != nil { + return nil, err + } else if ca != nil { + args.TLSCA = string(ca) + } + return args, nil +} + +// readPEM reads the file referenced by the given config key, or returns +// (nil, nil) when the key is absent. +func readPEM(config map[string]string, key string) ([]byte, error) { + path, ok := config[key] + if !ok || path == "" { + return nil, nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("incus: %s: %w", key, err) + } + return data, nil +} diff --git a/incus/internal/conn/conn_test.go b/incus/internal/conn/conn_test.go new file mode 100644 index 0000000..f6e0815 --- /dev/null +++ b/incus/internal/conn/conn_test.go @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2026 Antoine Dheygers + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +package conn + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// writeCertPair writes a fresh self-signed certificate and key as PEM +// files under dir and returns their paths. +func writeCertPair(t *testing.T, dir, prefix string) (certPath, keyPath string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "plakar-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + certPath = filepath.Join(dir, prefix+"-cert.pem") + keyPath = filepath.Join(dir, prefix+"-key.pem") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(certPath, certPEM, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + t.Fatal(err) + } + return certPath, keyPath +} + +func TestRemoteArgs(t *testing.T) { + dir := t.TempDir() + certPath, keyPath := writeCertPair(t, dir, "client") + + args, err := remoteArgs(map[string]string{ + "url": "https://incus.example:8443", + "tls_client_cert": certPath, + "tls_client_key": keyPath, + }) + if err != nil { + t.Fatal(err) + } + wantCert, _ := os.ReadFile(certPath) + wantKey, _ := os.ReadFile(keyPath) + if args.TLSClientCert != string(wantCert) { + t.Error("TLSClientCert is not the certificate file content") + } + if args.TLSClientKey != string(wantKey) { + t.Error("TLSClientKey is not the key file content") + } + if args.TLSServerCert != "" || args.TLSCA != "" { + t.Error("server cert / CA should be empty when not configured") + } +} + +func TestRemoteArgsServerCertAndCA(t *testing.T) { + dir := t.TempDir() + certPath, keyPath := writeCertPair(t, dir, "client") + serverCertPath, _ := writeCertPair(t, dir, "server") + caPath, _ := writeCertPair(t, dir, "ca") + + args, err := remoteArgs(map[string]string{ + "url": "https://incus.example:8443", + "tls_client_cert": certPath, + "tls_client_key": keyPath, + "tls_server_cert": serverCertPath, + "tls_ca": caPath, + }) + if err != nil { + t.Fatal(err) + } + wantServer, _ := os.ReadFile(serverCertPath) + wantCA, _ := os.ReadFile(caPath) + if args.TLSServerCert != string(wantServer) { + t.Error("TLSServerCert is not the file content") + } + if args.TLSCA != string(wantCA) { + t.Error("TLSCA is not the file content") + } +} + +func TestRemoteArgsErrors(t *testing.T) { + dir := t.TempDir() + certPath, keyPath := writeCertPair(t, dir, "client") + otherCert, _ := writeCertPair(t, dir, "other") + + cases := []struct { + name string + config map[string]string + wantSub string + }{ + { + "http scheme rejected", + map[string]string{"url": "http://incus.example:8443", "tls_client_cert": certPath, "tls_client_key": keyPath}, + "https://", + }, + { + "socket and url exclusive", + map[string]string{"url": "https://incus.example:8443", "socket": "/run/incus.sock", "tls_client_cert": certPath, "tls_client_key": keyPath}, + "mutually exclusive", + }, + { + "missing client cert", + map[string]string{"url": "https://incus.example:8443", "tls_client_key": keyPath}, + "tls_client_cert", + }, + { + "missing client key", + map[string]string{"url": "https://incus.example:8443", "tls_client_cert": certPath}, + "tls_client_key", + }, + { + "unreadable cert file", + map[string]string{"url": "https://incus.example:8443", "tls_client_cert": filepath.Join(dir, "nope.pem"), "tls_client_key": keyPath}, + "nope.pem", + }, + { + "mismatched cert/key pair", + map[string]string{"url": "https://incus.example:8443", "tls_client_cert": otherCert, "tls_client_key": keyPath}, + "tls_client_cert", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := remoteArgs(tc.config); err == nil { + t.Fatalf("expected error for %v", tc.config) + } else if !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("error %q does not mention %q", err, tc.wantSub) + } + }) + } +} + +// TLS options without url are a config mistake (they would be silently +// ignored on the unix-socket path): Connect must reject them before any +// dial attempt. +func TestConnectRejectsTLSOptionsWithoutURL(t *testing.T) { + for _, key := range []string{"tls_client_cert", "tls_client_key", "tls_server_cert", "tls_ca"} { + t.Run(key, func(t *testing.T) { + _, err := Connect(map[string]string{key: "/some/path.pem", "socket": "/nonexistent.sock"}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), key) || !strings.Contains(err.Error(), "url") { + t.Fatalf("error %q should name the option %q and mention url", err, key) + } + }) + } +} + +func TestConnectUnixDialError(t *testing.T) { + sock := filepath.Join(t.TempDir(), "absent.sock") + _, err := Connect(map[string]string{"socket": sock}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), sock) { + t.Fatalf("error %q should mention the socket path", err) + } +} + +// End-to-end remote connect against a fake Incus API endpoint: HTTPS, +// server certificate pinned via tls_server_cert, client certificate +// supplied. ConnectIncus performs a GET /1.0 on connect, so a successful +// Connect proves the whole TLS plumbing. +func TestConnectRemote(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/1.0") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": "sync", + "status": "Success", + "status_code": 200, + "metadata": map[string]any{ + "api_version": "1.0", + "auth": "trusted", + }, + }) + })) + defer ts.Close() + + dir := t.TempDir() + certPath, keyPath := writeCertPair(t, dir, "client") + serverCertPath := filepath.Join(dir, "server-cert.pem") + serverPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ts.Certificate().Raw}) + if err := os.WriteFile(serverCertPath, serverPEM, 0o600); err != nil { + t.Fatal(err) + } + + server, err := Connect(map[string]string{ + "url": ts.URL, + "tls_client_cert": certPath, + "tls_client_key": keyPath, + "tls_server_cert": serverCertPath, + "project": "prod", + }) + if err != nil { + t.Fatal(err) + } + if server == nil { + t.Fatal("nil server") + } + // Without the pinned server certificate the self-signed test server + // must be rejected by standard CA verification. + if _, err := Connect(map[string]string{ + "url": ts.URL, + "tls_client_cert": certPath, + "tls_client_key": keyPath, + }); err == nil { + t.Fatal("expected TLS verification failure without tls_server_cert") + } +} diff --git a/incus/manifest.yaml b/incus/manifest.yaml new file mode 100644 index 0000000..a9510d9 --- /dev/null +++ b/incus/manifest.yaml @@ -0,0 +1,21 @@ +name: incus +display_name: Incus +description: Backup and restore Incus containers as browsable file-level snapshots. +api_version: v1.1.0 +tier: community +contact: mailto:antoine.dheygers@cryptoweb.fr +connectors: +- type: importer + executable: incusImporter + homepage: https://github.com/PlakarKorp/integrations/tree/main/incus + license: ISC + protocols: [incus] + validator: ./importer/schema.json + class: compute +- type: exporter + executable: incusExporter + homepage: https://github.com/PlakarKorp/integrations/tree/main/incus + license: ISC + protocols: [incus] + validator: ./exporter/schema.json + class: compute diff --git a/incus/plugin/exporter/main.go b/incus/plugin/exporter/main.go new file mode 100644 index 0000000..d99d9ae --- /dev/null +++ b/incus/plugin/exporter/main.go @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Antoine Dheygers + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +package main + +import ( + "os" + + sdk "github.com/PlakarKorp/go-kloset-sdk" + "github.com/PlakarKorp/integrations/incus/exporter" +) + +func main() { + sdk.EntrypointExporter(os.Args, exporter.NewExporter) +} diff --git a/incus/plugin/importer/main.go b/incus/plugin/importer/main.go new file mode 100644 index 0000000..18af74f --- /dev/null +++ b/incus/plugin/importer/main.go @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Antoine Dheygers + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +package main + +import ( + "os" + + sdk "github.com/PlakarKorp/go-kloset-sdk" + "github.com/PlakarKorp/integrations/incus/importer" +) + +func main() { + sdk.EntrypointImporter(os.Args, importer.NewImporter) +}