From 366e2c2e46288a5e8252f1ea34fdf1572a2ddca3 Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Fri, 2 Oct 2026 15:55:08 +1000 Subject: [PATCH] refactor: move image catalogue to operator --- cmd/spinifex/cmd/admin.go | 29 +- cmd/spinifex/cmd/admin_test.go | 16 +- docs/oci-integration/README.md | 2 +- scripts/mkosi-publish.sh | 2 +- scripts/publish-system-image.sh | 2 +- .../imagecatalog}/images.go | 2 +- .../imagecatalog}/images_test.go | 4 +- spinifex/operator/imagecatalog/verify_test.go | 693 ++++++++++++++++++ spinifex/utils/utils_test.go | 685 ----------------- tests/e2e/gpu/eks_gpu_test.go | 2 +- tests/e2e/single/imagecatalog_test.go | 8 +- 11 files changed, 727 insertions(+), 718 deletions(-) rename spinifex/{utils => operator/imagecatalog}/images.go (99%) rename spinifex/{utils => operator/imagecatalog}/images_test.go (99%) create mode 100644 spinifex/operator/imagecatalog/verify_test.go diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index d10b110ed..d3e2c1e76 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -36,6 +36,7 @@ import ( handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" "github.com/mulgadc/spinifex/spinifex/network/host" + "github.com/mulgadc/spinifex/spinifex/operator/imagecatalog" operatorprogress "github.com/mulgadc/spinifex/spinifex/operator/progress" "github.com/mulgadc/spinifex/spinifex/providers/ebs" "github.com/mulgadc/spinifex/spinifex/providers/objectstore" @@ -506,7 +507,7 @@ const catalogPinnedDigestSource = "catalog:pinned" // importSource is the file an import is about to extract, and what it is to be verified against. type importSource struct { imageFile, imageName, localFile string - image utils.Images + image imagecatalog.Images checksumPath, checksumAlgo, expectedDigest string skipVerify bool } @@ -525,7 +526,7 @@ func (src importSource) resolveDigest(out, errOut io.Writer) (ebsmetadata.ImageD // A pinned digest is checked against this file rather than a sums file // fetched from the same host that served the image. if src.image.ChecksumDigest != "" { - actual, err := utils.VerifyImageDigest(src.imageFile, src.image.ChecksumType, src.image.ChecksumDigest) + actual, err := imagecatalog.VerifyImageDigest(src.imageFile, src.image.ChecksumType, src.image.ChecksumDigest) if err != nil { printChecksumError(errOut, src.imageFile, src.imageName, src.image, err) return digest, err @@ -535,7 +536,7 @@ func (src importSource) resolveDigest(out, errOut io.Writer) (ebsmetadata.ImageD digest.Verification = ebsmetadata.DigestCatalog return digest, nil } - actual, err := utils.VerifyImageChecksum(src.imageFile, src.image.Checksum, src.image.ChecksumType) + actual, err := imagecatalog.VerifyImageChecksum(src.imageFile, src.image.Checksum, src.image.ChecksumType) if err != nil { printChecksumError(errOut, src.imageFile, src.imageName, src.image, err) return digest, err @@ -544,7 +545,7 @@ func (src importSource) resolveDigest(out, errOut io.Writer) (ebsmetadata.ImageD digest.Algorithm, digest.Value, digest.Source = src.image.ChecksumType, actual, src.image.Checksum digest.Verification = ebsmetadata.DigestCatalog case ebsmetadata.DigestOperator: - actual, err := utils.VerifyImageDigest(src.localFile, src.checksumAlgo, src.expectedDigest) + actual, err := imagecatalog.VerifyImageDigest(src.localFile, src.checksumAlgo, src.expectedDigest) if err != nil { fmt.Fprintf(errOut, "Image integrity verification failed: %v\n", err) fmt.Fprintf(errOut, " file: %s\n", src.localFile) @@ -560,7 +561,7 @@ func (src importSource) resolveDigest(out, errOut io.Writer) (ebsmetadata.ImageD } else { fmt.Fprintf(errOut, "⚠️ --skip-verify set: checksum verification skipped for %s\n", src.imageName) } - actual, err := utils.HashImageFile(src.imageFile, "sha256") + actual, err := imagecatalog.HashImageFile(src.imageFile, "sha256") if err != nil { fmt.Fprintf(errOut, "Could not hash image: %v\n", err) return digest, err @@ -584,7 +585,7 @@ func sourceDigestMode(localFile, checksumPath string, skipVerify bool) string { } func runimagesImportCmd(cmd *cobra.Command, args []string) { - var image utils.Images + var image imagecatalog.Images var imageFile string var imageStat os.FileInfo @@ -629,7 +630,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { } var checksumAlgo, expectedDigest string if checksumPath != "" { - checksumAlgo, expectedDigest, err = utils.ReadExpectedDigest(checksumPath, filepath.Base(localFile)) + checksumAlgo, expectedDigest, err = imagecatalog.ReadExpectedDigest(checksumPath, filepath.Base(localFile)) if err != nil { fmt.Fprintf(os.Stderr, "Could not read checksum from %s: %v\n", checksumPath, err) os.Exit(1) @@ -638,7 +639,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { if imageName != "" { var exists bool - image, exists = utils.AvailableImages[imageName] + image, exists = imagecatalog.AvailableImages[imageName] if !exists { fmt.Fprintf(os.Stderr, "Image name not found in available images") os.Exit(1) @@ -763,7 +764,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { os.Exit(1) } - extractedImagePath, err := utils.ExtractDiskImageFromFile(imageFile, imagePath) + extractedImagePath, err := imagecatalog.ExtractDiskImageFromFile(imageFile, imagePath) if err != nil { fmt.Fprintf(os.Stderr, "Could not extract image: %v\n", err) os.Exit(1) @@ -797,7 +798,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { SnapshotID: admin.SnapPrefix(volumeId), BootMode: image.BootMode, Distro: image.Distro, - DistroFamily: utils.DistroFamily(image.Distro), + DistroFamily: imagecatalog.DistroFamily(image.Distro), SourceDigest: &sourceDigest, } @@ -1197,11 +1198,11 @@ func runimagesListCmd(cmd *cobra.Command, args []string) { } // Sort A→Z then iterate. - keys := slices.Sorted(maps.Keys(utils.AvailableImages)) + keys := slices.Sorted(maps.Keys(imagecatalog.AvailableImages)) for _, k := range keys { - img := utils.AvailableImages[k] + img := imagecatalog.AvailableImages[k] - //for _, img := range utils.AvailableImages { + //for _, img := range imagecatalog.AvailableImages { tableData = append(tableData, []string{img.Name, img.Distro, img.Version, img.Arch, img.BootMode}) } @@ -3948,7 +3949,7 @@ func resolveIfaceIP(iface string) string { // to investigate), and the exact --force recovery command. The cached file // is left in place: an implicit auto-delete would mutate state inside // "verify", and a tampered artifact is forensically useful intact. -func printChecksumError(w io.Writer, imageFile, imageName string, image utils.Images, err error) { +func printChecksumError(w io.Writer, imageFile, imageName string, image imagecatalog.Images, err error) { fmt.Fprintf(w, "Image integrity verification failed: %v\n", err) fmt.Fprintf(w, " file: %s\n", imageFile) if image.ChecksumDigest != "" { diff --git a/cmd/spinifex/cmd/admin_test.go b/cmd/spinifex/cmd/admin_test.go index c99e0009d..1690dd0d1 100644 --- a/cmd/spinifex/cmd/admin_test.go +++ b/cmd/spinifex/cmd/admin_test.go @@ -20,9 +20,9 @@ import ( "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata" handlers_dns "github.com/mulgadc/spinifex/spinifex/handlers/dns" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + "github.com/mulgadc/spinifex/spinifex/operator/imagecatalog" "github.com/mulgadc/spinifex/spinifex/runtime/formation" "github.com/mulgadc/spinifex/spinifex/runtime/host/dns" - "github.com/mulgadc/spinifex/spinifex/utils" toml "github.com/pelletier/go-toml/v2" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -34,13 +34,13 @@ import ( // mismatch, so a 404/non-HTTPS/size-cap failure still tells the operator // which URL to investigate. func TestPrintChecksumError(t *testing.T) { - image := utils.Images{Checksum: "https://example.com/SUMS", ChecksumType: "sha512"} + image := imagecatalog.Images{Checksum: "https://example.com/SUMS", ChecksumType: "sha512"} const imageFile = "/var/lib/img.tar.xz" const imageName = "debian-13-x86_64" errs := []error{ - fmt.Errorf("%w: expected abc got def", utils.ErrChecksumMismatch), - fmt.Errorf("%w: 404", utils.ErrChecksumFetchFailed), + fmt.Errorf("%w: expected abc got def", imagecatalog.ErrChecksumMismatch), + fmt.Errorf("%w: 404", imagecatalog.ErrChecksumFetchFailed), errors.New("open /x: no such file"), } for _, e := range errs { @@ -1398,7 +1398,7 @@ func TestImportSourceResolveDigest(t *testing.T) { imageFile: img, localFile: img, checksumPath: sums, checksumAlgo: "sha256", expectedDigest: strings.Repeat("0", 64), }.resolveDigest(&out, &errOut) - require.ErrorIs(t, err, utils.ErrChecksumMismatch) + require.ErrorIs(t, err, imagecatalog.ErrChecksumMismatch) assert.Contains(t, errOut.String(), "file: "+img) assert.Contains(t, errOut.String(), "checksum: "+sums) assert.Contains(t, errOut.String(), imgHex) @@ -1438,7 +1438,7 @@ func TestImportSourceResolveDigest(t *testing.T) { var out, errOut bytes.Buffer got, err := importSource{ imageFile: img, imageName: "oracle-10.1-x86_64", - image: utils.Images{ChecksumType: "sha256", ChecksumDigest: imgHex}, + image: imagecatalog.Images{ChecksumType: "sha256", ChecksumDigest: imgHex}, }.resolveDigest(&out, &errOut) require.NoError(t, err) assert.Equal(t, ebsmetadata.ImageDigest{ @@ -1453,9 +1453,9 @@ func TestImportSourceResolveDigest(t *testing.T) { wrong := strings.Repeat("a", 64) _, err := importSource{ imageFile: img, imageName: "oracle-10.1-x86_64", - image: utils.Images{ChecksumType: "sha256", ChecksumDigest: wrong, URL: "https://example.invalid/ol.qcow2"}, + image: imagecatalog.Images{ChecksumType: "sha256", ChecksumDigest: wrong, URL: "https://example.invalid/ol.qcow2"}, }.resolveDigest(&out, &errOut) - require.ErrorIs(t, err, utils.ErrChecksumMismatch) + require.ErrorIs(t, err, imagecatalog.ErrChecksumMismatch) assert.Contains(t, errOut.String(), catalogPinnedDigestSource) assert.Contains(t, errOut.String(), "https://example.invalid/ol.qcow2") assert.FileExists(t, img, "the downloaded file must be left in place") diff --git a/docs/oci-integration/README.md b/docs/oci-integration/README.md index bf6e7043c..f50281d48 100644 --- a/docs/oci-integration/README.md +++ b/docs/oci-integration/README.md @@ -878,7 +878,7 @@ ssh -i path/to/key.pem cloud-user@ ### Why these four pin a checksum digest inline -Every other catalog entry verifies against the publisher's sums file. Oracle ships none — the digests are published as HTML on `yum.oracle.com/oracle-linux-templates.html` — so these four carry `ChecksumDigest` in `spinifex/utils/images.go` instead. That is safe **only** because each URL names an immutable build (`b291`, `b293`, `b178`, `b182`) and Oracle never moves one. A new point release is a new URL and a new digest, never an edit to an existing entry. +Every other catalog entry verifies against the publisher's sums file. Oracle ships none — the digests are published as HTML on `yum.oracle.com/oracle-linux-templates.html` — so these four carry `ChecksumDigest` in `spinifex/operator/imagecatalog/images.go` instead. That is safe **only** because each URL names an immutable build (`b291`, `b293`, `b178`, `b182`) and Oracle never moves one. A new point release is a new URL and a new digest, never an edit to an existing entry. On arm64 Oracle publishes a `-kvm-cloud-` build alongside a plain `-kvm-` one. The catalog takes `-kvm-cloud-`: it is the one with cloud-init, and so the one that matches x86_64's `-kvm-`. The plain arm64 build imports fine and then has no datasource, which surfaces as an instance with no key and no metadata rather than as an import error. diff --git a/scripts/mkosi-publish.sh b/scripts/mkosi-publish.sh index b6de8f47e..a3684d4a4 100755 --- a/scripts/mkosi-publish.sh +++ b/scripts/mkosi-publish.sh @@ -5,7 +5,7 @@ # Converts mkosi's raw output to a compressed qcow2, writes the .sha256 sidecar # in the coreutils format the spx catalog verifier matches, and uploads both to # the bucket behind iso.mulgadc.com/system-ami/. The object name must match the -# basename of the catalog entry's URL in spinifex/utils/images.go. +# basename of the catalog entry's URL in spinifex/operator/imagecatalog/images.go. # # Unlike the build, this needs no relaxed confinement: qemu-img and aws create # no namespaces, so the container keeps Docker's default seccomp and AppArmor diff --git a/scripts/publish-system-image.sh b/scripts/publish-system-image.sh index 601b20d10..8897249a8 100755 --- a/scripts/publish-system-image.sh +++ b/scripts/publish-system-image.sh @@ -7,7 +7,7 @@ set -euo pipefail # qcow2, writes a .sha256 sidecar (coreutils format, matched by the spx catalog # checksum verifier), and uploads both to the R2 bucket that backs # iso.mulgadc.com/system-ami/. The matching catalog entry in -# spinifex/spinifex/utils/images.go then lets operators run: +# spinifex/spinifex/operator/imagecatalog/images.go then lets operators run: # # spx admin images import --name # diff --git a/spinifex/utils/images.go b/spinifex/operator/imagecatalog/images.go similarity index 99% rename from spinifex/utils/images.go rename to spinifex/operator/imagecatalog/images.go index c8591ac86..d37c307ca 100644 --- a/spinifex/utils/images.go +++ b/spinifex/operator/imagecatalog/images.go @@ -1,4 +1,4 @@ -package utils +package imagecatalog import ( "bufio" diff --git a/spinifex/utils/images_test.go b/spinifex/operator/imagecatalog/images_test.go similarity index 99% rename from spinifex/utils/images_test.go rename to spinifex/operator/imagecatalog/images_test.go index 3ed168bce..59e18453f 100644 --- a/spinifex/utils/images_test.go +++ b/spinifex/operator/imagecatalog/images_test.go @@ -1,4 +1,4 @@ -package utils +package imagecatalog import ( "bytes" @@ -63,7 +63,7 @@ func TestWithheldImages_VLLMServingCarriesBedrockTags(t *testing.T) { // it decodes to something validateDiskImagePath's file(1) sniff already accepts elsewhere. func rawFixture(t *testing.T) []byte { t.Helper() - data, err := os.ReadFile(filepath.Join("..", "..", "tests", "unit-test-disk-image.raw")) + data, err := os.ReadFile(filepath.Join("..", "..", "..", "tests", "unit-test-disk-image.raw")) if err != nil { t.Fatalf("read raw fixture: %v", err) } diff --git a/spinifex/operator/imagecatalog/verify_test.go b/spinifex/operator/imagecatalog/verify_test.go new file mode 100644 index 000000000..2e19811e0 --- /dev/null +++ b/spinifex/operator/imagecatalog/verify_test.go @@ -0,0 +1,693 @@ +package imagecatalog + +import ( + "crypto/sha256" + "crypto/sha512" + "crypto/x509" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractDiskImageFromFile(t *testing.T) { + tmpDir := t.TempDir() + + t.Log("Temp dir:", tmpDir) + + // Sample .xz (fail) + imagePath, err := ExtractDiskImageFromFile("/tmp/file.xz", tmpDir) + + assert.Empty(t, imagePath, "Should be blank") + assert.Error(t, err, "Should error") + + // Sample incorrect image (fail) + imagePath, err = ExtractDiskImageFromFile("../../../tests/ebs.json", tmpDir) + + assert.Empty(t, imagePath, "Should be blank") + assert.Error(t, err, "Should error") + assert.ErrorContains(t, err, "unsupported filetype") + + // Sample incorrect image (fail) + imagePath, err = ExtractDiskImageFromFile("../../../tests/unit-test-disk-image-bad.raw", tmpDir) + + assert.NotEmpty(t, imagePath, "Should be blank") + assert.Error(t, err, "Should error") + assert.ErrorContains(t, err, "no valid disk image found") + + // Sample raw + imagePath, err = ExtractDiskImageFromFile("../../../tests/unit-test-disk-image.raw", tmpDir) + + assert.NotEmpty(t, imagePath, "Should not be blank") + assert.Contains(t, imagePath, ".raw") + + assert.NoError(t, err, "Should not error") + + _, err = exec.LookPath("tar") + + if err == nil { + // Sample .tgz + imagePath, err = ExtractDiskImageFromFile("../../../tests/unit-test-disk-image2.tgz", tmpDir) + + assert.NotEmpty(t, imagePath, "Should not be blank") + assert.Contains(t, imagePath, ".img") + + assert.NoError(t, err, "Should not error") + + // Sample .tar + imagePath, err = ExtractDiskImageFromFile("../../../tests/unit-test-disk-image.tar", tmpDir) + + assert.NotEmpty(t, imagePath, "Should not be blank") + assert.Contains(t, imagePath, ".raw") + + assert.NoError(t, err, "Should not error") + + // Sample .tar.gz + imagePath, err = ExtractDiskImageFromFile("../../../tests/unit-test-disk-image.tar.gz", tmpDir) + + assert.NotEmpty(t, imagePath, "Should not be blank") + assert.Contains(t, imagePath, ".raw") + + assert.NoError(t, err, "Should not error") + + // Sample xz + imagePath, err = ExtractDiskImageFromFile("../../../tests/unit-test-disk-image.tar.xz", tmpDir) + + assert.NotEmpty(t, imagePath, "Should not be blank") + assert.Contains(t, imagePath, ".raw") + + assert.NoError(t, err, "Should not error") + + // Sample tgz + imagePath, err = ExtractDiskImageFromFile("../../../tests/unit-test-disk-image.tgz", tmpDir) + + assert.NotEmpty(t, imagePath, "Should not be blank") + assert.Contains(t, imagePath, ".raw") + + assert.NoError(t, err, "Should not error") + } else { + t.Skip("tar command not found, skipping archive extraction tests") + } + + //err = os.RemoveAll(tmpDir) + //assert.NoError(t, err, "Could not remove temp dir") +} + +func TestExtractDiskImagePath_NoMatch(t *testing.T) { + output := []byte("somefile.txt\nanotherfile.conf\n") + diskimage, err := extractDiskImagePath(t.TempDir(), output) + assert.Empty(t, diskimage) + assert.NoError(t, err) +} + +func TestExtractDiskImagePath_EmptyOutput(t *testing.T) { + diskimage, err := extractDiskImagePath(t.TempDir(), []byte{}) + assert.Empty(t, diskimage) + assert.NoError(t, err) +} + +// writeTempImage writes content to a file named name under t.TempDir() and +// returns the absolute path. +func writeTempImage(t *testing.T, name string, content []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, content, 0o600)) + return path +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func sha512Hex(b []byte) string { + sum := sha512.Sum512(b) + return hex.EncodeToString(sum[:]) +} + +// newSumsServer stands up a TLS test server returning body for any request, +// configures the package-level trust hook, and registers cleanup. Returns the +// server URL. +func newSumsServer(t *testing.T, body string) string { + t.Helper() + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + trustTestServer(t, srv) + return srv.URL + "/SUMS" +} + +// trustTestServer adds srv's certificate to the package-level trust pool so +// VerifyImageChecksum's HTTPS-only client accepts it. Restores the previous +// pool on test cleanup. +func trustTestServer(t *testing.T, srv *httptest.Server) { + t.Helper() + prev := checksumExtraRootCAs + pool := x509.NewCertPool() + if prev != nil { + pool = prev.Clone() + } + pool.AddCert(srv.Certificate()) + checksumExtraRootCAs = pool + t.Cleanup(func() { checksumExtraRootCAs = prev }) +} + +func TestVerifyImageChecksum(t *testing.T) { + const debianName = "debian-13-genericcloud-amd64-20260518-2482.tar.xz" + const ubuntuName = "resolute-server-cloudimg-amd64.img" + const alpineName = "alb-alpine-3.21.6-x86_64.raw" + + debianBytes := []byte("debian-image-bytes-fixture") + ubuntuBytes := []byte("ubuntu-image-bytes-fixture") + alpineBytes := []byte("alpine-image-bytes-fixture") + + t.Run("debian sha512 multi-line match", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + body := fmt.Sprintf("%s %s\n%s other-file\n", + sha512Hex(debianBytes), debianName, sha512Hex([]byte("unrelated"))) + url := newSumsServer(t, body) + got, err := VerifyImageChecksum(img, url, "sha512") + require.NoError(t, err) + assert.Equal(t, sha512Hex(debianBytes), got) + }) + + t.Run("ubuntu sha256 binary-mode asterisk match", func(t *testing.T) { + img := writeTempImage(t, ubuntuName, ubuntuBytes) + body := fmt.Sprintf("%s *%s\n", sha256Hex(ubuntuBytes), ubuntuName) + url := newSumsServer(t, body) + _, err := VerifyImageChecksum(img, url, "sha256") + assert.NoError(t, err) + }) + + t.Run("alpine single-line named match", func(t *testing.T) { + img := writeTempImage(t, alpineName, alpineBytes) + body := fmt.Sprintf("%s %s\n", sha512Hex(alpineBytes), alpineName) + url := newSumsServer(t, body) + _, err := VerifyImageChecksum(img, url, "sha512") + assert.NoError(t, err) + }) + + t.Run("alpine bare-digest fallback", func(t *testing.T) { + img := writeTempImage(t, alpineName, alpineBytes) + body := sha512Hex(alpineBytes) + "\n" + url := newSumsServer(t, body) + _, err := VerifyImageChecksum(img, url, "sha512") + assert.NoError(t, err) + }) + + t.Run("case-sensitive filename mismatch", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + // Capitalised "Debian-..." in sums file: legitimate divergence from upstream + // convention, treated as not-found. + body := fmt.Sprintf("%s Debian-12-generic-amd64.tar.xz\n", sha512Hex(debianBytes)) + url := newSumsServer(t, body) + _, err := VerifyImageChecksum(img, url, "sha512") + assert.ErrorIs(t, err, ErrChecksumNotFound) + }) + + t.Run("comment lines skipped", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + body := fmt.Sprintf("# Hash file generated by upstream\n# PGP-signed below\n%s %s\n", + sha512Hex(debianBytes), debianName) + url := newSumsServer(t, body) + _, err := VerifyImageChecksum(img, url, "sha512") + assert.NoError(t, err) + }) + + t.Run("digest mismatch", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + wrong := sha512Hex([]byte("a different blob entirely")) + body := fmt.Sprintf("%s %s\n", wrong, debianName) + url := newSumsServer(t, body) + _, err := VerifyImageChecksum(img, url, "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumMismatch) + // Both expected and actual digests should be in the rendered error so + // operators can compare without scraping slog. + assert.Contains(t, err.Error(), wrong) + assert.Contains(t, err.Error(), sha512Hex(debianBytes)) + }) + + t.Run("filename absent from sums file", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + body := fmt.Sprintf("%s some-other-file.tar.xz\n%s yet-another.iso\n", + sha512Hex([]byte("a")), sha512Hex([]byte("b"))) + url := newSumsServer(t, body) + _, err := VerifyImageChecksum(img, url, "sha512") + assert.ErrorIs(t, err, ErrChecksumNotFound) + }) + + t.Run("unsupported checksum type rejected before any fetch", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + // Server URL is gibberish — function must fail before reaching it. + _, err := VerifyImageChecksum(img, "https://example.invalid/SUMS", "md5") + assert.ErrorIs(t, err, ErrUnsupportedChecksumType) + }) + + t.Run("http 404 wraps fetch failure", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + trustTestServer(t, srv) + _, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + }) + + t.Run("non-https initial url rejected before any request", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + // Hit a port nothing listens on — if HTTPS check ever regresses we'd + // still get an error, but we want ErrChecksumFetchFailed wrapping the + // scheme refusal specifically. + _, err := VerifyImageChecksum(img, "http://127.0.0.1:1/SUMS", "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + assert.Contains(t, err.Error(), "non-https") + }) + + t.Run("non-https redirect refused", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + // Plain-HTTP target the TLS server will try to redirect us to. + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "%s %s\n", sha512Hex(debianBytes), debianName) + })) + t.Cleanup(plain.Close) + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, plain.URL+"/SUMS", http.StatusFound) + })) + t.Cleanup(srv.Close) + trustTestServer(t, srv) + _, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + }) + + t.Run("redirect cap exceeded", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + var hits atomic.Int32 + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + http.Redirect(w, r, r.URL.Path, http.StatusFound) + })) + t.Cleanup(srv.Close) + trustTestServer(t, srv) + _, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + assert.GreaterOrEqual(t, int(hits.Load()), 10) + }) + + t.Run("sums file size cap exceeded", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + // Body of 2 MB of unrelated entries — over the 1 MB cap. + var b strings.Builder + junk := strings.Repeat("a", 128) + for b.Len() <= sumsFileMaxSize+1 { + fmt.Fprintf(&b, "%s %s.bin\n", sha512Hex([]byte(junk)), junk) + } + url := newSumsServer(t, b.String()) + _, err := VerifyImageChecksum(img, url, "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + assert.Contains(t, err.Error(), "exceeds") + }) + + t.Run("context deadline exceeded wraps fetch failure", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + t.Cleanup(srv.Close) + trustTestServer(t, srv) + + prev := checksumFetchTimeout + checksumFetchTimeout = 100 * time.Millisecond + t.Cleanup(func() { checksumFetchTimeout = prev }) + + _, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + }) + + t.Run("digest length mismatch rejected before hashing", func(t *testing.T) { + // sha256 digest served, caller declares sha512: algorithm disagreement, + // not tampering. Must be distinct from ErrChecksumMismatch. + img := writeTempImage(t, debianName, debianBytes) + body := fmt.Sprintf("%s %s\n", sha256Hex(debianBytes), debianName) + url := newSumsServer(t, body) + _, err := VerifyImageChecksum(img, url, "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + assert.NotErrorIs(t, err, ErrChecksumMismatch) + assert.Contains(t, err.Error(), "digest length") + }) + + t.Run("empty image file rejected with clear error", func(t *testing.T) { + img := writeTempImage(t, debianName, nil) + body := fmt.Sprintf("%s %s\n", sha512Hex(nil), debianName) + url := newSumsServer(t, body) + _, err := VerifyImageChecksum(img, url, "sha512") + require.Error(t, err) + assert.NotErrorIs(t, err, ErrChecksumMismatch) + assert.Contains(t, err.Error(), "empty") + }) + + t.Run("transport error wrapped", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + // Stand up a TLS server, trust it, then close immediately so any + // subsequent request fails at the transport layer. + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + trustTestServer(t, srv) + url := srv.URL + "/SUMS" + srv.Close() + _, err := VerifyImageChecksum(img, url, "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + }) + + t.Run("rocky bsd-style gpg-armored sums match", func(t *testing.T) { + const rockyName = "Rocky-10.0-20250612.0.x86_64.qcow2" + rockyBytes := []byte("rocky-image-bytes-fixture") + img := writeTempImage(t, rockyName, rockyBytes) + // Captured shape of a real Rocky CHECKSUM file: GPG cleartext-signed + // armor wrapping BSD-style "SHA256 (name) = hex" lines, followed by an + // ASCII-armored signature block we must skip without misparsing. + body := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +SHA256 (Rocky-10.0-20250612.0-x86_64-boot.iso) = %s +SHA256 (%s) = %s +SHA256 (Rocky-10.0-20250612.0-x86_64-dvd.iso) = %s +-----BEGIN PGP SIGNATURE----- + +iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm +nopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+/abcdefghijklmn +=ABCD +-----END PGP SIGNATURE----- +`, sha256Hex([]byte("boot-iso")), rockyName, sha256Hex(rockyBytes), sha256Hex([]byte("dvd-iso"))) + url := newSumsServer(t, body) + _, err := VerifyImageChecksum(img, url, "sha256") + assert.NoError(t, err) + }) + + t.Run("alma bsd-style gpg-armored sums match", func(t *testing.T) { + const almaName = "AlmaLinux-10-GenericCloud-10.0-x86_64.qcow2" + almaBytes := []byte("alma-image-bytes-fixture") + img := writeTempImage(t, almaName, almaBytes) + body := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +SHA256 (%s) = %s +-----BEGIN PGP SIGNATURE----- + +iHUEABYKAB0WIQTm5n8/yqv7tQAaIQAaIQAFAmJabcdEFGhijklmnopqrstuvwxyz +=EFGH +-----END PGP SIGNATURE----- +`, almaName, sha256Hex(almaBytes)) + url := newSumsServer(t, body) + _, err := VerifyImageChecksum(img, url, "sha256") + assert.NoError(t, err) + }) + + t.Run("bsd-style filename absent", func(t *testing.T) { + const rockyName = "Rocky-10.0-20250612.0.x86_64.qcow2" + rockyBytes := []byte("rocky-image-bytes-fixture") + img := writeTempImage(t, rockyName, rockyBytes) + body := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +SHA256 (Rocky-10.0-20250612.0-x86_64-boot.iso) = %s +-----BEGIN PGP SIGNATURE----- + +iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm +=ABCD +-----END PGP SIGNATURE----- +`, sha256Hex([]byte("boot-iso"))) + url := newSumsServer(t, body) + _, err := VerifyImageChecksum(img, url, "sha256") + assert.ErrorIs(t, err, ErrChecksumNotFound) + }) +} + +func TestParseSumsFile(t *testing.T) { + const target = "Rocky-10.0-20250612.0.x86_64.qcow2" + const targetHex = "a3f1c2d4e5f60718293a4b5c6d7e8f9012345678901234567890abcdefabcdef" + + t.Run("bsd 4-field line", func(t *testing.T) { + body := []byte("SHA256 (" + target + ") = " + targetHex + "\n") + got, err := parseSumsFile(body, target) + require.NoError(t, err) + assert.Equal(t, targetHex, got) + }) + + t.Run("bsd missing equals separator skipped", func(t *testing.T) { + // Malformed line — third field is not "=". Must not match. + body := []byte("SHA256 (" + target + ") - " + targetHex + "\n") + _, err := parseSumsFile(body, target) + assert.ErrorIs(t, err, ErrChecksumNotFound) + }) + + t.Run("bsd missing parens skipped", func(t *testing.T) { + // Without parens the filename field is ambiguous; reject by not matching. + body := []byte("SHA256 " + target + " = " + targetHex + "\n") + _, err := parseSumsFile(body, target) + assert.ErrorIs(t, err, ErrChecksumNotFound) + }) + + t.Run("gpg-armored body with no sums returns not found", func(t *testing.T) { + // PGP signatures always have ≥ 2 bare-token lines (base64 + =CRC), so bareCount stays ≥ 2 and NotFound is returned. + body := []byte(`-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +-----BEGIN PGP SIGNATURE----- + +iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm +=ABCD +-----END PGP SIGNATURE----- +`) + _, err := parseSumsFile(body, target) + assert.ErrorIs(t, err, ErrChecksumNotFound) + }) + + t.Run("mixed bsd and coreutils shapes", func(t *testing.T) { + // Defensive: a sums file with both shapes should still resolve the + // requested filename regardless of which line carries it. + body := []byte("# header\n" + + targetHex + " some-other.iso\n" + + "SHA256 (" + target + ") = " + targetHex + "\n") + got, err := parseSumsFile(body, target) + require.NoError(t, err) + assert.Equal(t, targetHex, got) + }) +} + +func TestReadExpectedDigest(t *testing.T) { + const image = "debian-13-generic-amd64.tar.xz" + imgBytes := []byte("local-image-bytes-fixture") + h256, h512 := sha256Hex(imgBytes), sha512Hex(imgBytes) + other := sha512Hex([]byte("other")) + + cases := []struct { + name string + sumsName string + body string + imageName string + dir bool + missing bool + wantAlgo string + wantDigest string + wantErr error + wantErrMsg string + }{ + {name: "coreutils text sha512", sumsName: "SHA512SUMS", + body: h512 + " " + image + "\n" + other + " other.iso\n", wantAlgo: "sha512", wantDigest: h512}, + {name: "coreutils binary sha256", sumsName: "SHA256SUMS", + body: h256 + " *" + image + "\n", wantAlgo: "sha256", wantDigest: h256}, + {name: "bsd sha256", sumsName: "CHECKSUM", + body: "SHA256 (" + image + ") = " + h256 + "\n", wantAlgo: "sha256", wantDigest: h256}, + {name: "bare sha512", sumsName: image + ".sha512", + body: h512 + "\n", wantAlgo: "sha512", wantDigest: h512}, + {name: "neutral sums name uses length", sumsName: "checksums.txt", + body: h256 + " " + image + "\n", wantAlgo: "sha256", wantDigest: h256}, + {name: "uppercase hex normalised", sumsName: "SHA512SUMS", + body: strings.ToUpper(h512) + " " + image + "\n", wantAlgo: "sha512", wantDigest: h512}, + {name: "sums file name does not decide the algorithm", sumsName: "SHA256SUMS", + body: h512 + " " + image + "\n", wantAlgo: "sha512", wantDigest: h512}, + {name: "renamed image in multi-entry file", sumsName: "SHA512SUMS", imageName: "renamed.tar.xz", + body: h512 + " " + image + "\n" + other + " other.iso\n", wantErr: ErrChecksumNotFound, wantErrMsg: "renamed.tar.xz"}, + {name: "path-prefixed entry names the prefix", sumsName: "image.sha512", + body: h512 + " ./" + image + "\n", wantErr: ErrChecksumNotFound, wantErrMsg: "directory prefix"}, + {name: "renamed image with bare digest", sumsName: "image.sha512", imageName: "renamed.tar.xz", + body: h512 + "\n", wantAlgo: "sha512", wantDigest: h512}, + {name: "sha1 length unsupported", sumsName: "checksums.txt", + body: strings.Repeat("a", 40) + " " + image + "\n", wantErr: ErrUnsupportedChecksumType}, + {name: "non-hex digest", sumsName: "checksums.txt", + body: strings.Repeat("g", 64) + " " + image + "\n", wantErr: ErrMalformedChecksum}, + {name: "oversized file", sumsName: "SHA512SUMS", + body: h512 + " " + image + "\n" + strings.Repeat("#", sumsFileMaxSize), wantErr: ErrMalformedChecksum}, + {name: "directory refused", sumsName: "SHA512SUMS", dir: true, wantErr: ErrMalformedChecksum}, + {name: "missing file refused", sumsName: "SHA512SUMS", missing: true, wantErr: os.ErrNotExist}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), tc.sumsName) + switch { + case tc.dir: + require.NoError(t, os.Mkdir(path, 0o755)) + case !tc.missing: + require.NoError(t, os.WriteFile(path, []byte(tc.body), 0o600)) + } + imageName := tc.imageName + if imageName == "" { + imageName = image + } + + algo, digest, err := ReadExpectedDigest(path, imageName) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + assert.Contains(t, err.Error(), tc.wantErrMsg) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantAlgo, algo) + assert.Equal(t, tc.wantDigest, digest) + }) + } +} + +func TestVerifyImageDigest(t *testing.T) { + imgBytes := []byte("verify-digest-fixture") + img := writeTempImage(t, "image.raw", imgBytes) + + t.Run("sha256 match returns digest", func(t *testing.T) { + got, err := VerifyImageDigest(img, "sha256", sha256Hex(imgBytes)) + require.NoError(t, err) + assert.Equal(t, sha256Hex(imgBytes), got) + }) + + t.Run("sha512 match returns digest", func(t *testing.T) { + got, err := VerifyImageDigest(img, "sha512", sha512Hex(imgBytes)) + require.NoError(t, err) + assert.Equal(t, sha512Hex(imgBytes), got) + }) + + t.Run("mismatch names both digests", func(t *testing.T) { + wrong := sha512Hex([]byte("tampered")) + got, err := VerifyImageDigest(img, "sha512", wrong) + require.ErrorIs(t, err, ErrChecksumMismatch) + assert.Equal(t, sha512Hex(imgBytes), got) + assert.Contains(t, err.Error(), wrong) + assert.Contains(t, err.Error(), sha512Hex(imgBytes)) + }) + + t.Run("unsupported algorithm", func(t *testing.T) { + _, err := VerifyImageDigest(img, "md5", "abc") + assert.ErrorIs(t, err, ErrUnsupportedChecksumType) + }) +} + +func TestHashImageFile(t *testing.T) { + imgBytes := []byte("hash-image-fixture") + img := writeTempImage(t, "image.raw", imgBytes) + + got, err := HashImageFile(img, "sha256") + require.NoError(t, err) + assert.Equal(t, sha256Hex(imgBytes), got) + + _, err = HashImageFile(writeTempImage(t, "empty.raw", nil), "sha256") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestAvailableImages_Rocky(t *testing.T) { + cases := []struct { + name string + arch string + filenameSub string + }{ + {"rocky-10-x86_64", "x86_64", "x86_64.qcow2"}, + {"rocky-10-arm64", "arm64", "aarch64.qcow2"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + img, ok := AvailableImages[tc.name] + require.True(t, ok, "catalog must contain %q", tc.name) + assert.Equal(t, tc.name, img.Name) + assert.Equal(t, "rocky", img.Distro) + assert.Equal(t, "10", img.Version) + assert.Equal(t, tc.arch, img.Arch) + // Rocky 10 cloud images are UEFI-only on both architectures. + assert.Equal(t, "uefi", img.BootMode) + assert.Equal(t, "sha256", img.ChecksumType) + // URL must track the moving .latest. alias — Rocky prunes dated + // builds, so a pinned dated URL 404s once it ages out. The checksum + // URL must be the BSD-style .CHECKSUM companion of that same alias. + assert.Contains(t, img.URL, ".latest.") + assert.Contains(t, img.URL, tc.filenameSub) + assert.True(t, strings.HasSuffix(img.Checksum, ".CHECKSUM"), + "Rocky publishes BSD-style .CHECKSUM files; got %q", img.Checksum) + // Catalog Distro must resolve to the rhel family — typo here would + // silently render Rocky guests with netplan/sudo group. + assert.Equal(t, "rhel", DistroFamily(img.Distro)) + }) + } +} + +func TestDistroFamily(t *testing.T) { + cases := []struct { + distro string + want string + }{ + {"debian", "debian"}, + {"ubuntu", "debian"}, + {"rocky", "rhel"}, + {"rhel", "rhel"}, + {"alma", "rhel"}, + {"fedora", "rhel"}, + {"centos", "rhel"}, + {"alpine", "alpine"}, + // Case + whitespace normalisation + {" Rocky ", "rhel"}, + {"UBUNTU", "debian"}, + // Unknown and empty fall through to debian with a warning logged. + {"", "debian"}, + {"plan9", "debian"}, + } + for _, tc := range cases { + t.Run(tc.distro, func(t *testing.T) { + assert.Equal(t, tc.want, DistroFamily(tc.distro)) + }) + } +} + +func TestAvailableImages_ECSNodeEntry(t *testing.T) { + img, ok := AvailableImages["spinifex-ecs-node"] + require.True(t, ok, "spinifex-ecs-node must be in the system image catalog") + assert.Equal(t, "ecs", img.Tags["spinifex:managed-by"], + "ECS node image must carry the managed-by=ecs tag the UI guard resolves on") +} + +func TestAvailableImages_RDSPostgresEntry(t *testing.T) { + img, ok := AvailableImages["spinifex-rds-postgres"] + require.True(t, ok, "spinifex-rds-postgres must be in the system image catalog") + assert.Equal(t, "rds", img.Tags["spinifex:managed-by"], + "RDS image must carry the managed-by=rds tag instance launches resolve on") + assert.Equal(t, "postgres", img.Tags["engine"]) + assert.Equal(t, "18", img.Tags["engine-version"], + "the pinned PostgreSQL major version is what EngineVersion resolves against") + assert.Equal(t, "format-auth-v1", img.Tags["rds-data-volume-contract"], + "RDS launches must exclude images that still discover generic data disks") +} diff --git a/spinifex/utils/utils_test.go b/spinifex/utils/utils_test.go index 1bb2520bc..7a7692bd5 100644 --- a/spinifex/utils/utils_test.go +++ b/spinifex/utils/utils_test.go @@ -1,23 +1,10 @@ package utils import ( - "crypto/sha256" - "crypto/sha512" - "crypto/x509" - "encoding/hex" - "fmt" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" "strings" - "sync/atomic" "testing" - "time" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestGenerateResourceID(t *testing.T) { @@ -201,675 +188,3 @@ func TestValidateErrorPayload(t *testing.T) { } // Test file extraction process - -func TestExtractDiskImageFromFile(t *testing.T) { - tmpDir := t.TempDir() - - t.Log("Temp dir:", tmpDir) - - // Sample .xz (fail) - imagePath, err := ExtractDiskImageFromFile("/tmp/file.xz", tmpDir) - - assert.Empty(t, imagePath, "Should be blank") - assert.Error(t, err, "Should error") - - // Sample incorrect image (fail) - imagePath, err = ExtractDiskImageFromFile("../../tests/ebs.json", tmpDir) - - assert.Empty(t, imagePath, "Should be blank") - assert.Error(t, err, "Should error") - assert.ErrorContains(t, err, "unsupported filetype") - - // Sample incorrect image (fail) - imagePath, err = ExtractDiskImageFromFile("../../tests/unit-test-disk-image-bad.raw", tmpDir) - - assert.NotEmpty(t, imagePath, "Should be blank") - assert.Error(t, err, "Should error") - assert.ErrorContains(t, err, "no valid disk image found") - - // Sample raw - imagePath, err = ExtractDiskImageFromFile("../../tests/unit-test-disk-image.raw", tmpDir) - - assert.NotEmpty(t, imagePath, "Should not be blank") - assert.Contains(t, imagePath, ".raw") - - assert.NoError(t, err, "Should not error") - - _, err = exec.LookPath("tar") - - if err == nil { - // Sample .tgz - imagePath, err = ExtractDiskImageFromFile("../../tests/unit-test-disk-image2.tgz", tmpDir) - - assert.NotEmpty(t, imagePath, "Should not be blank") - assert.Contains(t, imagePath, ".img") - - assert.NoError(t, err, "Should not error") - - // Sample .tar - imagePath, err = ExtractDiskImageFromFile("../../tests/unit-test-disk-image.tar", tmpDir) - - assert.NotEmpty(t, imagePath, "Should not be blank") - assert.Contains(t, imagePath, ".raw") - - assert.NoError(t, err, "Should not error") - - // Sample .tar.gz - imagePath, err = ExtractDiskImageFromFile("../../tests/unit-test-disk-image.tar.gz", tmpDir) - - assert.NotEmpty(t, imagePath, "Should not be blank") - assert.Contains(t, imagePath, ".raw") - - assert.NoError(t, err, "Should not error") - - // Sample xz - imagePath, err = ExtractDiskImageFromFile("../../tests/unit-test-disk-image.tar.xz", tmpDir) - - assert.NotEmpty(t, imagePath, "Should not be blank") - assert.Contains(t, imagePath, ".raw") - - assert.NoError(t, err, "Should not error") - - // Sample tgz - imagePath, err = ExtractDiskImageFromFile("../../tests/unit-test-disk-image.tgz", tmpDir) - - assert.NotEmpty(t, imagePath, "Should not be blank") - assert.Contains(t, imagePath, ".raw") - - assert.NoError(t, err, "Should not error") - } else { - t.Skip("tar command not found, skipping archive extraction tests") - } - - //err = os.RemoveAll(tmpDir) - //assert.NoError(t, err, "Could not remove temp dir") -} - -func TestExtractDiskImagePath_NoMatch(t *testing.T) { - output := []byte("somefile.txt\nanotherfile.conf\n") - diskimage, err := extractDiskImagePath(t.TempDir(), output) - assert.Empty(t, diskimage) - assert.NoError(t, err) -} - -func TestExtractDiskImagePath_EmptyOutput(t *testing.T) { - diskimage, err := extractDiskImagePath(t.TempDir(), []byte{}) - assert.Empty(t, diskimage) - assert.NoError(t, err) -} - -// writeTempImage writes content to a file named name under t.TempDir() and -// returns the absolute path. -func writeTempImage(t *testing.T, name string, content []byte) string { - t.Helper() - path := filepath.Join(t.TempDir(), name) - require.NoError(t, os.WriteFile(path, content, 0o600)) - return path -} - -func sha256Hex(b []byte) string { - sum := sha256.Sum256(b) - return hex.EncodeToString(sum[:]) -} - -func sha512Hex(b []byte) string { - sum := sha512.Sum512(b) - return hex.EncodeToString(sum[:]) -} - -// newSumsServer stands up a TLS test server returning body for any request, -// configures the package-level trust hook, and registers cleanup. Returns the -// server URL. -func newSumsServer(t *testing.T, body string) string { - t.Helper() - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(body)) - })) - t.Cleanup(srv.Close) - trustTestServer(t, srv) - return srv.URL + "/SUMS" -} - -// trustTestServer adds srv's certificate to the package-level trust pool so -// VerifyImageChecksum's HTTPS-only client accepts it. Restores the previous -// pool on test cleanup. -func trustTestServer(t *testing.T, srv *httptest.Server) { - t.Helper() - prev := checksumExtraRootCAs - pool := x509.NewCertPool() - if prev != nil { - pool = prev.Clone() - } - pool.AddCert(srv.Certificate()) - checksumExtraRootCAs = pool - t.Cleanup(func() { checksumExtraRootCAs = prev }) -} - -func TestVerifyImageChecksum(t *testing.T) { - const debianName = "debian-13-genericcloud-amd64-20260518-2482.tar.xz" - const ubuntuName = "resolute-server-cloudimg-amd64.img" - const alpineName = "alb-alpine-3.21.6-x86_64.raw" - - debianBytes := []byte("debian-image-bytes-fixture") - ubuntuBytes := []byte("ubuntu-image-bytes-fixture") - alpineBytes := []byte("alpine-image-bytes-fixture") - - t.Run("debian sha512 multi-line match", func(t *testing.T) { - img := writeTempImage(t, debianName, debianBytes) - body := fmt.Sprintf("%s %s\n%s other-file\n", - sha512Hex(debianBytes), debianName, sha512Hex([]byte("unrelated"))) - url := newSumsServer(t, body) - got, err := VerifyImageChecksum(img, url, "sha512") - require.NoError(t, err) - assert.Equal(t, sha512Hex(debianBytes), got) - }) - - t.Run("ubuntu sha256 binary-mode asterisk match", func(t *testing.T) { - img := writeTempImage(t, ubuntuName, ubuntuBytes) - body := fmt.Sprintf("%s *%s\n", sha256Hex(ubuntuBytes), ubuntuName) - url := newSumsServer(t, body) - _, err := VerifyImageChecksum(img, url, "sha256") - assert.NoError(t, err) - }) - - t.Run("alpine single-line named match", func(t *testing.T) { - img := writeTempImage(t, alpineName, alpineBytes) - body := fmt.Sprintf("%s %s\n", sha512Hex(alpineBytes), alpineName) - url := newSumsServer(t, body) - _, err := VerifyImageChecksum(img, url, "sha512") - assert.NoError(t, err) - }) - - t.Run("alpine bare-digest fallback", func(t *testing.T) { - img := writeTempImage(t, alpineName, alpineBytes) - body := sha512Hex(alpineBytes) + "\n" - url := newSumsServer(t, body) - _, err := VerifyImageChecksum(img, url, "sha512") - assert.NoError(t, err) - }) - - t.Run("case-sensitive filename mismatch", func(t *testing.T) { - img := writeTempImage(t, debianName, debianBytes) - // Capitalised "Debian-..." in sums file: legitimate divergence from upstream - // convention, treated as not-found. - body := fmt.Sprintf("%s Debian-12-generic-amd64.tar.xz\n", sha512Hex(debianBytes)) - url := newSumsServer(t, body) - _, err := VerifyImageChecksum(img, url, "sha512") - assert.ErrorIs(t, err, ErrChecksumNotFound) - }) - - t.Run("comment lines skipped", func(t *testing.T) { - img := writeTempImage(t, debianName, debianBytes) - body := fmt.Sprintf("# Hash file generated by upstream\n# PGP-signed below\n%s %s\n", - sha512Hex(debianBytes), debianName) - url := newSumsServer(t, body) - _, err := VerifyImageChecksum(img, url, "sha512") - assert.NoError(t, err) - }) - - t.Run("digest mismatch", func(t *testing.T) { - img := writeTempImage(t, debianName, debianBytes) - wrong := sha512Hex([]byte("a different blob entirely")) - body := fmt.Sprintf("%s %s\n", wrong, debianName) - url := newSumsServer(t, body) - _, err := VerifyImageChecksum(img, url, "sha512") - require.Error(t, err) - assert.ErrorIs(t, err, ErrChecksumMismatch) - // Both expected and actual digests should be in the rendered error so - // operators can compare without scraping slog. - assert.Contains(t, err.Error(), wrong) - assert.Contains(t, err.Error(), sha512Hex(debianBytes)) - }) - - t.Run("filename absent from sums file", func(t *testing.T) { - img := writeTempImage(t, debianName, debianBytes) - body := fmt.Sprintf("%s some-other-file.tar.xz\n%s yet-another.iso\n", - sha512Hex([]byte("a")), sha512Hex([]byte("b"))) - url := newSumsServer(t, body) - _, err := VerifyImageChecksum(img, url, "sha512") - assert.ErrorIs(t, err, ErrChecksumNotFound) - }) - - t.Run("unsupported checksum type rejected before any fetch", func(t *testing.T) { - img := writeTempImage(t, debianName, debianBytes) - // Server URL is gibberish — function must fail before reaching it. - _, err := VerifyImageChecksum(img, "https://example.invalid/SUMS", "md5") - assert.ErrorIs(t, err, ErrUnsupportedChecksumType) - }) - - t.Run("http 404 wraps fetch failure", func(t *testing.T) { - img := writeTempImage(t, debianName, debianBytes) - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.NotFound(w, r) - })) - t.Cleanup(srv.Close) - trustTestServer(t, srv) - _, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") - assert.ErrorIs(t, err, ErrChecksumFetchFailed) - }) - - t.Run("non-https initial url rejected before any request", func(t *testing.T) { - img := writeTempImage(t, debianName, debianBytes) - // Hit a port nothing listens on — if HTTPS check ever regresses we'd - // still get an error, but we want ErrChecksumFetchFailed wrapping the - // scheme refusal specifically. - _, err := VerifyImageChecksum(img, "http://127.0.0.1:1/SUMS", "sha512") - require.Error(t, err) - assert.ErrorIs(t, err, ErrChecksumFetchFailed) - assert.Contains(t, err.Error(), "non-https") - }) - - t.Run("non-https redirect refused", func(t *testing.T) { - img := writeTempImage(t, debianName, debianBytes) - // Plain-HTTP target the TLS server will try to redirect us to. - plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "%s %s\n", sha512Hex(debianBytes), debianName) - })) - t.Cleanup(plain.Close) - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, plain.URL+"/SUMS", http.StatusFound) - })) - t.Cleanup(srv.Close) - trustTestServer(t, srv) - _, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") - require.Error(t, err) - assert.ErrorIs(t, err, ErrChecksumFetchFailed) - }) - - t.Run("redirect cap exceeded", func(t *testing.T) { - img := writeTempImage(t, debianName, debianBytes) - var hits atomic.Int32 - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - hits.Add(1) - http.Redirect(w, r, r.URL.Path, http.StatusFound) - })) - t.Cleanup(srv.Close) - trustTestServer(t, srv) - _, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") - require.Error(t, err) - assert.ErrorIs(t, err, ErrChecksumFetchFailed) - assert.GreaterOrEqual(t, int(hits.Load()), 10) - }) - - t.Run("sums file size cap exceeded", func(t *testing.T) { - img := writeTempImage(t, debianName, debianBytes) - // Body of 2 MB of unrelated entries — over the 1 MB cap. - var b strings.Builder - junk := strings.Repeat("a", 128) - for b.Len() <= sumsFileMaxSize+1 { - fmt.Fprintf(&b, "%s %s.bin\n", sha512Hex([]byte(junk)), junk) - } - url := newSumsServer(t, b.String()) - _, err := VerifyImageChecksum(img, url, "sha512") - require.Error(t, err) - assert.ErrorIs(t, err, ErrChecksumFetchFailed) - assert.Contains(t, err.Error(), "exceeds") - }) - - t.Run("context deadline exceeded wraps fetch failure", func(t *testing.T) { - img := writeTempImage(t, debianName, debianBytes) - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - <-r.Context().Done() - })) - t.Cleanup(srv.Close) - trustTestServer(t, srv) - - prev := checksumFetchTimeout - checksumFetchTimeout = 100 * time.Millisecond - t.Cleanup(func() { checksumFetchTimeout = prev }) - - _, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") - require.Error(t, err) - assert.ErrorIs(t, err, ErrChecksumFetchFailed) - }) - - t.Run("digest length mismatch rejected before hashing", func(t *testing.T) { - // sha256 digest served, caller declares sha512: algorithm disagreement, - // not tampering. Must be distinct from ErrChecksumMismatch. - img := writeTempImage(t, debianName, debianBytes) - body := fmt.Sprintf("%s %s\n", sha256Hex(debianBytes), debianName) - url := newSumsServer(t, body) - _, err := VerifyImageChecksum(img, url, "sha512") - require.Error(t, err) - assert.ErrorIs(t, err, ErrChecksumFetchFailed) - assert.NotErrorIs(t, err, ErrChecksumMismatch) - assert.Contains(t, err.Error(), "digest length") - }) - - t.Run("empty image file rejected with clear error", func(t *testing.T) { - img := writeTempImage(t, debianName, nil) - body := fmt.Sprintf("%s %s\n", sha512Hex(nil), debianName) - url := newSumsServer(t, body) - _, err := VerifyImageChecksum(img, url, "sha512") - require.Error(t, err) - assert.NotErrorIs(t, err, ErrChecksumMismatch) - assert.Contains(t, err.Error(), "empty") - }) - - t.Run("transport error wrapped", func(t *testing.T) { - img := writeTempImage(t, debianName, debianBytes) - // Stand up a TLS server, trust it, then close immediately so any - // subsequent request fails at the transport layer. - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) - trustTestServer(t, srv) - url := srv.URL + "/SUMS" - srv.Close() - _, err := VerifyImageChecksum(img, url, "sha512") - require.Error(t, err) - assert.ErrorIs(t, err, ErrChecksumFetchFailed) - }) - - t.Run("rocky bsd-style gpg-armored sums match", func(t *testing.T) { - const rockyName = "Rocky-10.0-20250612.0.x86_64.qcow2" - rockyBytes := []byte("rocky-image-bytes-fixture") - img := writeTempImage(t, rockyName, rockyBytes) - // Captured shape of a real Rocky CHECKSUM file: GPG cleartext-signed - // armor wrapping BSD-style "SHA256 (name) = hex" lines, followed by an - // ASCII-armored signature block we must skip without misparsing. - body := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE----- -Hash: SHA256 - -SHA256 (Rocky-10.0-20250612.0-x86_64-boot.iso) = %s -SHA256 (%s) = %s -SHA256 (Rocky-10.0-20250612.0-x86_64-dvd.iso) = %s ------BEGIN PGP SIGNATURE----- - -iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm -nopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+/abcdefghijklmn -=ABCD ------END PGP SIGNATURE----- -`, sha256Hex([]byte("boot-iso")), rockyName, sha256Hex(rockyBytes), sha256Hex([]byte("dvd-iso"))) - url := newSumsServer(t, body) - _, err := VerifyImageChecksum(img, url, "sha256") - assert.NoError(t, err) - }) - - t.Run("alma bsd-style gpg-armored sums match", func(t *testing.T) { - const almaName = "AlmaLinux-10-GenericCloud-10.0-x86_64.qcow2" - almaBytes := []byte("alma-image-bytes-fixture") - img := writeTempImage(t, almaName, almaBytes) - body := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE----- -Hash: SHA256 - -SHA256 (%s) = %s ------BEGIN PGP SIGNATURE----- - -iHUEABYKAB0WIQTm5n8/yqv7tQAaIQAaIQAFAmJabcdEFGhijklmnopqrstuvwxyz -=EFGH ------END PGP SIGNATURE----- -`, almaName, sha256Hex(almaBytes)) - url := newSumsServer(t, body) - _, err := VerifyImageChecksum(img, url, "sha256") - assert.NoError(t, err) - }) - - t.Run("bsd-style filename absent", func(t *testing.T) { - const rockyName = "Rocky-10.0-20250612.0.x86_64.qcow2" - rockyBytes := []byte("rocky-image-bytes-fixture") - img := writeTempImage(t, rockyName, rockyBytes) - body := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE----- -Hash: SHA256 - -SHA256 (Rocky-10.0-20250612.0-x86_64-boot.iso) = %s ------BEGIN PGP SIGNATURE----- - -iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm -=ABCD ------END PGP SIGNATURE----- -`, sha256Hex([]byte("boot-iso"))) - url := newSumsServer(t, body) - _, err := VerifyImageChecksum(img, url, "sha256") - assert.ErrorIs(t, err, ErrChecksumNotFound) - }) -} - -func TestParseSumsFile(t *testing.T) { - const target = "Rocky-10.0-20250612.0.x86_64.qcow2" - const targetHex = "a3f1c2d4e5f60718293a4b5c6d7e8f9012345678901234567890abcdefabcdef" - - t.Run("bsd 4-field line", func(t *testing.T) { - body := []byte("SHA256 (" + target + ") = " + targetHex + "\n") - got, err := parseSumsFile(body, target) - require.NoError(t, err) - assert.Equal(t, targetHex, got) - }) - - t.Run("bsd missing equals separator skipped", func(t *testing.T) { - // Malformed line — third field is not "=". Must not match. - body := []byte("SHA256 (" + target + ") - " + targetHex + "\n") - _, err := parseSumsFile(body, target) - assert.ErrorIs(t, err, ErrChecksumNotFound) - }) - - t.Run("bsd missing parens skipped", func(t *testing.T) { - // Without parens the filename field is ambiguous; reject by not matching. - body := []byte("SHA256 " + target + " = " + targetHex + "\n") - _, err := parseSumsFile(body, target) - assert.ErrorIs(t, err, ErrChecksumNotFound) - }) - - t.Run("gpg-armored body with no sums returns not found", func(t *testing.T) { - // PGP signatures always have ≥ 2 bare-token lines (base64 + =CRC), so bareCount stays ≥ 2 and NotFound is returned. - body := []byte(`-----BEGIN PGP SIGNED MESSAGE----- -Hash: SHA256 - ------BEGIN PGP SIGNATURE----- - -iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm -=ABCD ------END PGP SIGNATURE----- -`) - _, err := parseSumsFile(body, target) - assert.ErrorIs(t, err, ErrChecksumNotFound) - }) - - t.Run("mixed bsd and coreutils shapes", func(t *testing.T) { - // Defensive: a sums file with both shapes should still resolve the - // requested filename regardless of which line carries it. - body := []byte("# header\n" + - targetHex + " some-other.iso\n" + - "SHA256 (" + target + ") = " + targetHex + "\n") - got, err := parseSumsFile(body, target) - require.NoError(t, err) - assert.Equal(t, targetHex, got) - }) -} - -func TestReadExpectedDigest(t *testing.T) { - const image = "debian-13-generic-amd64.tar.xz" - imgBytes := []byte("local-image-bytes-fixture") - h256, h512 := sha256Hex(imgBytes), sha512Hex(imgBytes) - other := sha512Hex([]byte("other")) - - cases := []struct { - name string - sumsName string - body string - imageName string - dir bool - missing bool - wantAlgo string - wantDigest string - wantErr error - wantErrMsg string - }{ - {name: "coreutils text sha512", sumsName: "SHA512SUMS", - body: h512 + " " + image + "\n" + other + " other.iso\n", wantAlgo: "sha512", wantDigest: h512}, - {name: "coreutils binary sha256", sumsName: "SHA256SUMS", - body: h256 + " *" + image + "\n", wantAlgo: "sha256", wantDigest: h256}, - {name: "bsd sha256", sumsName: "CHECKSUM", - body: "SHA256 (" + image + ") = " + h256 + "\n", wantAlgo: "sha256", wantDigest: h256}, - {name: "bare sha512", sumsName: image + ".sha512", - body: h512 + "\n", wantAlgo: "sha512", wantDigest: h512}, - {name: "neutral sums name uses length", sumsName: "checksums.txt", - body: h256 + " " + image + "\n", wantAlgo: "sha256", wantDigest: h256}, - {name: "uppercase hex normalised", sumsName: "SHA512SUMS", - body: strings.ToUpper(h512) + " " + image + "\n", wantAlgo: "sha512", wantDigest: h512}, - {name: "sums file name does not decide the algorithm", sumsName: "SHA256SUMS", - body: h512 + " " + image + "\n", wantAlgo: "sha512", wantDigest: h512}, - {name: "renamed image in multi-entry file", sumsName: "SHA512SUMS", imageName: "renamed.tar.xz", - body: h512 + " " + image + "\n" + other + " other.iso\n", wantErr: ErrChecksumNotFound, wantErrMsg: "renamed.tar.xz"}, - {name: "path-prefixed entry names the prefix", sumsName: "image.sha512", - body: h512 + " ./" + image + "\n", wantErr: ErrChecksumNotFound, wantErrMsg: "directory prefix"}, - {name: "renamed image with bare digest", sumsName: "image.sha512", imageName: "renamed.tar.xz", - body: h512 + "\n", wantAlgo: "sha512", wantDigest: h512}, - {name: "sha1 length unsupported", sumsName: "checksums.txt", - body: strings.Repeat("a", 40) + " " + image + "\n", wantErr: ErrUnsupportedChecksumType}, - {name: "non-hex digest", sumsName: "checksums.txt", - body: strings.Repeat("g", 64) + " " + image + "\n", wantErr: ErrMalformedChecksum}, - {name: "oversized file", sumsName: "SHA512SUMS", - body: h512 + " " + image + "\n" + strings.Repeat("#", sumsFileMaxSize), wantErr: ErrMalformedChecksum}, - {name: "directory refused", sumsName: "SHA512SUMS", dir: true, wantErr: ErrMalformedChecksum}, - {name: "missing file refused", sumsName: "SHA512SUMS", missing: true, wantErr: os.ErrNotExist}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), tc.sumsName) - switch { - case tc.dir: - require.NoError(t, os.Mkdir(path, 0o755)) - case !tc.missing: - require.NoError(t, os.WriteFile(path, []byte(tc.body), 0o600)) - } - imageName := tc.imageName - if imageName == "" { - imageName = image - } - - algo, digest, err := ReadExpectedDigest(path, imageName) - if tc.wantErr != nil { - require.ErrorIs(t, err, tc.wantErr) - assert.Contains(t, err.Error(), tc.wantErrMsg) - return - } - require.NoError(t, err) - assert.Equal(t, tc.wantAlgo, algo) - assert.Equal(t, tc.wantDigest, digest) - }) - } -} - -func TestVerifyImageDigest(t *testing.T) { - imgBytes := []byte("verify-digest-fixture") - img := writeTempImage(t, "image.raw", imgBytes) - - t.Run("sha256 match returns digest", func(t *testing.T) { - got, err := VerifyImageDigest(img, "sha256", sha256Hex(imgBytes)) - require.NoError(t, err) - assert.Equal(t, sha256Hex(imgBytes), got) - }) - - t.Run("sha512 match returns digest", func(t *testing.T) { - got, err := VerifyImageDigest(img, "sha512", sha512Hex(imgBytes)) - require.NoError(t, err) - assert.Equal(t, sha512Hex(imgBytes), got) - }) - - t.Run("mismatch names both digests", func(t *testing.T) { - wrong := sha512Hex([]byte("tampered")) - got, err := VerifyImageDigest(img, "sha512", wrong) - require.ErrorIs(t, err, ErrChecksumMismatch) - assert.Equal(t, sha512Hex(imgBytes), got) - assert.Contains(t, err.Error(), wrong) - assert.Contains(t, err.Error(), sha512Hex(imgBytes)) - }) - - t.Run("unsupported algorithm", func(t *testing.T) { - _, err := VerifyImageDigest(img, "md5", "abc") - assert.ErrorIs(t, err, ErrUnsupportedChecksumType) - }) -} - -func TestHashImageFile(t *testing.T) { - imgBytes := []byte("hash-image-fixture") - img := writeTempImage(t, "image.raw", imgBytes) - - got, err := HashImageFile(img, "sha256") - require.NoError(t, err) - assert.Equal(t, sha256Hex(imgBytes), got) - - _, err = HashImageFile(writeTempImage(t, "empty.raw", nil), "sha256") - require.Error(t, err) - assert.Contains(t, err.Error(), "empty") -} - -func TestAvailableImages_Rocky(t *testing.T) { - cases := []struct { - name string - arch string - filenameSub string - }{ - {"rocky-10-x86_64", "x86_64", "x86_64.qcow2"}, - {"rocky-10-arm64", "arm64", "aarch64.qcow2"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - img, ok := AvailableImages[tc.name] - require.True(t, ok, "catalog must contain %q", tc.name) - assert.Equal(t, tc.name, img.Name) - assert.Equal(t, "rocky", img.Distro) - assert.Equal(t, "10", img.Version) - assert.Equal(t, tc.arch, img.Arch) - // Rocky 10 cloud images are UEFI-only on both architectures. - assert.Equal(t, "uefi", img.BootMode) - assert.Equal(t, "sha256", img.ChecksumType) - // URL must track the moving .latest. alias — Rocky prunes dated - // builds, so a pinned dated URL 404s once it ages out. The checksum - // URL must be the BSD-style .CHECKSUM companion of that same alias. - assert.Contains(t, img.URL, ".latest.") - assert.Contains(t, img.URL, tc.filenameSub) - assert.True(t, strings.HasSuffix(img.Checksum, ".CHECKSUM"), - "Rocky publishes BSD-style .CHECKSUM files; got %q", img.Checksum) - // Catalog Distro must resolve to the rhel family — typo here would - // silently render Rocky guests with netplan/sudo group. - assert.Equal(t, "rhel", DistroFamily(img.Distro)) - }) - } -} - -func TestDistroFamily(t *testing.T) { - cases := []struct { - distro string - want string - }{ - {"debian", "debian"}, - {"ubuntu", "debian"}, - {"rocky", "rhel"}, - {"rhel", "rhel"}, - {"alma", "rhel"}, - {"fedora", "rhel"}, - {"centos", "rhel"}, - {"alpine", "alpine"}, - // Case + whitespace normalisation - {" Rocky ", "rhel"}, - {"UBUNTU", "debian"}, - // Unknown and empty fall through to debian with a warning logged. - {"", "debian"}, - {"plan9", "debian"}, - } - for _, tc := range cases { - t.Run(tc.distro, func(t *testing.T) { - assert.Equal(t, tc.want, DistroFamily(tc.distro)) - }) - } -} - -func TestAvailableImages_ECSNodeEntry(t *testing.T) { - img, ok := AvailableImages["spinifex-ecs-node"] - require.True(t, ok, "spinifex-ecs-node must be in the system image catalog") - assert.Equal(t, "ecs", img.Tags["spinifex:managed-by"], - "ECS node image must carry the managed-by=ecs tag the UI guard resolves on") -} - -func TestAvailableImages_RDSPostgresEntry(t *testing.T) { - img, ok := AvailableImages["spinifex-rds-postgres"] - require.True(t, ok, "spinifex-rds-postgres must be in the system image catalog") - assert.Equal(t, "rds", img.Tags["spinifex:managed-by"], - "RDS image must carry the managed-by=rds tag instance launches resolve on") - assert.Equal(t, "postgres", img.Tags["engine"]) - assert.Equal(t, "18", img.Tags["engine-version"], - "the pinned PostgreSQL major version is what EngineVersion resolves against") - assert.Equal(t, "format-auth-v1", img.Tags["rds-data-volume-contract"], - "RDS launches must exclude images that still discover generic data disks") -} diff --git a/tests/e2e/gpu/eks_gpu_test.go b/tests/e2e/gpu/eks_gpu_test.go index 438a58795..2ecd46ccd 100644 --- a/tests/e2e/gpu/eks_gpu_test.go +++ b/tests/e2e/gpu/eks_gpu_test.go @@ -36,7 +36,7 @@ const ( eksGPUClusterPfx = "eks-gpu-e2e" // eksNodeAMIName / eksNodeGPUAMIName are the registered EKS node AMI - // names (see spinifex/spinifex/utils/images.go). Both must be imported — + // names (see spinifex/spinifex/operator/imagecatalog/images.go). Both must be imported — // the base image for control-plane parity checks, the GPU variant for // resolveWorkerAMI's GPU branch. eksNodeAMIName = "spinifex-eks-node" diff --git a/tests/e2e/single/imagecatalog_test.go b/tests/e2e/single/imagecatalog_test.go index 2ee348a16..ef04780d3 100644 --- a/tests/e2e/single/imagecatalog_test.go +++ b/tests/e2e/single/imagecatalog_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/mulgadc/spinifex/spinifex/operator/imagecatalog" "github.com/mulgadc/spinifex/tests/e2e/harness" "github.com/stretchr/testify/assert" ) @@ -35,7 +35,7 @@ const catalogHeadTimeout = 30 * time.Second // and the sooner it is seen the smaller the window in which a released binary // points at a dead URL. // -// utils.WithheldImages is deliberately not walked: those are the entries whose +// imagecatalog.WithheldImages is deliberately not walked: those are the entries whose // artifact is known to be unpublished, so HEADing them would assert a failure // we already know about and hide the ones we do not. func runImageCatalogReachable(t *testing.T, _ *Fixture) { @@ -57,8 +57,8 @@ func runImageCatalogReachable(t *testing.T, _ *Fixture) { Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}, } - for _, name := range slices.Sorted(maps.Keys(utils.AvailableImages)) { - img := utils.AvailableImages[name] + for _, name := range slices.Sorted(maps.Keys(imagecatalog.AvailableImages)) { + img := imagecatalog.AvailableImages[name] t.Run(name, func(t *testing.T) { t.Parallel()