From ccc177a13461368c0fadde08235c6e2eeba207a8 Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Wed, 8 Jul 2026 14:49:37 +0200 Subject: [PATCH 01/22] incus: scaffold integration module --- incus/.gitignore | 3 +++ incus/LICENSE | 15 +++++++++++++++ incus/Makefile | 22 ++++++++++++++++++++++ incus/README.md | 23 +++++++++++++++++++++++ incus/exporter/schema.json | 24 ++++++++++++++++++++++++ incus/go.mod | 3 +++ incus/importer/schema.json | 20 ++++++++++++++++++++ incus/manifest.yaml | 21 +++++++++++++++++++++ 8 files changed, 131 insertions(+) create mode 100644 incus/.gitignore create mode 100644 incus/LICENSE create mode 100644 incus/Makefile create mode 100644 incus/README.md create mode 100644 incus/exporter/schema.json create mode 100644 incus/go.mod create mode 100644 incus/importer/schema.json create mode 100644 incus/manifest.yaml 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..1b909f3 --- /dev/null +++ b/incus/Makefile @@ -0,0 +1,22 @@ +GO=go +PLAKAR=plakar +VERSION=v0.1.0 + +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 ./... + +clean: + rm -f incusImporter incusExporter *.ptar diff --git a/incus/README.md b/incus/README.md new file mode 100644 index 0000000..63fc192 --- /dev/null +++ b/incus/README.md @@ -0,0 +1,23 @@ +# Plakar Incus Integration + +Backup and restore Incus containers as browsable file-level snapshots. + +## Importer + +Import filesystem contents from Incus containers for backup: + +```bash +plakar backup incus://web-1 +``` + +## Exporter + +Restore backups to new or existing Incus containers: + +```bash +plakar restore -to incus://web-1-restored +``` + +## Notes + +This integration backs up containers only and runs on the Incus host using the local unix socket. diff --git a/incus/exporter/schema.json b/incus/exporter/schema.json new file mode 100644 index 0000000..cbeda14 --- /dev/null +++ b/incus/exporter/schema.json @@ -0,0 +1,24 @@ +{ + "$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-z0-9][A-Za-z0-9-]*$" + }, + "socket": { + "type": "string", + "default": "/var/lib/incus/unix.socket", + "description": "Path to the Incus unix socket" + }, + "pool": { + "type": "string", + "description": "Target storage pool (defaults to the pool referenced by the backup)" + } + } +} diff --git a/incus/go.mod b/incus/go.mod new file mode 100644 index 0000000..a2ea4d0 --- /dev/null +++ b/incus/go.mod @@ -0,0 +1,3 @@ +module github.com/PlakarKorp/integration-incus + +go 1.24.0 diff --git a/incus/importer/schema.json b/incus/importer/schema.json new file mode 100644 index 0000000..5b4e69b --- /dev/null +++ b/incus/importer/schema.json @@ -0,0 +1,20 @@ +{ + "$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-z0-9][A-Za-z0-9-]*$" + }, + "socket": { + "type": "string", + "default": "/var/lib/incus/unix.socket", + "description": "Path to the Incus unix socket" + } + } +} diff --git a/incus/manifest.yaml b/incus/manifest.yaml new file mode 100644 index 0000000..cf0bc92 --- /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/integration-incus + license: ISC + protocols: [incus] + validator: ./importer/schema.json + class: compute +- type: exporter + executable: incusExporter + homepage: https://github.com/PlakarKorp/integration-incus + license: ISC + protocols: [incus] + validator: ./exporter/schema.json + class: compute From e635019ffec796da72890dc3b2cdf303f8aec5d8 Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Wed, 8 Jul 2026 14:54:46 +0200 Subject: [PATCH 02/22] incus: tar header to FileInfo mapping --- incus/go.mod | 11 ++++++- incus/go.sum | 18 +++++++++++ incus/importer/finfo.go | 61 ++++++++++++++++++++++++++++++++++++ incus/importer/finfo_test.go | 48 ++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 incus/go.sum create mode 100644 incus/importer/finfo.go create mode 100644 incus/importer/finfo_test.go diff --git a/incus/go.mod b/incus/go.mod index a2ea4d0..ce7c9ec 100644 --- a/incus/go.mod +++ b/incus/go.mod @@ -1,3 +1,12 @@ module github.com/PlakarKorp/integration-incus -go 1.24.0 +go 1.25.0 + +require github.com/PlakarKorp/kloset v1.1.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/mod v0.36.0 // indirect +) diff --git a/incus/go.sum b/incus/go.sum new file mode 100644 index 0000000..773450b --- /dev/null +++ b/incus/go.sum @@ -0,0 +1,18 @@ +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/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/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/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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/incus/importer/finfo.go b/incus/importer/finfo.go new file mode 100644 index 0000000..c34038e --- /dev/null +++ b/incus/importer/finfo.go @@ -0,0 +1,61 @@ +/* + * 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" + + "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 { + f := objects.FileInfo{ + Lname: path.Base(path.Clean(hdr.Name)), + Lsize: hdr.Size, + Lmode: fs.FileMode(hdr.Mode), + LmodTime: hdr.ModTime, + Luid: uint64(hdr.Uid), + Lgid: uint64(hdr.Gid), + Lnlink: 1, + } + + switch hdr.Typeflag { + case tar.TypeSymlink, tar.TypeLink: + // hardlinks are mapped to symlinks: kloset has no hardlink + // notion; acceptable for container rootfs (v1 caveat). + f.Lmode |= fs.ModeSymlink + case tar.TypeChar: + f.Lmode |= fs.ModeCharDevice + case tar.TypeBlock: + f.Lmode |= fs.ModeDevice + case tar.TypeDir: + f.Lmode |= fs.ModeDir + case tar.TypeFifo: + f.Lmode |= fs.ModeNamedPipe + } + + return f +} + +// 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..56340da --- /dev/null +++ b/incus/importer/finfo_test.go @@ -0,0 +1,48 @@ +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 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} + if fi := finfo(hdr); fi.Lmode&fs.ModeSymlink == 0 { + t.Fatalf("symlink flag missing: %v", fi.Lmode) + } +} + +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) + } +} From b6db30e45acdf2eb1c64e93fc11010b9788e7352 Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Wed, 8 Jul 2026 15:01:14 +0200 Subject: [PATCH 03/22] incus: streaming importer over abstract backup source --- incus/go.mod | 10 +++ incus/go.sum | 105 ++++++++++++++++++++++++ incus/importer/incus.go | 134 +++++++++++++++++++++++++++++++ incus/importer/incus_api_stub.go | 25 ++++++ incus/importer/incus_test.go | 94 ++++++++++++++++++++++ 5 files changed, 368 insertions(+) create mode 100644 incus/importer/incus.go create mode 100644 incus/importer/incus_api_stub.go create mode 100644 incus/importer/incus_test.go diff --git a/incus/go.mod b/incus/go.mod index ce7c9ec..4701f64 100644 --- a/incus/go.mod +++ b/incus/go.mod @@ -6,7 +6,17 @@ require github.com/PlakarKorp/kloset v1.1.0 require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect golang.org/x/mod v0.36.0 // indirect + golang.org/x/sys v0.45.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 index 773450b..531660d 100644 --- a/incus/go.sum +++ b/incus/go.sum @@ -1,18 +1,123 @@ +github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= +github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= 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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +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/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/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/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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +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.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/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/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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= +github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= +github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= +github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= +github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= +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/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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/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= +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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 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/incus.go b/incus/importer/incus.go new file mode 100644 index 0000000..4670059 --- /dev/null +++ b/incus/importer/incus.go @@ -0,0 +1,134 @@ +/* + * 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" + "os" + "strings" + + "github.com/PlakarKorp/kloset/connectors" + "github.com/PlakarKorp/kloset/connectors/importer" + "github.com/PlakarKorp/kloset/location" +) + +const defaultSocket = "/var/lib/incus/unix.socket" + +// 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) +} + +type Importer struct { + instance string + src backupSource +} + +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) + } + socket := config["socket"] + if socket == "" { + socket = defaultSocket + } + src, err := newIncusSource(socket) + if err != nil { + return nil, err + } + return newImporterWithSource(instance, src), nil +} + +func newImporterWithSource(instance string, src backupSource) *Importer { + return &Importer{instance: instance, src: src} +} + +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 { + hostname, err := os.Hostname() + if err != nil { + return "incus" + } + return hostname +} + +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) + + 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 + } + + records <- &connectors.Record{ + Pathname: recordPath(hdr), + Target: hdr.Linkname, + FileInfo: finfo(hdr), + Reader: io.NopCloser(tr), + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-results: + // sequential stream: wait for the ack before advancing. + } + } +} + +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_stub.go b/incus/importer/incus_api_stub.go new file mode 100644 index 0000000..4c4fadc --- /dev/null +++ b/incus/importer/incus_api_stub.go @@ -0,0 +1,25 @@ +/* + * 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 "fmt" + +// newIncusSource is a provisional stub, replaced by the real Incus +// client in Task 4. +func newIncusSource(socket string) (backupSource, error) { + return nil, fmt.Errorf("incus: source not implemented yet") +} diff --git a/incus/importer/incus_test.go b/incus/importer/incus_test.go new file mode 100644 index 0000000..378df35 --- /dev/null +++ b/incus/importer/incus_test.go @@ -0,0 +1,94 @@ +package importer + +import ( + "archive/tar" + "bytes" + "context" + "io" + "testing" + + "github.com/PlakarKorp/kloset/connectors" +) + +type fakeSource struct { + tarball []byte + cleaned bool + pinged bool +} + +func (f *fakeSource) Ping(ctx context.Context) error { f.pinged = true; return nil } + +func (f *fakeSource) Open(ctx context.Context, instance string) (io.ReadCloser, func() error, error) { + 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) + } + } + } + tw.Close() + 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") + } +} From b9e1760499de6a9a65db4c7b16acbfe8f9939ace Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Wed, 8 Jul 2026 15:10:26 +0200 Subject: [PATCH 04/22] incus: real backup source over the unix socket API --- incus/go.mod | 51 ++++- incus/go.sum | 205 +++++++++++++++++-- incus/importer/incus_api.go | 114 +++++++++++ incus/importer/incus_api_integration_test.go | 22 ++ incus/importer/incus_api_stub.go | 25 --- 5 files changed, 378 insertions(+), 39 deletions(-) create mode 100644 incus/importer/incus_api.go create mode 100644 incus/importer/incus_api_integration_test.go delete mode 100644 incus/importer/incus_api_stub.go diff --git a/incus/go.mod b/incus/go.mod index 4701f64..25dfa18 100644 --- a/incus/go.mod +++ b/incus/go.mod @@ -1,20 +1,67 @@ module github.com/PlakarKorp/integration-incus -go 1.25.0 +go 1.25.6 -require github.com/PlakarKorp/kloset v1.1.0 +require ( + 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/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.3 // 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/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/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/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/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/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.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/mod v0.36.0 // indirect + golang.org/x/oauth2 v0.36.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/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 diff --git a/incus/go.sum b/incus/go.sum index 531660d..afd63e7 100644 --- a/incus/go.sum +++ b/incus/go.sum @@ -1,3 +1,6 @@ +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/PlakarKorp/kloset v1.1.0 h1:Yc76wQUbwkrLLx8ZR9XGaVf+s3dslIXKXH/V66rBAoM= @@ -6,8 +9,19 @@ github.com/RaduBerinde/axisds v0.1.0 h1:YItk/RmU5nvlsv/awo2Fjx97Mfpt4JfgtEVAGPrL 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= @@ -24,73 +38,240 @@ github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b h1:VXvSNzmr8hMj8 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.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/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/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/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +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/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/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/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.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= -github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= -github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= -github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= -github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= +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/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/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.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +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= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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= diff --git a/incus/importer/incus_api.go b/incus/importer/incus_api.go new file mode 100644 index 0000000..085e06d --- /dev/null +++ b/incus/importer/incus_api.go @@ -0,0 +1,114 @@ +/* + * 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" + "time" + + incus "github.com/lxc/incus/v6/client" + "github.com/lxc/incus/v6/shared/api" +) + +type incusSource struct { + server incus.InstanceServer +} + +func newIncusSource(socket string) (backupSource, error) { + server, err := incus.ConnectIncusUnix(socket, nil) + if err != nil { + return nil, fmt.Errorf("incus: connect %s: %w", socket, err) + } + return &incusSource{server: server}, nil +} + +func (s *incusSource) Ping(ctx context.Context) error { + _, _, err := s.server.GetServer() + return err +} + +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(6 * time.Hour), + InstanceOnly: true, + OptimizedStorage: false, + CompressionAlgorithm: "none", + }) + if err != nil { + return nil, nil, fmt.Errorf("incus: create backup: %w", err) + } + if err := op.WaitContext(ctx); err != nil { + return nil, nil, fmt.Errorf("incus: backup %s: %w", instance, err) + } + + cleanup := func() error { + delOp, err := s.server.DeleteInstanceBackup(instance, backupName) + if err != nil { + return err + } + return delOp.Wait() + } + + // 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} + + go func() { + _, err := s.server.GetInstanceBackupFile(instance, backupName, &incus.BackupFileRequest{ + BackupFile: sink, + }) + // 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 +} + +// 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..e9ff679 --- /dev/null +++ b/incus/importer/incus_api_integration_test.go @@ -0,0 +1,22 @@ +//go:build integration + +package importer + +import ( + "context" + "os" + "testing" +) + +func TestIncusSourcePing(t *testing.T) { + if _, err := os.Stat(defaultSocket); err != nil { + t.Skip("no incus socket on this machine") + } + src, err := newIncusSource(defaultSocket) + if err != nil { + t.Fatal(err) + } + if err := src.Ping(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/incus/importer/incus_api_stub.go b/incus/importer/incus_api_stub.go deleted file mode 100644 index 4c4fadc..0000000 --- a/incus/importer/incus_api_stub.go +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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 "fmt" - -// newIncusSource is a provisional stub, replaced by the real Incus -// client in Task 4. -func newIncusSource(socket string) (backupSource, error) { - return nil, fmt.Errorf("incus: source not implemented yet") -} From f50f0499cd2c662f2bfd40f223914a59b20d0713 Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Wed, 8 Jul 2026 15:21:35 +0200 Subject: [PATCH 05/22] incus: honor context during backup download, cover pipeWriteSeeker --- incus/importer/incus_api.go | 33 ++++++++-- incus/importer/incus_api_test.go | 103 +++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 incus/importer/incus_api_test.go diff --git a/incus/importer/incus_api.go b/incus/importer/incus_api.go index 085e06d..ec5559a 100644 --- a/incus/importer/incus_api.go +++ b/incus/importer/incus_api.go @@ -24,6 +24,7 @@ import ( incus "github.com/lxc/incus/v6/client" "github.com/lxc/incus/v6/shared/api" + "github.com/lxc/incus/v6/shared/cancel" ) type incusSource struct { @@ -56,10 +57,6 @@ func (s *incusSource) Open(ctx context.Context, instance string) (io.ReadCloser, if err != nil { return nil, nil, fmt.Errorf("incus: create backup: %w", err) } - if err := op.WaitContext(ctx); err != nil { - return nil, nil, fmt.Errorf("incus: backup %s: %w", instance, err) - } - cleanup := func() error { delOp, err := s.server.DeleteInstanceBackup(instance, backupName) if err != nil { @@ -68,6 +65,17 @@ func (s *incusSource) Open(ctx context.Context, instance string) (io.ReadCloser, return delOp.Wait() } + 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 6h ExpiresAt TTL; the contract is + // "error return => nil cleanup", so any delete failure here is + // deliberately swallowed. + _ = cleanup() + return nil, nil, fmt.Errorf("incus: backup %s: %w", 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 @@ -78,10 +86,27 @@ func (s *incusSource) Open(ctx context.Context, instance string) (io.ReadCloser, 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) diff --git a/incus/importer/incus_api_test.go b/incus/importer/incus_api_test.go new file mode 100644 index 0000000..0eef9a4 --- /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) + } + }) + } +} From 62a50f74052d31f74019f8abebabbcf5a19100b4 Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Wed, 8 Jul 2026 15:26:47 +0200 Subject: [PATCH 06/22] incus: FileInfo to tar header mapping for restore --- incus/exporter/header.go | 64 +++++++++++++++++++++++++++++++++++ incus/exporter/header_test.go | 55 ++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 incus/exporter/header.go create mode 100644 incus/exporter/header_test.go diff --git a/incus/exporter/header.go b/incus/exporter/header.go new file mode 100644 index 0000000..39270c5 --- /dev/null +++ b/incus/exporter/header.go @@ -0,0 +1,64 @@ +/* + * 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" + "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 + + hdr := &tar.Header{ + Name: name, + Mode: int64(fi.Lmode & fs.ModePerm), + 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: + hdr.Typeflag = tar.TypeSymlink + hdr.Linkname = rec.Target + case fi.Lmode&fs.ModeCharDevice != 0: + hdr.Typeflag = tar.TypeChar + case fi.Lmode&fs.ModeDevice != 0: + hdr.Typeflag = tar.TypeBlock + 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..38348df --- /dev/null +++ b/incus/exporter/header_test.go @@ -0,0 +1,55 @@ +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) + } +} + +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) + } +} From 81b645ebd9c794877ebe90d24be19aeff9aeea88 Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Wed, 8 Jul 2026 15:32:34 +0200 Subject: [PATCH 07/22] incus: streaming exporter rebuilding the backup tarball --- incus/exporter/incus.go | 148 +++++++++++++++++++++++++++++++ incus/exporter/incus_api_stub.go | 25 ++++++ incus/exporter/incus_test.go | 97 ++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 incus/exporter/incus.go create mode 100644 incus/exporter/incus_api_stub.go create mode 100644 incus/exporter/incus_test.go diff --git a/incus/exporter/incus.go b/incus/exporter/incus.go new file mode 100644 index 0000000..a99120c --- /dev/null +++ b/incus/exporter/incus.go @@ -0,0 +1,148 @@ +/* + * 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" + "fmt" + "io" + "os" + "strings" + + "github.com/PlakarKorp/kloset/connectors" + "github.com/PlakarKorp/kloset/connectors/exporter" + "github.com/PlakarKorp/kloset/location" +) + +const defaultSocket = "/var/lib/incus/unix.socket" + +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 + 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) + } + socket := config["socket"] + if socket == "" { + socket = defaultSocket + } + sink, err := newIncusSink(socket, config["pool"]) + if err != nil { + return nil, err + } + return newExporterWithSink(instance, sink), 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 { + hostname, err := os.Hostname() + if err != nil { + return "incus" + } + return hostname +} + +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() { sinkDone <- p.sink.Restore(ctx, p.instance, pr) }() + + tw := tar.NewWriter(pw) + + // The tar stream is sequential by nature: records are written in + // the order they arrive, one at a time. +loop: + for { + select { + case <-ctx.Done(): + ret = ctx.Err() + break loop + + case rec, ok := <-records: + if !ok { + break loop + } + if rec.Err != nil || rec.IsXattr { + results <- rec.Ok() + continue + } + + hdr, err := tarHeader(rec) + if err != nil { + results <- rec.Error(err) + continue + } + if err := tw.WriteHeader(hdr); err != nil { + results <- rec.Error(err) + ret = err + break loop + } + if hdr.Typeflag == tar.TypeReg && rec.Reader != nil { + if _, err := io.Copy(tw, rec.Reader); err != nil { + results <- rec.Error(err) + ret = err + break loop + } + } + results <- rec.Ok() + } + } + + if err := tw.Close(); err != nil && ret == nil { + ret = err + } + if ret != nil { + pw.CloseWithError(ret) + } else { + pw.Close() + } + + if err := <-sinkDone; err != nil && ret == nil { + ret = fmt.Errorf("incus: restore %s: %w", p.instance, err) + } + return ret +} + +func (p *Exporter) Close(ctx context.Context) error { return nil } diff --git a/incus/exporter/incus_api_stub.go b/incus/exporter/incus_api_stub.go new file mode 100644 index 0000000..d48a61e --- /dev/null +++ b/incus/exporter/incus_api_stub.go @@ -0,0 +1,25 @@ +/* + * 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 "fmt" + +// newIncusSink is a provisional stub; Task 7 replaces it with the real +// Incus restore adapter. +func newIncusSink(socket, pool string) (restoreSink, error) { + return nil, fmt.Errorf("incus: sink not implemented yet") +} diff --git a/incus/exporter/incus_test.go b/incus/exporter/incus_test.go new file mode 100644 index 0000000..13ee495 --- /dev/null +++ b/incus/exporter/incus_test.go @@ -0,0 +1,97 @@ +package exporter + +import ( + "archive/tar" + "bytes" + "context" + "io" + "io/fs" + "strings" + "testing" + "time" + + "github.com/PlakarKorp/kloset/connectors" + "github.com/PlakarKorp/kloset/objects" +) + +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) + } +} From 9b9e4ac9a0efe458cdcfb4c42cf259a8dd2ce56c Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Wed, 8 Jul 2026 15:47:30 +0200 Subject: [PATCH 08/22] incus: unblock exporter when restore sink fails early --- incus/exporter/incus.go | 27 +++++- incus/exporter/incus_test.go | 174 +++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 3 deletions(-) diff --git a/incus/exporter/incus.go b/incus/exporter/incus.go index a99120c..1b98d00 100644 --- a/incus/exporter/incus.go +++ b/incus/exporter/incus.go @@ -19,6 +19,7 @@ package exporter import ( "archive/tar" "context" + "errors" "fmt" "io" "os" @@ -87,7 +88,15 @@ func (p *Exporter) Export(ctx context.Context, records <-chan *connectors.Record pr, pw := io.Pipe() sinkDone := make(chan error, 1) - go func() { sinkDone <- p.sink.Restore(ctx, p.instance, pr) }() + 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) @@ -114,6 +123,14 @@ loop: results <- rec.Error(err) continue } + if hdr.Typeflag == tar.TypeReg && hdr.Size > 0 && rec.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 <- rec.Error(fmt.Errorf("incus: record %q has size %d but no reader", rec.Pathname, hdr.Size)) + continue + } if err := tw.WriteHeader(hdr); err != nil { results <- rec.Error(err) ret = err @@ -139,8 +156,12 @@ loop: pw.Close() } - if err := <-sinkDone; err != nil && ret == nil { - ret = fmt.Errorf("incus: restore %s: %w", p.instance, err) + // 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 } diff --git a/incus/exporter/incus_test.go b/incus/exporter/incus_test.go index 13ee495..87ac607 100644 --- a/incus/exporter/incus_test.go +++ b/incus/exporter/incus_test.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bytes" "context" + "errors" "io" "io/fs" "strings" @@ -95,3 +96,176 @@ func TestExportRebuildsTar(t *testing.T) { 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) + } +} From 70dd053ba1c10edb38d28f5562f6f00dc9441651 Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Wed, 8 Jul 2026 15:54:14 +0200 Subject: [PATCH 09/22] incus: restore sink, plugin entrypoints, full build --- .../{incus_api_stub.go => incus_api.go} | 38 ++++++++++++-- incus/go.mod | 19 +++++-- incus/go.sum | 50 ++++++++++++++++--- incus/plugin/exporter/main.go | 12 +++++ incus/plugin/importer/main.go | 12 +++++ 5 files changed, 115 insertions(+), 16 deletions(-) rename incus/exporter/{incus_api_stub.go => incus_api.go} (54%) create mode 100644 incus/plugin/exporter/main.go create mode 100644 incus/plugin/importer/main.go diff --git a/incus/exporter/incus_api_stub.go b/incus/exporter/incus_api.go similarity index 54% rename from incus/exporter/incus_api_stub.go rename to incus/exporter/incus_api.go index d48a61e..cbeb331 100644 --- a/incus/exporter/incus_api_stub.go +++ b/incus/exporter/incus_api.go @@ -16,10 +16,40 @@ package exporter -import "fmt" +import ( + "context" + "fmt" + "io" + + incus "github.com/lxc/incus/v6/client" +) + +type incusSink struct { + server incus.InstanceServer + pool string +} -// newIncusSink is a provisional stub; Task 7 replaces it with the real -// Incus restore adapter. func newIncusSink(socket, pool string) (restoreSink, error) { - return nil, fmt.Errorf("incus: sink not implemented yet") + server, err := incus.ConnectIncusUnix(socket, nil) + if err != nil { + return nil, fmt.Errorf("incus: connect %s: %w", socket, err) + } + return &incusSink{server: server, pool: pool}, 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 { + op, err := s.server.CreateInstanceFromBackup(incus.InstanceBackupArgs{ + BackupFile: tarStream, + Name: instance, + PoolName: s.pool, + }) + if err != nil { + return err + } + return op.WaitContext(ctx) } diff --git a/incus/go.mod b/incus/go.mod index 25dfa18..7a39bd3 100644 --- a/incus/go.mod +++ b/incus/go.mod @@ -3,12 +3,14 @@ module github.com/PlakarKorp/integration-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 @@ -18,7 +20,7 @@ require ( 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.3 // 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 @@ -26,6 +28,7 @@ require ( 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 @@ -33,33 +36,41 @@ require ( 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.42.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // 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 diff --git a/incus/go.sum b/incus/go.sum index afd63e7..bf26455 100644 --- a/incus/go.sum +++ b/incus/go.sum @@ -3,6 +3,14 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h 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= @@ -60,8 +68,8 @@ github.com/getsentry/sentry-go v0.31.1 h1:ELVc0h7gwyhnXHDouXkhqTFSO5oslsRDk0++ey 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.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +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= @@ -73,6 +81,8 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 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= @@ -98,6 +108,8 @@ github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht 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= @@ -135,6 +147,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq 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= @@ -145,6 +159,8 @@ github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5 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= @@ -190,6 +206,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl 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= @@ -205,6 +223,12 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU 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= @@ -213,12 +237,16 @@ 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.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +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= @@ -256,6 +284,12 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm 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= diff --git a/incus/plugin/exporter/main.go b/incus/plugin/exporter/main.go new file mode 100644 index 0000000..07b666c --- /dev/null +++ b/incus/plugin/exporter/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "os" + + sdk "github.com/PlakarKorp/go-kloset-sdk" + "github.com/PlakarKorp/integration-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..21273b7 --- /dev/null +++ b/incus/plugin/importer/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "os" + + sdk "github.com/PlakarKorp/go-kloset-sdk" + "github.com/PlakarKorp/integration-incus/importer" +) + +func main() { + sdk.EntrypointImporter(os.Args, importer.NewImporter) +} From d496fb6490e4541ecc9f0e2f32b6bc02fa370c4c Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Wed, 8 Jul 2026 16:04:51 +0200 Subject: [PATCH 10/22] incus: beta validation results --- incus/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/incus/README.md b/incus/README.md index 63fc192..9838556 100644 --- a/incus/README.md +++ b/incus/README.md @@ -21,3 +21,13 @@ plakar restore -to incus://web-1-restored ## Notes This integration backs up containers only and runs on the Incus host using the local unix socket. + +## 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. + +Known v1 caveats: hardlinks are mapped to symlinks (kloset has no hardlink notion); extended attributes (e.g. file capabilities) are not preserved through restore. From 5aebce1a62b7dd93cdbefa0db79dda178141bc2b Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Wed, 8 Jul 2026 16:14:46 +0200 Subject: [PATCH 11/22] incus: preserve setuid/setgid/sticky bits through restore exporter/header.go masked the tar Mode field with fs.ModePerm (0o777), dropping any setuid/setgid/sticky bit before restore (e.g. a 04755 sudo binary came back as 0755). Mask with 0o7777 instead to keep the special bits, symmetric with the importer's raw-bit-preserving cast in finfo.go. Added round-trip tests in both directions. Also: note device node major/minor loss in README caveats, document the tar record-ordering assumption in the exporter write loop, and add the ISC header to the plugin entrypoints. --- incus/README.md | 2 +- incus/exporter/header.go | 2 +- incus/exporter/header_test.go | 12 ++++++++++++ incus/exporter/incus.go | 5 +++++ incus/importer/finfo_test.go | 11 +++++++++++ incus/plugin/exporter/main.go | 16 ++++++++++++++++ incus/plugin/importer/main.go | 16 ++++++++++++++++ 7 files changed, 62 insertions(+), 2 deletions(-) diff --git a/incus/README.md b/incus/README.md index 9838556..c7875e2 100644 --- a/incus/README.md +++ b/incus/README.md @@ -30,4 +30,4 @@ This integration backs up containers only and runs on the Incus host using the l - 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. -Known v1 caveats: hardlinks are mapped to symlinks (kloset has no hardlink notion); extended attributes (e.g. file capabilities) are not preserved through restore. +Known v1 caveats: hardlinks are mapped to symlinks (kloset has no hardlink notion); extended attributes (e.g. file capabilities) are not preserved through restore; device nodes lose their major/minor numbers on restore (kloset FileInfo carries none; low impact — Incus manages /dev for containers). diff --git a/incus/exporter/header.go b/incus/exporter/header.go index 39270c5..da11828 100644 --- a/incus/exporter/header.go +++ b/incus/exporter/header.go @@ -36,7 +36,7 @@ func tarHeader(rec *connectors.Record) (*tar.Header, error) { hdr := &tar.Header{ Name: name, - Mode: int64(fi.Lmode & fs.ModePerm), + Mode: int64(fi.Lmode) & 0o7777, Uid: int(fi.Luid), Gid: int(fi.Lgid), ModTime: fi.LmodTime, diff --git a/incus/exporter/header_test.go b/incus/exporter/header_test.go index 38348df..681e9de 100644 --- a/incus/exporter/header_test.go +++ b/incus/exporter/header_test.go @@ -43,6 +43,18 @@ func TestTarHeaderDir(t *testing.T) { } } +func TestTarHeaderSpecialBits(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: 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")) diff --git a/incus/exporter/incus.go b/incus/exporter/incus.go index 1b98d00..c0258f0 100644 --- a/incus/exporter/incus.go +++ b/incus/exporter/incus.go @@ -102,6 +102,11 @@ func (p *Exporter) Export(ctx context.Context, records <-chan *connectors.Record // 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. loop: for { select { diff --git a/incus/importer/finfo_test.go b/incus/importer/finfo_test.go index 56340da..71930c2 100644 --- a/incus/importer/finfo_test.go +++ b/incus/importer/finfo_test.go @@ -22,6 +22,17 @@ func TestFinfoRegular(t *testing.T) { } } +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) + if fi.Lmode != fs.FileMode(0o4755) { + t.Fatalf("setuid bit dropped: got mode %o, want %o", fi.Lmode, 0o4755) + } +} + 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 { diff --git a/incus/plugin/exporter/main.go b/incus/plugin/exporter/main.go index 07b666c..f6c95da 100644 --- a/incus/plugin/exporter/main.go +++ b/incus/plugin/exporter/main.go @@ -1,3 +1,19 @@ +/* + * 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 ( diff --git a/incus/plugin/importer/main.go b/incus/plugin/importer/main.go index 21273b7..b4a60cf 100644 --- a/incus/plugin/importer/main.go +++ b/incus/plugin/importer/main.go @@ -1,3 +1,19 @@ +/* + * 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 ( From 9647f51a63e6aa2f15bec3c8ab634e0509a2c583 Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Thu, 9 Jul 2026 11:07:55 +0200 Subject: [PATCH 12/22] incus: use the instance name as snapshot origin --- incus/exporter/incus.go | 7 +------ incus/importer/incus.go | 9 +++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/incus/exporter/incus.go b/incus/exporter/incus.go index c0258f0..0ba3e62 100644 --- a/incus/exporter/incus.go +++ b/incus/exporter/incus.go @@ -22,7 +22,6 @@ import ( "errors" "fmt" "io" - "os" "strings" "github.com/PlakarKorp/kloset/connectors" @@ -74,11 +73,7 @@ func (p *Exporter) Root() string { return "/" } func (p *Exporter) Flags() location.Flags { return 0 } func (p *Exporter) Origin() string { - hostname, err := os.Hostname() - if err != nil { - return "incus" - } - return hostname + return p.instance } func (p *Exporter) Ping(ctx context.Context) error { return p.sink.Ping(ctx) } diff --git a/incus/importer/incus.go b/incus/importer/incus.go index 4670059..c0d8d0c 100644 --- a/incus/importer/incus.go +++ b/incus/importer/incus.go @@ -22,7 +22,6 @@ import ( "errors" "fmt" "io" - "os" "strings" "github.com/PlakarKorp/kloset/connectors" @@ -80,11 +79,9 @@ func (p *Importer) Root() string { return "/" } func (p *Importer) Flags() location.Flags { return flags } func (p *Importer) Origin() string { - hostname, err := os.Hostname() - if err != nil { - return "incus" - } - return hostname + // The snapshot origin is the instance itself, not the node the + // plugin happens to run on: it names snapshots in listings/UI. + return p.instance } func (p *Importer) Ping(ctx context.Context) error { return p.src.Ping(ctx) } From b624bcebc74e40cb05403eb105f178154e5738dc Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Thu, 9 Jul 2026 15:22:50 +0200 Subject: [PATCH 13/22] incus: fix hardlink, special-bit and device-node fidelity --- incus/exporter/header.go | 43 ++++++++++++++++++++++++++-- incus/exporter/header_test.go | 54 +++++++++++++++++++++++++++++++++-- incus/importer/finfo.go | 50 ++++++++++++++++++++++++++++++-- incus/importer/finfo_test.go | 48 +++++++++++++++++++++++++++++-- incus/importer/incus.go | 2 +- 5 files changed, 185 insertions(+), 12 deletions(-) diff --git a/incus/exporter/header.go b/incus/exporter/header.go index da11828..3ab38ff 100644 --- a/incus/exporter/header.go +++ b/incus/exporter/header.go @@ -34,9 +34,30 @@ func tarHeader(rec *connectors.Record) (*tar.Header, error) { } 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: int64(fi.Lmode) & 0o7777, + Mode: mode, Uid: int(fi.Luid), Gid: int(fi.Lgid), ModTime: fi.LmodTime, @@ -47,12 +68,28 @@ func tarHeader(rec *connectors.Record) (*tar.Header, error) { hdr.Typeflag = tar.TypeDir hdr.Name += "/" case fi.Lmode&fs.ModeSymlink != 0: - hdr.Typeflag = tar.TypeSymlink - hdr.Linkname = rec.Target + if fi.Lnlink > 1 { + // Hardlink (see importer/finfo.go's linkTarget): Target + // was recorded tar-root-relative, matching how entry + // names are stored, so re-emit it the same way here. + hdr.Typeflag = tar.TypeLink + hdr.Linkname = strings.TrimPrefix(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: diff --git a/incus/exporter/header_test.go b/incus/exporter/header_test.go index 681e9de..4fe65cb 100644 --- a/incus/exporter/header_test.go +++ b/incus/exporter/header_test.go @@ -43,7 +43,10 @@ func TestTarHeaderDir(t *testing.T) { } } -func TestTarHeaderSpecialBits(t *testing.T) { +// 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, "")) @@ -51,7 +54,21 @@ func TestTarHeaderSpecialBits(t *testing.T) { t.Fatal(err) } if hdr.Mode != 0o4755 { - t.Fatalf("setuid bit dropped: got mode %o, want %o", 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) } } @@ -65,3 +82,36 @@ func TestTarHeaderSymlink(t *testing.T) { 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 +// tar-root-relative. 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, "/backup/container/rootfs/bin/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) + } +} + +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/importer/finfo.go b/incus/importer/finfo.go index c34038e..ef7d83c 100644 --- a/incus/importer/finfo.go +++ b/incus/importer/finfo.go @@ -27,10 +27,27 @@ import ( // 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: fs.FileMode(hdr.Mode), + Lmode: mode, LmodTime: hdr.ModTime, Luid: uint64(hdr.Uid), Lgid: uint64(hdr.Gid), @@ -38,14 +55,26 @@ func finfo(hdr *tar.Header) objects.FileInfo { } switch hdr.Typeflag { - case tar.TypeSymlink, tar.TypeLink: + case tar.TypeSymlink: + f.Lmode |= fs.ModeSymlink + case tar.TypeLink: // hardlinks are mapped to symlinks: kloset has no hardlink - // notion; acceptable for container rootfs (v1 caveat). + // 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: @@ -55,6 +84,21 @@ func finfo(hdr *tar.Header) objects.FileInfo { 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"), so it is normalized +// the same way recordPath() normalizes entry names, keeping both +// sides comparable for the exporter to reconstruct a tar-root-relative +// Linkname later. +func linkTarget(hdr *tar.Header) string { + if hdr.Typeflag == tar.TypeLink { + return path.Join("/", hdr.Linkname) + } + return hdr.Linkname +} + // 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 index 71930c2..53b5aa2 100644 --- a/incus/importer/finfo_test.go +++ b/incus/importer/finfo_test.go @@ -28,8 +28,9 @@ func TestFinfoSpecialBits(t *testing.T) { Size: 3, Mode: 0o4755, Typeflag: tar.TypeReg, } fi := finfo(hdr) - if fi.Lmode != fs.FileMode(0o4755) { - t.Fatalf("setuid bit dropped: got mode %o, want %o", fi.Lmode, 0o4755) + 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) } } @@ -42,9 +43,50 @@ func TestFinfoDir(t *testing.T) { func TestFinfoSymlink(t *testing.T) { hdr := &tar.Header{Name: "backup/container/rootfs/bin", Linkname: "usr/bin", Typeflag: tar.TypeSymlink} - if fi := finfo(hdr); fi.Lmode&fs.ModeSymlink == 0 { + 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) + } + want := "/backup/container/rootfs/bin/busybox" + if got := linkTarget(hdr); got != want { + t.Fatalf("hardlink target: got %q, want %q (tar-root-relative)", got, 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) { diff --git a/incus/importer/incus.go b/incus/importer/incus.go index c0d8d0c..08031ce 100644 --- a/incus/importer/incus.go +++ b/incus/importer/incus.go @@ -112,7 +112,7 @@ func (p *Importer) Import(ctx context.Context, records chan<- *connectors.Record records <- &connectors.Record{ Pathname: recordPath(hdr), - Target: hdr.Linkname, + Target: linkTarget(hdr), FileInfo: finfo(hdr), Reader: io.NopCloser(tr), } From 4f5df94e052a46bdc7e5556d0b8eb70a0c62283d Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Thu, 9 Jul 2026 15:45:31 +0200 Subject: [PATCH 14/22] incus: portable relative targets for hardlink records --- incus/README.md | 2 +- incus/exporter/header.go | 12 ++++++--- incus/exporter/header_test.go | 23 ++++++++++++++--- incus/importer/finfo.go | 47 +++++++++++++++++++++++++++++++---- incus/importer/finfo_test.go | 34 +++++++++++++++++++++++-- 5 files changed, 104 insertions(+), 14 deletions(-) diff --git a/incus/README.md b/incus/README.md index c7875e2..3879965 100644 --- a/incus/README.md +++ b/incus/README.md @@ -30,4 +30,4 @@ This integration backs up containers only and runs on the Incus host using the l - 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. -Known v1 caveats: hardlinks are mapped to symlinks (kloset has no hardlink notion); extended attributes (e.g. file capabilities) are not preserved through restore; device nodes lose their major/minor numbers on restore (kloset FileInfo carries none; low impact — Incus manages /dev for containers). +Known v1 caveats: hardlinks round-trip as true hardlinks incus-to-incus, but are materialized as relative symlinks on non-Incus restore destinations (true hardlink identity is not representable in the connector protocol); extended attributes (e.g. file capabilities) are not preserved through restore. diff --git a/incus/exporter/header.go b/incus/exporter/header.go index 3ab38ff..1a2f7e1 100644 --- a/incus/exporter/header.go +++ b/incus/exporter/header.go @@ -20,6 +20,7 @@ import ( "archive/tar" "fmt" "io/fs" + "path" "strings" "github.com/PlakarKorp/kloset/connectors" @@ -70,10 +71,15 @@ func tarHeader(rec *connectors.Record) (*tar.Header, error) { case fi.Lmode&fs.ModeSymlink != 0: if fi.Lnlink > 1 { // Hardlink (see importer/finfo.go's linkTarget): Target - // was recorded tar-root-relative, matching how entry - // names are stored, so re-emit it the same way here. + // 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(rec.Target, "/") + hdr.Linkname = strings.TrimPrefix(path.Join(path.Dir(rec.Pathname), rec.Target), "/") } else { hdr.Typeflag = tar.TypeSymlink hdr.Linkname = rec.Target diff --git a/incus/exporter/header_test.go b/incus/exporter/header_test.go index 4fe65cb..af6bc18 100644 --- a/incus/exporter/header_test.go +++ b/incus/exporter/header_test.go @@ -85,11 +85,11 @@ func TestTarHeaderSymlink(t *testing.T) { // TestTarHeaderHardlink mirrors what importer.finfo/linkTarget produce for a // tar.TypeLink entry: Lmode has ModeSymlink set, Lnlink==2, and Target is -// tar-root-relative. The exporter must re-emit tar.TypeLink with a -// tar-root-relative Linkname, not a broken TypeSymlink. +// 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, "/backup/container/rootfs/bin/busybox")) + hdr, err := tarHeader(rec("/backup/container/rootfs/bin/ls", fi, "busybox")) if err != nil { t.Fatal(err) } @@ -102,6 +102,23 @@ func TestTarHeaderHardlink(t *testing.T) { } } +// 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, "")) diff --git a/incus/importer/finfo.go b/incus/importer/finfo.go index ef7d83c..a6068f3 100644 --- a/incus/importer/finfo.go +++ b/incus/importer/finfo.go @@ -20,6 +20,7 @@ import ( "archive/tar" "io/fs" "path" + "strings" "github.com/PlakarKorp/kloset/objects" ) @@ -88,17 +89,53 @@ func finfo(hdr *tar.Header) objects.FileInfo { // 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"), so it is normalized -// the same way recordPath() normalizes entry names, keeping both -// sides comparable for the exporter to reconstruct a tar-root-relative -// Linkname later. +// (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 { - return path.Join("/", hdr.Linkname) + 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 index 53b5aa2..31fb95b 100644 --- a/incus/importer/finfo_test.go +++ b/incus/importer/finfo_test.go @@ -68,9 +68,39 @@ func TestFinfoHardlink(t *testing.T) { if fi.Lnlink != 2 { t.Fatalf("hardlink must be flagged via Lnlink=2, got %d", fi.Lnlink) } - want := "/backup/container/rootfs/bin/busybox" + // 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("hardlink target: got %q, want %q (tar-root-relative)", 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) + } } } From 877302e8c652e029e00d32db20d10dfab04e490f Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Thu, 9 Jul 2026 16:00:50 +0200 Subject: [PATCH 15/22] incus: preserve extended attributes and file capabilities The importer now replays each tar entry's PAX SCHILY.xattr.* records as kloset xattr Records right after the owning file record (mirroring the reference fs importer's file-then-xattr order, which this module's exporter already relies on for record replay ordering). The exporter buffers one record at a time so a file record's xattr(s), arriving right after it, can be folded into its rebuilt tar header's PAXRecords before the header is written - fixing security.capability (e.g. ping's cap_net_raw) and other xattrs silently dropping on restore. --- incus/README.md | 4 +- incus/exporter/incus.go | 120 +++++++++++++++++++++++++------ incus/exporter/incus_test.go | 135 +++++++++++++++++++++++++++++++++++ incus/importer/incus.go | 61 ++++++++++++++++ incus/importer/incus_test.go | 84 ++++++++++++++++++++++ 5 files changed, 381 insertions(+), 23 deletions(-) diff --git a/incus/README.md b/incus/README.md index 3879965..168a83d 100644 --- a/incus/README.md +++ b/incus/README.md @@ -30,4 +30,6 @@ This integration backs up containers only and runs on the Incus host using the l - 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. -Known v1 caveats: hardlinks round-trip as true hardlinks incus-to-incus, but are materialized as relative symlinks on non-Incus restore destinations (true hardlink identity is not representable in the connector protocol); extended attributes (e.g. file capabilities) are not preserved through restore. +Known v1 caveats: hardlinks round-trip as true hardlinks incus-to-incus, but are materialized as relative symlinks on non-Incus restore destinations (true hardlink identity is not representable in the connector protocol). + +Extended attributes and file capabilities (e.g. `security.capability`, needed for `ping`'s `cap_net_raw`) are preserved incus-to-incus: the importer replays each tar entry's PAX `SCHILY.xattr.*` records as kloset xattr records, and the exporter reinjects them into the rebuilt tar's PAX records. Restoring to a generic (non-tar-based) destination depends on that connector's own xattr handling to apply them. diff --git a/incus/exporter/incus.go b/incus/exporter/incus.go index 0ba3e62..0ea6811 100644 --- a/incus/exporter/incus.go +++ b/incus/exporter/incus.go @@ -102,48 +102,124 @@ func (p *Exporter) Export(ctx context.Context, records <-chan *connectors.Record // 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 { + if _, err := io.Copy(tw, p.Reader); err != nil { + 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 || rec.IsXattr { + if rec.Err != nil { + if err := flushPending(); err != nil { + results <- rec.Error(err) + ret = err + break loop + } results <- rec.Ok() continue } - - hdr, err := tarHeader(rec) - if err != nil { - results <- rec.Error(err) - continue - } - if hdr.Typeflag == tar.TypeReg && hdr.Size > 0 && rec.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 <- rec.Error(fmt.Errorf("incus: record %q has size %d but no reader", rec.Pathname, hdr.Size)) + 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 } - if err := tw.WriteHeader(hdr); err != nil { + + // 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 } - if hdr.Typeflag == tar.TypeReg && rec.Reader != nil { - if _, err := io.Copy(tw, rec.Reader); err != nil { - results <- rec.Error(err) - ret = err - break loop - } - } - results <- rec.Ok() + pending = rec + } + } + + if pending != nil { + if err := flushPending(); err != nil && ret == nil { + ret = err } } diff --git a/incus/exporter/incus_test.go b/incus/exporter/incus_test.go index 87ac607..187ed17 100644 --- a/incus/exporter/incus_test.go +++ b/incus/exporter/incus_test.go @@ -269,3 +269,138 @@ func TestExportNilReaderRegular(t *testing.T) { t.Fatalf("entries: %v, want %v (broken record must not appear)", names, want) } } + +// 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) + } + stw.Close() + + // 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/importer/incus.go b/incus/importer/incus.go index 08031ce..4a2282b 100644 --- a/incus/importer/incus.go +++ b/incus/importer/incus.go @@ -22,15 +22,26 @@ import ( "errors" "fmt" "io" + "sort" "strings" "github.com/PlakarKorp/kloset/connectors" "github.com/PlakarKorp/kloset/connectors/importer" "github.com/PlakarKorp/kloset/location" + "github.com/PlakarKorp/kloset/objects" ) const defaultSocket = "/var/lib/incus/unix.socket" +// 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 @@ -123,7 +134,57 @@ func (p *Importer) Import(ctx context.Context, records chan<- *connectors.Record 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] + records <- &connectors.Record{ + Pathname: path, + IsXattr: true, + XattrName: name, + XattrType: objects.AttributeExtended, + Reader: io.NopCloser(strings.NewReader(value)), + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-results: + // sequential stream: wait for the ack before advancing. + } } + return nil } func (p *Importer) Close(ctx context.Context) error { return nil } diff --git a/incus/importer/incus_test.go b/incus/importer/incus_test.go index 378df35..7f9007c 100644 --- a/incus/importer/incus_test.go +++ b/incus/importer/incus_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/PlakarKorp/kloset/connectors" + "github.com/PlakarKorp/kloset/objects" ) type fakeSource struct { @@ -92,3 +93,86 @@ func TestImportEmitsRecords(t *testing.T) { t.Fatal("incus backup not cleaned up") } } + +// 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) + } + tw.Close() + 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) + } +} From 251925722f737095f7b5faca99755e7c32ccff58 Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Thu, 9 Jul 2026 16:10:59 +0200 Subject: [PATCH 16/22] incus: harden cancellation, short-read detection and name validation --- incus/exporter/incus.go | 13 ++++++++++- incus/exporter/incus_test.go | 44 ++++++++++++++++++++++++++++++++++++ incus/exporter/schema.json | 2 +- incus/importer/incus.go | 12 ++++++++-- incus/importer/incus_api.go | 11 ++++++++- incus/importer/schema.json | 2 +- 6 files changed, 78 insertions(+), 6 deletions(-) diff --git a/incus/exporter/incus.go b/incus/exporter/incus.go index 0ea6811..2ab5156 100644 --- a/incus/exporter/incus.go +++ b/incus/exporter/incus.go @@ -153,7 +153,18 @@ func (p *Exporter) Export(ctx context.Context, records <-chan *connectors.Record return err } if hdr.Typeflag == tar.TypeReg && p.Reader != nil { - if _, err := io.Copy(tw, p.Reader); err != 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 } diff --git a/incus/exporter/incus_test.go b/incus/exporter/incus_test.go index 187ed17..d1da787 100644 --- a/incus/exporter/incus_test.go +++ b/incus/exporter/incus_test.go @@ -270,6 +270,50 @@ func TestExportNilReaderRegular(t *testing.T) { } } +// 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 diff --git a/incus/exporter/schema.json b/incus/exporter/schema.json index cbeda14..fd34b61 100644 --- a/incus/exporter/schema.json +++ b/incus/exporter/schema.json @@ -9,7 +9,7 @@ "type": "string", "minLength": 1, "description": "Incus instance to create on restore: incus://", - "pattern": "^incus://[A-Za-z0-9][A-Za-z0-9-]*$" + "pattern": "^incus://[A-Za-z]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$" }, "socket": { "type": "string", diff --git a/incus/importer/incus.go b/incus/importer/incus.go index 4a2282b..c11aaae 100644 --- a/incus/importer/incus.go +++ b/incus/importer/incus.go @@ -121,11 +121,15 @@ func (p *Importer) Import(ctx context.Context, records chan<- *connectors.Record return err } - records <- &connectors.Record{ + 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 { @@ -169,12 +173,16 @@ func (p *Importer) emitXattrs(ctx context.Context, hdr *tar.Header, records chan path := recordPath(hdr) for _, name := range names { value := hdr.PAXRecords[paxXattrPrefix+name] - records <- &connectors.Record{ + 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 { diff --git a/incus/importer/incus_api.go b/incus/importer/incus_api.go index ec5559a..2a09b89 100644 --- a/incus/importer/incus_api.go +++ b/incus/importer/incus_api.go @@ -40,6 +40,10 @@ func newIncusSource(socket string) (backupSource, error) { } 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 } @@ -62,7 +66,12 @@ func (s *incusSource) Open(ctx context.Context, instance string) (io.ReadCloser, if err != nil { return err } - return delOp.Wait() + // 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(), 2*time.Minute) + defer cancel() + return delOp.WaitContext(waitCtx) } if err := op.WaitContext(ctx); err != nil { diff --git a/incus/importer/schema.json b/incus/importer/schema.json index 5b4e69b..df155e6 100644 --- a/incus/importer/schema.json +++ b/incus/importer/schema.json @@ -9,7 +9,7 @@ "type": "string", "minLength": 1, "description": "Incus instance to back up: incus://", - "pattern": "^incus://[A-Za-z0-9][A-Za-z0-9-]*$" + "pattern": "^incus://[A-Za-z]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$" }, "socket": { "type": "string", From 732036055f4a8ceaaf85bc12adf1cdff2f688761 Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Fri, 10 Jul 2026 15:00:08 +0200 Subject: [PATCH 17/22] incus: shared connection layer, remote HTTPS with client certificates --- incus/internal/conn/conn.go | 136 ++++++++++++++++ incus/internal/conn/conn_test.go | 256 +++++++++++++++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 incus/internal/conn/conn.go create mode 100644 incus/internal/conn/conn_test.go 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") + } +} From c7f7c91796e476b8773bd7eed274641ab5d29556 Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Fri, 10 Jul 2026 15:00:08 +0200 Subject: [PATCH 18/22] incus: importer project scoping, VM refusal and backup safeguards --- incus/importer/incus.go | 109 ++++++++- incus/importer/incus_api.go | 80 +++++- incus/importer/incus_api_integration_test.go | 10 +- incus/importer/incus_api_test.go | 4 +- incus/importer/incus_test.go | 242 ++++++++++++++++++- incus/importer/schema.json | 44 +++- 6 files changed, 460 insertions(+), 29 deletions(-) diff --git a/incus/importer/incus.go b/incus/importer/incus.go index c11aaae..1194582 100644 --- a/incus/importer/incus.go +++ b/incus/importer/incus.go @@ -24,6 +24,7 @@ import ( "io" "sort" "strings" + "time" "github.com/PlakarKorp/kloset/connectors" "github.com/PlakarKorp/kloset/connectors/importer" @@ -31,7 +32,34 @@ import ( "github.com/PlakarKorp/kloset/objects" ) -const defaultSocket = "/var/lib/incus/unix.socket" +// 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 @@ -47,7 +75,7 @@ const paxXattrPrefix = "SCHILY.xattr." const flags = location.FLAG_STREAM | location.FLAG_NEEDACK func init() { - importer.Register("incus", flags, NewImporter) + _ = importer.Register("incus", flags, NewImporter) } // backupSource abstracts the Incus API so the import pipeline is @@ -58,11 +86,25 @@ type backupSource interface { // 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) { @@ -70,19 +112,30 @@ func NewImporter(ctx context.Context, opts *connectors.Options, proto string, co if instance == "" || strings.Contains(instance, "/") { return nil, fmt.Errorf("incus: invalid instance name %q", instance) } - socket := config["socket"] - if socket == "" { - socket = defaultSocket + 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(socket) + src, err := newIncusSource(config, backupTTL, cleanupTimeout) if err != nil { return nil, err } - return newImporterWithSource(instance, src), nil + 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} + return &Importer{instance: instance, src: src, stderr: io.Discard} } func (p *Importer) Type() string { return "incus" } @@ -92,6 +145,14 @@ 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 } @@ -100,12 +161,16 @@ 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() + _ = stream.Close() if cleanup != nil { _ = cleanup() } @@ -195,6 +260,32 @@ func (p *Importer) emitXattrs(ctx context.Context, hdr *tar.Header, records chan 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 index 2a09b89..2d1cd98 100644 --- a/incus/importer/incus_api.go +++ b/incus/importer/incus_api.go @@ -20,8 +20,11 @@ import ( "context" "fmt" "io" + "sort" + "strings" "time" + "github.com/PlakarKorp/integration-incus/internal/conn" incus "github.com/lxc/incus/v6/client" "github.com/lxc/incus/v6/shared/api" "github.com/lxc/incus/v6/shared/cancel" @@ -29,14 +32,22 @@ import ( 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(socket string) (backupSource, error) { - server, err := incus.ConnectIncusUnix(socket, nil) +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, fmt.Errorf("incus: connect %s: %w", socket, err) + return nil, err } - return &incusSource{server: server}, nil + return &incusSource{server: server, backupTTL: backupTTL, cleanupTimeout: cleanupTimeout}, nil } func (s *incusSource) Ping(ctx context.Context) error { @@ -48,12 +59,49 @@ func (s *incusSource) Ping(ctx context.Context) error { 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(6 * time.Hour), + ExpiresAt: time.Now().Add(s.backupTTL), InstanceOnly: true, OptimizedStorage: false, CompressionAlgorithm: "none", @@ -69,7 +117,7 @@ func (s *incusSource) Open(ctx context.Context, instance string) (io.ReadCloser, // 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(), 2*time.Minute) + waitCtx, cancel := context.WithTimeout(context.Background(), s.cleanupTimeout) defer cancel() return delOp.WaitContext(waitCtx) } @@ -78,11 +126,11 @@ func (s *incusSource) Open(ctx context.Context, instance string) (io.ReadCloser, // 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 6h ExpiresAt TTL; the contract is + // 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, fmt.Errorf("incus: backup %s: %w", instance, err) + return nil, nil, wrapBackupError(instance, err) } // The InstanceServer interface exposed by github.com/lxc/incus/v6/client @@ -124,6 +172,22 @@ func (s *incusSource) Open(ctx context.Context, instance string) (io.ReadCloser, 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 diff --git a/incus/importer/incus_api_integration_test.go b/incus/importer/incus_api_integration_test.go index e9ff679..1c02c60 100644 --- a/incus/importer/incus_api_integration_test.go +++ b/incus/importer/incus_api_integration_test.go @@ -6,17 +6,19 @@ import ( "context" "os" "testing" + + "github.com/PlakarKorp/integration-incus/internal/conn" ) func TestIncusSourcePing(t *testing.T) { - if _, err := os.Stat(defaultSocket); err != nil { + if _, err := os.Stat(conn.DefaultSocket); err != nil { t.Skip("no incus socket on this machine") } - src, err := newIncusSource(defaultSocket) + src, err := newIncusSource(map[string]string{"socket": conn.DefaultSocket}, defaultBackupTTL, defaultCleanupTimeout) if err != nil { - t.Fatal(err) + t.Skipf("incus socket present but unusable: %v", err) } if err := src.Ping(context.Background()); err != nil { - t.Fatal(err) + t.Skipf("incus daemon unreachable: %v", err) } } diff --git a/incus/importer/incus_api_test.go b/incus/importer/incus_api_test.go index 0eef9a4..52eb7e1 100644 --- a/incus/importer/incus_api_test.go +++ b/incus/importer/incus_api_test.go @@ -21,11 +21,11 @@ func newTestPipeWriteSeeker(t *testing.T) (sink *pipeWriteSeeker, got *bytes.Buf done := make(chan struct{}) go func() { defer close(done) - io.Copy(got, pr) + _, _ = io.Copy(got, pr) }() closeAndWait = func() { - pw.Close() + _ = pw.Close() <-done } t.Cleanup(closeAndWait) diff --git a/incus/importer/incus_test.go b/incus/importer/incus_test.go index 7f9007c..a3823e3 100644 --- a/incus/importer/incus_test.go +++ b/incus/importer/incus_test.go @@ -4,22 +4,37 @@ 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 + 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 } @@ -47,7 +62,9 @@ func makeTar(t *testing.T) []byte { } } } - tw.Close() + if err := tw.Close(); err != nil { + t.Fatal(err) + } return buf.Bytes() } @@ -94,6 +111,219 @@ func TestImportEmitsRecords(t *testing.T) { } } +// 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. @@ -117,7 +347,9 @@ func makeTarWithXattr(t *testing.T) []byte { if _, err := tw.Write([]byte(body)); err != nil { t.Fatal(err) } - tw.Close() + if err := tw.Close(); err != nil { + t.Fatal(err) + } return buf.Bytes() } diff --git a/incus/importer/schema.json b/incus/importer/schema.json index df155e6..8f185c5 100644 --- a/incus/importer/schema.json +++ b/incus/importer/schema.json @@ -14,7 +14,49 @@ "socket": { "type": "string", "default": "/var/lib/incus/unix.socket", - "description": "Path to the 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))+$" } } } From 573b64f672dcc3c97096aacc374569fa5286bc60 Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Fri, 10 Jul 2026 15:00:08 +0200 Subject: [PATCH 19/22] incus: exporter cluster target, project scoping and name pre-check --- incus/exporter/incus.go | 23 +++--- incus/exporter/incus_api.go | 44 +++++++++-- incus/exporter/incus_api_test.go | 130 +++++++++++++++++++++++++++++++ incus/exporter/incus_test.go | 22 +++++- incus/exporter/schema.json | 37 ++++++++- 5 files changed, 236 insertions(+), 20 deletions(-) create mode 100644 incus/exporter/incus_api_test.go diff --git a/incus/exporter/incus.go b/incus/exporter/incus.go index 2ab5156..a28349c 100644 --- a/incus/exporter/incus.go +++ b/incus/exporter/incus.go @@ -29,10 +29,8 @@ import ( "github.com/PlakarKorp/kloset/location" ) -const defaultSocket = "/var/lib/incus/unix.socket" - func init() { - exporter.Register("incus", 0, NewExporter) + _ = exporter.Register("incus", 0, NewExporter) } // restoreSink abstracts the Incus restore API for testability. @@ -45,6 +43,7 @@ type restoreSink interface { type Exporter struct { instance string + project string sink restoreSink } @@ -53,15 +52,13 @@ func NewExporter(ctx context.Context, opts *connectors.Options, proto string, co if instance == "" || strings.Contains(instance, "/") { return nil, fmt.Errorf("incus: invalid instance name %q", instance) } - socket := config["socket"] - if socket == "" { - socket = defaultSocket - } - sink, err := newIncusSink(socket, config["pool"]) + sink, err := newIncusSink(config) if err != nil { return nil, err } - return newExporterWithSink(instance, sink), nil + exp := newExporterWithSink(instance, sink) + exp.project = config["project"] + return exp, nil } func newExporterWithSink(instance string, sink restoreSink) *Exporter { @@ -73,6 +70,12 @@ 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 } @@ -240,7 +243,7 @@ loop: if ret != nil { pw.CloseWithError(ret) } else { - pw.Close() + _ = pw.Close() } // The sink's error is the actionable one: it explains *why* the pipe diff --git a/incus/exporter/incus_api.go b/incus/exporter/incus_api.go index cbeb331..5326b30 100644 --- a/incus/exporter/incus_api.go +++ b/incus/exporter/incus_api.go @@ -21,20 +21,30 @@ import ( "fmt" "io" + "github.com/PlakarKorp/integration-incus/internal/conn" incus "github.com/lxc/incus/v6/client" ) type incusSink struct { - server incus.InstanceServer - pool string + server incus.InstanceServer + pool string + project string + target string } -func newIncusSink(socket, pool string) (restoreSink, error) { - server, err := incus.ConnectIncusUnix(socket, nil) +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, fmt.Errorf("incus: connect %s: %w", socket, err) + return nil, err } - return &incusSink{server: server, pool: pool}, nil + return &incusSink{ + server: server, + pool: config["pool"], + project: config["project"], + target: config["target"], + }, nil } func (s *incusSink) Ping(ctx context.Context) error { @@ -43,7 +53,27 @@ func (s *incusSink) Ping(ctx context.Context) error { } func (s *incusSink) Restore(ctx context.Context, instance string, tarStream io.Reader) error { - op, err := s.server.CreateInstanceFromBackup(incus.InstanceBackupArgs{ + // 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, 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 index d1da787..f11e293 100644 --- a/incus/exporter/incus_test.go +++ b/incus/exporter/incus_test.go @@ -15,6 +15,22 @@ import ( "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 @@ -108,7 +124,7 @@ type earlyFailSink struct { 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) + _, _ = io.CopyN(io.Discard, tarStream, s.readN) return s.err } @@ -396,7 +412,9 @@ func TestRoundtripXattrSurvivesImportExport(t *testing.T) { if _, err := stw.Write([]byte(body)); err != nil { t.Fatal(err) } - stw.Close() + 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). diff --git a/incus/exporter/schema.json b/incus/exporter/schema.json index fd34b61..54d6a85 100644 --- a/incus/exporter/schema.json +++ b/incus/exporter/schema.json @@ -14,11 +14,46 @@ "socket": { "type": "string", "default": "/var/lib/incus/unix.socket", - "description": "Path to the 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)" } } } From a322683e99577f15f59214246b36547b949ed95d Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Fri, 10 Jul 2026 15:00:08 +0200 Subject: [PATCH 20/22] incus: live e2e roundtrip test, lint target and dynamic version Validated on a 3-member cluster; instances are pinned to the local member because cross-node exec output arrives truncated over the local unix socket. --- incus/Makefile | 11 +- incus/e2e/roundtrip_test.go | 460 ++++++++++++++++++++++++++++++++++++ 2 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 incus/e2e/roundtrip_test.go diff --git a/incus/Makefile b/incus/Makefile index 1b909f3..227fabc 100644 --- a/incus/Makefile +++ b/incus/Makefile @@ -1,6 +1,7 @@ GO=go PLAKAR=plakar -VERSION=v0.1.0 +VERSION?=$(shell git describe --tags --always) +GOLANGCI_LINT_VERSION=v2.12.2 all: build @@ -18,5 +19,13 @@ install: pkg 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/e2e/roundtrip_test.go b/incus/e2e/roundtrip_test.go new file mode 100644 index 0000000..aa3a061 --- /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/integration-incus/exporter" + pimporter "github.com/PlakarKorp/integration-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() + } +} From edea8ac4302fd7e1f38ac805cc3b6b5c6c095f65 Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Fri, 10 Jul 2026 15:00:08 +0200 Subject: [PATCH 21/22] incus: document requirements, options, exclusions and remote usage --- incus/README.md | 240 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 230 insertions(+), 10 deletions(-) diff --git a/incus/README.md b/incus/README.md index 168a83d..55744e3 100644 --- a/incus/README.md +++ b/incus/README.md @@ -2,25 +2,249 @@ Backup and restore Incus containers as browsable file-level snapshots. -## Importer +**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. -Import filesystem contents from Incus containers for backup: +## 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 ``` -## Exporter +Every file of the container's root filesystem becomes an individually +browsable, deduplicated entry in the repository: -Restore backups to new or existing Incus containers: +```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 ``` -## Notes +### Remote servers (HTTPS + client certificate) -This integration backs up containers only and runs on the Incus host using the local unix socket. +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 @@ -29,7 +253,3 @@ This integration backs up containers only and runs on the Incus host using the l - `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. - -Known v1 caveats: hardlinks round-trip as true hardlinks incus-to-incus, but are materialized as relative symlinks on non-Incus restore destinations (true hardlink identity is not representable in the connector protocol). - -Extended attributes and file capabilities (e.g. `security.capability`, needed for `ping`'s `cap_net_raw`) are preserved incus-to-incus: the importer replays each tar entry's PAX `SCHILY.xattr.*` records as kloset xattr records, and the exporter reinjects them into the rebuilt tar's PAX records. Restoring to a generic (non-tar-based) destination depends on that connector's own xattr handling to apply them. From c84144467ea9d1f74b0b4369554ef6312e25cf34 Mon Sep 17 00:00:00 2001 From: Antoine DHEYGERS Date: Fri, 10 Jul 2026 15:08:15 +0200 Subject: [PATCH 22/22] incus: rename module to match integrations repo layout --- incus/e2e/roundtrip_test.go | 4 ++-- incus/exporter/incus_api.go | 2 +- incus/go.mod | 2 +- incus/importer/incus_api.go | 2 +- incus/importer/incus_api_integration_test.go | 2 +- incus/manifest.yaml | 4 ++-- incus/plugin/exporter/main.go | 2 +- incus/plugin/importer/main.go | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/incus/e2e/roundtrip_test.go b/incus/e2e/roundtrip_test.go index aa3a061..17b25f3 100644 --- a/incus/e2e/roundtrip_test.go +++ b/incus/e2e/roundtrip_test.go @@ -45,8 +45,8 @@ import ( incus "github.com/lxc/incus/v6/client" "github.com/lxc/incus/v6/shared/api" - pexporter "github.com/PlakarKorp/integration-incus/exporter" - pimporter "github.com/PlakarKorp/integration-incus/importer" + pexporter "github.com/PlakarKorp/integrations/incus/exporter" + pimporter "github.com/PlakarKorp/integrations/incus/importer" ) const socketPath = "/var/lib/incus/unix.socket" diff --git a/incus/exporter/incus_api.go b/incus/exporter/incus_api.go index 5326b30..4e9a15e 100644 --- a/incus/exporter/incus_api.go +++ b/incus/exporter/incus_api.go @@ -21,7 +21,7 @@ import ( "fmt" "io" - "github.com/PlakarKorp/integration-incus/internal/conn" + "github.com/PlakarKorp/integrations/incus/internal/conn" incus "github.com/lxc/incus/v6/client" ) diff --git a/incus/go.mod b/incus/go.mod index 7a39bd3..9b2f904 100644 --- a/incus/go.mod +++ b/incus/go.mod @@ -1,4 +1,4 @@ -module github.com/PlakarKorp/integration-incus +module github.com/PlakarKorp/integrations/incus go 1.25.6 diff --git a/incus/importer/incus_api.go b/incus/importer/incus_api.go index 2d1cd98..af738cb 100644 --- a/incus/importer/incus_api.go +++ b/incus/importer/incus_api.go @@ -24,7 +24,7 @@ import ( "strings" "time" - "github.com/PlakarKorp/integration-incus/internal/conn" + "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" diff --git a/incus/importer/incus_api_integration_test.go b/incus/importer/incus_api_integration_test.go index 1c02c60..2e32048 100644 --- a/incus/importer/incus_api_integration_test.go +++ b/incus/importer/incus_api_integration_test.go @@ -7,7 +7,7 @@ import ( "os" "testing" - "github.com/PlakarKorp/integration-incus/internal/conn" + "github.com/PlakarKorp/integrations/incus/internal/conn" ) func TestIncusSourcePing(t *testing.T) { diff --git a/incus/manifest.yaml b/incus/manifest.yaml index cf0bc92..a9510d9 100644 --- a/incus/manifest.yaml +++ b/incus/manifest.yaml @@ -7,14 +7,14 @@ contact: mailto:antoine.dheygers@cryptoweb.fr connectors: - type: importer executable: incusImporter - homepage: https://github.com/PlakarKorp/integration-incus + 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/integration-incus + homepage: https://github.com/PlakarKorp/integrations/tree/main/incus license: ISC protocols: [incus] validator: ./exporter/schema.json diff --git a/incus/plugin/exporter/main.go b/incus/plugin/exporter/main.go index f6c95da..d99d9ae 100644 --- a/incus/plugin/exporter/main.go +++ b/incus/plugin/exporter/main.go @@ -20,7 +20,7 @@ import ( "os" sdk "github.com/PlakarKorp/go-kloset-sdk" - "github.com/PlakarKorp/integration-incus/exporter" + "github.com/PlakarKorp/integrations/incus/exporter" ) func main() { diff --git a/incus/plugin/importer/main.go b/incus/plugin/importer/main.go index b4a60cf..18af74f 100644 --- a/incus/plugin/importer/main.go +++ b/incus/plugin/importer/main.go @@ -20,7 +20,7 @@ import ( "os" sdk "github.com/PlakarKorp/go-kloset-sdk" - "github.com/PlakarKorp/integration-incus/importer" + "github.com/PlakarKorp/integrations/incus/importer" ) func main() {