mirror of
https://github.com/siderolabs/image-factory.git
synced 2026-05-05 20:36:16 +02:00
This feature is Enterprise only (requires BUSL). Serves GET/HEAD /vex/:version/vex.json for Talos ≥ 1.13.0. Pulls exploitability data from an OCI registry, generates a VEX document via go-vex, and caches it in-memory with configurable TTL. Signed-off-by: Mateusz Urbanek <mateusz.urbanek@siderolabs.com>
430 lines
11 KiB
Go
430 lines
11 KiB
Go
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
//go:build integration
|
|
|
|
package integration_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/elliptic"
|
|
_ "embed"
|
|
"flag"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/minio/minio-go/v7"
|
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
|
"github.com/ory/dockertest"
|
|
dc "github.com/ory/dockertest/docker"
|
|
"github.com/sigstore/sigstore/pkg/cryptoutils"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap/zaptest"
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"github.com/siderolabs/image-factory/cmd/image-factory/cmd"
|
|
"github.com/siderolabs/image-factory/internal/remotewrap"
|
|
"github.com/siderolabs/image-factory/pkg/client"
|
|
"github.com/siderolabs/image-factory/pkg/enterprise"
|
|
)
|
|
|
|
func setupFactory(t *testing.T, options cmd.Options) (context.Context, string, string) {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
|
|
logger := zaptest.NewLogger(t)
|
|
|
|
host, port := findListenAddr(t, "127.0.0.1")
|
|
|
|
defaultAddr := net.JoinHostPort(host, port)
|
|
pxeAddr := net.JoinHostPort("localhost", port)
|
|
|
|
options.HTTP.ListenAddr = net.JoinHostPort(host, port)
|
|
options.HTTP.ExternalURL = "http://" + defaultAddr + "/"
|
|
options.HTTP.ExternalPXEURL = "http://" + pxeAddr + "/"
|
|
|
|
_, metricsPort := findListenAddr(t, "127.0.0.1")
|
|
options.Metrics.Addr = net.JoinHostPort("127.0.0.1", metricsPort)
|
|
|
|
options.Artifacts.Core.Registry = imageRegistryFlag
|
|
options.Artifacts.Schematic = schematicFactoryRepositoryFlag.OCIRepositoryOptions
|
|
options.Artifacts.Installer.External = installerExternalRepository.OCIRepositoryOptions
|
|
options.Artifacts.Installer.Internal = installerInternalRepository.OCIRepositoryOptions
|
|
options.Artifacts.RefreshInterval = time.Minute // use a short interval for the tests
|
|
|
|
setupSecureBoot(t, &options)
|
|
setupCacheSigningKey(t, &options)
|
|
setupEnterprise(t, &options)
|
|
|
|
t.Cleanup(remotewrap.ShutdownTransport)
|
|
|
|
eg, ctx := errgroup.WithContext(ctx)
|
|
|
|
eg.Go(func() error {
|
|
return cmd.RunFactory(ctx, logger, options)
|
|
})
|
|
|
|
t.Cleanup(func() {
|
|
require.NoError(t, eg.Wait())
|
|
})
|
|
t.Cleanup(cancel)
|
|
t.Cleanup(http.DefaultClient.CloseIdleConnections)
|
|
|
|
// wait for the endpoint to be ready
|
|
require.Eventually(t, func() bool {
|
|
d, err := net.Dial("tcp", options.HTTP.ListenAddr)
|
|
if d != nil {
|
|
require.NoError(t, d.Close())
|
|
}
|
|
|
|
return err == nil
|
|
}, 10*time.Second, 10*time.Millisecond)
|
|
|
|
return ctx, defaultAddr, pxeAddr
|
|
}
|
|
|
|
func setupCacheSigningKey(t *testing.T, options *cmd.Options) {
|
|
t.Helper()
|
|
|
|
optionsDir := t.TempDir()
|
|
|
|
// we use a new key each time in the tests, so cached assets will never be used, as the signature won't match
|
|
priv, _, err := cryptoutils.GeneratePEMEncodedECDSAKeyPair(elliptic.P256(), cryptoutils.SkipPassword)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, os.WriteFile(optionsDir+"/cache-signing-key.pem", priv, 0o600))
|
|
|
|
options.Cache.SigningKeyPath = optionsDir + "/cache-signing-key.pem"
|
|
}
|
|
|
|
func docker(t *testing.T) *dockertest.Pool {
|
|
pool, err := dockertest.NewPool("")
|
|
require.NoError(t, err)
|
|
|
|
err = pool.Client.Ping()
|
|
require.NoError(t, err)
|
|
|
|
return pool
|
|
}
|
|
|
|
func healthcheck(url string) func() error {
|
|
return func() error {
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("status code not OK")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
const (
|
|
s3Access = "AKIA6Z4C7N3S2JD3JH9A"
|
|
s3Secret = "y1rE4xZnqO6xvM7L0jFD3EXAMPLEnG4K2vOfLp8Iv9"
|
|
)
|
|
|
|
func setupS3(t *testing.T, pool *dockertest.Pool, bucket string) string {
|
|
t.Helper()
|
|
|
|
_, port := findListenAddr(t, "127.0.0.1")
|
|
|
|
res, err := pool.RunWithOptions(&dockertest.RunOptions{
|
|
Repository: "minio/minio",
|
|
Tag: "latest",
|
|
Cmd: []string{"server", "/data"},
|
|
PortBindings: map[dc.Port][]dc.PortBinding{
|
|
"9000": {{HostPort: port}},
|
|
},
|
|
Env: []string{
|
|
fmt.Sprintf("MINIO_ROOT_USER=%s", s3Access),
|
|
fmt.Sprintf("MINIO_ROOT_PASSWORD=%s", s3Secret),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() {
|
|
err := pool.Purge(res)
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
endpoint := net.JoinHostPort("127.0.0.1", res.GetPort("9000/tcp"))
|
|
t.Logf("running MinIO on %q", endpoint)
|
|
|
|
s3cli, err := minio.New(endpoint, &minio.Options{
|
|
Creds: credentials.NewStaticV4(s3Access, s3Secret, ""),
|
|
Secure: false,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = pool.Retry(func() error {
|
|
return s3cli.MakeBucket(t.Context(), bucket, minio.MakeBucketOptions{ForceCreate: true})
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return endpoint
|
|
}
|
|
|
|
//go:embed testdata/templates/nginx.sh
|
|
var nginxConfigTemplate string
|
|
|
|
func setupMockCDN(t *testing.T, pool *dockertest.Pool, s3, bucket string) string {
|
|
t.Helper()
|
|
|
|
_, port := findListenAddr(t, "127.0.0.1")
|
|
|
|
inlineEntrypoint := fmt.Appendf([]byte{}, nginxConfigTemplate, s3, bucket)
|
|
|
|
res, err := pool.RunWithOptions(&dockertest.RunOptions{
|
|
Repository: "nginx",
|
|
Tag: "1",
|
|
Cmd: []string{"sh", "-c", string(inlineEntrypoint)},
|
|
PortBindings: map[dc.Port][]dc.PortBinding{
|
|
"80": {{HostPort: port}},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() {
|
|
err := pool.Purge(res)
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
endpoint := net.JoinHostPort("127.0.0.1", res.GetPort("80/tcp"))
|
|
t.Logf("running Nginx on %q", endpoint)
|
|
|
|
err = pool.Retry(healthcheck(fmt.Sprintf("http://%s/health", endpoint)))
|
|
require.NoError(t, err)
|
|
|
|
return endpoint
|
|
}
|
|
|
|
var (
|
|
//go:embed "testdata/secureboot/uki-signing-key.pem"
|
|
secureBootSigningKey []byte
|
|
//go:embed "testdata/secureboot/uki-signing-cert.pem"
|
|
secureBootSigningCert []byte
|
|
//go:embed "testdata/secureboot/pcr-signing-key.pem"
|
|
secureBootPCRKey []byte
|
|
)
|
|
|
|
func setupSecureBoot(t *testing.T, options *cmd.Options) {
|
|
t.Helper()
|
|
|
|
certDir := t.TempDir()
|
|
|
|
require.NoError(t, os.WriteFile(filepath.Join(certDir, "secureboot-signing-key.pem"), secureBootSigningKey, 0o600))
|
|
require.NoError(t, os.WriteFile(filepath.Join(certDir, "secureboot-signing-cert.pem"), secureBootSigningCert, 0o600))
|
|
require.NoError(t, os.WriteFile(filepath.Join(certDir, "pcr-signing-key.pem"), secureBootPCRKey, 0o600))
|
|
|
|
// use fixed SecureBoot keys
|
|
options.SecureBoot = cmd.SecureBootOptions{
|
|
Enabled: true,
|
|
File: cmd.FileProviderOptions{
|
|
SigningKeyPath: filepath.Join(certDir, "secureboot-signing-key.pem"),
|
|
SigningCertPath: filepath.Join(certDir, "secureboot-signing-cert.pem"),
|
|
PCRKeyPath: filepath.Join(certDir, "pcr-signing-key.pem"),
|
|
},
|
|
}
|
|
}
|
|
|
|
//go:embed "testdata/htpasswd"
|
|
var htpasswdFile []byte
|
|
|
|
func setupEnterprise(t *testing.T, options *cmd.Options) {
|
|
t.Helper()
|
|
|
|
if !enterprise.Enabled() {
|
|
return
|
|
}
|
|
|
|
options.Enterprise.VEX.Data = vexDataRepositoryFlag.OCIRepositoryOptions
|
|
|
|
// Skip if the caller already configured auth (e.g. reload tests that need
|
|
// explicit control over the htpasswd file path).
|
|
if options.Authentication.Enabled && options.Authentication.HTPasswdPath != "" {
|
|
return
|
|
}
|
|
|
|
configDir := t.TempDir()
|
|
|
|
require.NoError(t, os.WriteFile(filepath.Join(configDir, "htpasswd"), htpasswdFile, 0o600))
|
|
|
|
options.Authentication.Enabled = true
|
|
options.Authentication.HTPasswdPath = filepath.Join(configDir, "htpasswd")
|
|
}
|
|
|
|
func authCredentials() (string, string) {
|
|
if !enterprise.Enabled() {
|
|
return "", ""
|
|
}
|
|
|
|
return "alice", "alicetopsecret"
|
|
}
|
|
|
|
// addTestAuth sets BasicAuth on req when enterprise auth is active.
|
|
func addTestAuth(req *http.Request) {
|
|
username, password := authCredentials()
|
|
if username != "" {
|
|
req.SetBasicAuth(username, password)
|
|
}
|
|
}
|
|
|
|
func clientAuthCredentials() []client.Option {
|
|
username, password := authCredentials()
|
|
|
|
if username != "" && password != "" {
|
|
return []client.Option{
|
|
client.WithBasicAuth(username, password),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func findListenAddr(t *testing.T, host string) (string, string) {
|
|
t.Helper()
|
|
|
|
l, err := net.Listen("tcp", net.JoinHostPort(host, "0"))
|
|
require.NoError(t, err)
|
|
|
|
addr := l.Addr().String()
|
|
|
|
require.NoError(t, l.Close())
|
|
|
|
host, port, err := net.SplitHostPort(addr)
|
|
require.NoError(t, err)
|
|
|
|
return host, port
|
|
}
|
|
|
|
func commonTest(t *testing.T, options cmd.Options) {
|
|
ctx, listenAddr, pxeAddr := setupFactory(t, options)
|
|
baseURL := "http://" + listenAddr
|
|
pxeURL := "http://" + pxeAddr
|
|
|
|
t.Run("TestFrontend", testFrontend(ctx, baseURL))
|
|
|
|
t.Run("TestSchematic", func(t *testing.T) {
|
|
// schematic should be created first, thus no t.Parallel
|
|
testSchematic(ctx, t, baseURL)
|
|
})
|
|
|
|
t.Run("TestDownloadFrontend", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testDownloadFrontend(ctx, t, baseURL)
|
|
})
|
|
|
|
t.Run("TestPXEFrontend", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testPXEFrontend(ctx, t, baseURL, pxeURL)
|
|
})
|
|
|
|
t.Run("TestTalosctlFrontend", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testTalosctlFrontend(ctx, t, baseURL)
|
|
})
|
|
|
|
t.Run("TestRegistryFrontend", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testRegistryFrontend(ctx, t, listenAddr, baseURL)
|
|
})
|
|
|
|
t.Run("TestLatestTagResolution", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testLatestTagResolution(ctx, t, listenAddr, baseURL)
|
|
})
|
|
|
|
t.Run("TestMetaFrontend", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testMetaFrontend(ctx, t, baseURL)
|
|
})
|
|
|
|
t.Run("TestSecureBootFrontend", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testSecureBootFrontend(ctx, t, baseURL)
|
|
})
|
|
|
|
t.Run("TestSPDXFrontend", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testSPDXFrontend(ctx, t, baseURL)
|
|
})
|
|
|
|
t.Run("TestChecksumFrontend", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testChecksumFrontend(ctx, t, baseURL)
|
|
})
|
|
|
|
t.Run("TestAuthFrontend", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testAuthFrontend(ctx, t, baseURL)
|
|
})
|
|
|
|
t.Run("TestVEXFrontend", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testVEXFrontend(ctx, t, baseURL)
|
|
})
|
|
}
|
|
|
|
type ociRepositoryFalg struct {
|
|
cmd.OCIRepositoryOptions
|
|
}
|
|
|
|
func (o *ociRepositoryFalg) Set(s string) error {
|
|
return o.UnmarshalText([]byte(s))
|
|
}
|
|
|
|
func mustNewDefaultOCIRepository(s string) ociRepositoryFalg {
|
|
o := ociRepositoryFalg{}
|
|
|
|
if err := o.Set(s); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return o
|
|
}
|
|
|
|
var (
|
|
imageRegistryFlag string
|
|
schematicFactoryRepositoryFlag = mustNewDefaultOCIRepository(cmd.DefaultOptions.Artifacts.Schematic.String())
|
|
installerExternalRepository = mustNewDefaultOCIRepository(cmd.DefaultOptions.Artifacts.Installer.External.String())
|
|
installerInternalRepository = mustNewDefaultOCIRepository(cmd.DefaultOptions.Artifacts.Installer.Internal.String())
|
|
cacheRepository = mustNewDefaultOCIRepository(cmd.DefaultOptions.Cache.OCI.String())
|
|
signingCacheRepository = mustNewDefaultOCIRepository(cmd.DefaultOptions.Cache.OCI.String() + "sign")
|
|
vexDataRepositoryFlag = mustNewDefaultOCIRepository(cmd.DefaultOptions.Enterprise.VEX.Data.String())
|
|
)
|
|
|
|
func init() {
|
|
flag.StringVar(&imageRegistryFlag, "test.image-registry", cmd.DefaultOptions.Artifacts.Core.Registry, "image registry")
|
|
flag.Var(&schematicFactoryRepositoryFlag, "test.schematic-service-repository", "schematic factory repository")
|
|
flag.Var(&installerExternalRepository, "test.installer-external-repository", "image repository for the installer (external)")
|
|
flag.Var(&installerInternalRepository, "test.installer-internal-repository", "image repository for the installer (internal)")
|
|
flag.Var(&cacheRepository, "test.cache-repository", "image repository for cached boot assets")
|
|
flag.Var(&signingCacheRepository, "test.signing-cache-repository", "image repository for signatures of cached boot assets (used for S3+CDN tests)")
|
|
flag.Var(&vexDataRepositoryFlag, "test.vex-data-repository", "OCI repository for VEX data")
|
|
}
|