refactor: move image catalogue to operator

This commit is contained in:
joshsiv-mulga
2026-10-02 15:55:08 +10:00
parent 18d48808b7
commit 366e2c2e46
11 changed files with 727 additions and 718 deletions
+15 -14
View File
@@ -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 != "" {
+8 -8
View File
@@ -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")
+1 -1
View File
@@ -878,7 +878,7 @@ ssh -i path/to/key.pem cloud-user@<public-ip>
### 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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 <catalog-key>
#
@@ -1,4 +1,4 @@
package utils
package imagecatalog
import (
"bufio"
@@ -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)
}
@@ -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")
}
-685
View File
@@ -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")
}
+1 -1
View File
@@ -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"
+4 -4
View File
@@ -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()