From 6b01f39e78bdcb4dbaaa65a83c2486d74115fde5 Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Fri, 9 Oct 2026 14:51:14 +1000 Subject: [PATCH] refactor: move operator-only admin code to operator/admin Config generation, certificate issuance, host discovery, telemetry, email validation and image import/removal/orphan-volume tooling move to operator/admin. Legacy admin keeps only the symbols with non-operator consumers: default account identifiers, AWS credential generators and system image promotion. foundation/pki and the tenant ACM CA are unchanged. The readAMI helper and the removal test fixtures are duplicated privately in legacy admin rather than shared across the split. The now-stale operator/installer/ui -> admin layering allowlist entry is removed. --- architecture/layering/allowlist_test.go | 2 - spinifex/admin/accounts.go | 49 + spinifex/admin/admin_test.go | 1207 +--------------- spinifex/admin/images_promote.go | 4 +- spinifex/admin/images_promote_test.go | 56 +- spinifex/{ => operator}/admin/admin.go | 38 - spinifex/operator/admin/admin_test.go | 1215 +++++++++++++++++ spinifex/{ => operator}/admin/email.go | 0 spinifex/{ => operator}/admin/email_test.go | 0 spinifex/{ => operator}/admin/image_import.go | 0 .../{ => operator}/admin/image_import_test.go | 0 .../{ => operator}/admin/images_remove.go | 0 .../admin/images_remove_test.go | 0 spinifex/{ => operator}/admin/main_test.go | 0 .../{ => operator}/admin/network_detect.go | 0 .../admin/network_detect_test.go | 0 .../{ => operator}/admin/orphan_volumes.go | 0 .../admin/orphan_volumes_test.go | 0 spinifex/{ => operator}/admin/telemetry.go | 0 .../{ => operator}/admin/telemetry_test.go | 0 spinifex/operator/cli/admin.go | 35 +- spinifex/operator/cli/admin_acm.go | 5 +- spinifex/operator/cli/admin_gpu.go | 2 +- spinifex/operator/cli/admin_nat_test.go | 2 +- spinifex/operator/cli/admin_ochre.go | 2 +- spinifex/operator/cli/admin_test.go | 2 +- .../operator/cli/northstar_template_test.go | 2 +- .../operator/cli/predastore_planes_test.go | 2 +- spinifex/operator/installer/ui/ui.go | 2 +- spinifex/runtime/roles/nats/nats_test.go | 2 +- 30 files changed, 1349 insertions(+), 1278 deletions(-) create mode 100644 spinifex/admin/accounts.go rename spinifex/{ => operator}/admin/admin.go (97%) create mode 100644 spinifex/operator/admin/admin_test.go rename spinifex/{ => operator}/admin/email.go (100%) rename spinifex/{ => operator}/admin/email_test.go (100%) rename spinifex/{ => operator}/admin/image_import.go (100%) rename spinifex/{ => operator}/admin/image_import_test.go (100%) rename spinifex/{ => operator}/admin/images_remove.go (100%) rename spinifex/{ => operator}/admin/images_remove_test.go (100%) rename spinifex/{ => operator}/admin/main_test.go (100%) rename spinifex/{ => operator}/admin/network_detect.go (100%) rename spinifex/{ => operator}/admin/network_detect_test.go (100%) rename spinifex/{ => operator}/admin/orphan_volumes.go (100%) rename spinifex/{ => operator}/admin/orphan_volumes_test.go (100%) rename spinifex/{ => operator}/admin/telemetry.go (100%) rename spinifex/{ => operator}/admin/telemetry_test.go (100%) diff --git a/architecture/layering/allowlist_test.go b/architecture/layering/allowlist_test.go index efb4fe3c2..768edf60e 100644 --- a/architecture/layering/allowlist_test.go +++ b/architecture/layering/allowlist_test.go @@ -177,6 +177,4 @@ var allowlist = []allowance{ {From: "operator/cli", To: "services/viperblockd", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"}, {From: "operator/cli", To: "services/viperblockd/vbwire", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"}, {From: "operator/cli", To: "vpcd", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"}, - - {From: "operator/installer/ui", To: "admin", Debt: "operator/installer/ui → admin", Reason: "ValidateEmail; clears with the admin -> operator/admin blocker"}, } diff --git a/spinifex/admin/accounts.go b/spinifex/admin/accounts.go new file mode 100644 index 000000000..a67135bc3 --- /dev/null +++ b/spinifex/admin/accounts.go @@ -0,0 +1,49 @@ +// Package admin holds what non-operator code still needs from the former admin +// package: default account identifiers and AWS credential generators (awaiting +// IAM owners) and system-image promotion (awaiting domains/ec2/image). +package admin + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "math/big" +) + +// DefaultAccountID returns the default admin account ID (000000000001). +// This is the first human-facing account created during bootstrap. +func DefaultAccountID() string { + return "000000000001" +} + +// DefaultAccountName returns the default admin account name ("spinifex"). +func DefaultAccountName() string { + return "spinifex" +} + +// GenerateAWSAccessKey generates an AWS-style access key (AKIA + 16 random alphanumeric chars). +func GenerateAWSAccessKey() (string, error) { + const prefix = "AKIA" + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + const length = 16 + + result := make([]byte, length) + for i := range result { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", fmt.Errorf("crypto/rand failure: %w", err) + } + result[i] = charset[num.Int64()] + } + + return prefix + string(result), nil +} + +// GenerateAWSSecretKey generates a 40-character base64-encoded AWS-style secret key. +func GenerateAWSSecretKey() (string, error) { + bytes := make([]byte, 30) // 30 bytes = 40 chars in base64 + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("crypto/rand failure: %w", err) + } + return base64.StdEncoding.EncodeToString(bytes), nil +} diff --git a/spinifex/admin/admin_test.go b/spinifex/admin/admin_test.go index c81590588..dbf8297a7 100644 --- a/spinifex/admin/admin_test.go +++ b/spinifex/admin/admin_test.go @@ -1,41 +1,14 @@ package admin import ( - "crypto/x509" "encoding/base64" - "encoding/pem" - "fmt" - "net" - "os" - "path/filepath" "strings" "testing" - awsconfig "github.com/aws/aws-sdk-go-v2/config" - "github.com/mulgadc/spinifex/spinifex/bootstrap/config" - "github.com/mulgadc/spinifex/spinifex/runtime/formation" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -// certHasIP reports whether a PEM-encoded cert carries ip as an IP SAN. -func certHasIP(t *testing.T, certPath, ip string) bool { - t.Helper() - certPEM, err := os.ReadFile(certPath) - require.NoError(t, err) - block, _ := pem.Decode(certPEM) - require.NotNil(t, block) - cert, err := x509.ParseCertificate(block.Bytes) - require.NoError(t, err) - for _, got := range cert.IPAddresses { - if got.Equal(net.ParseIP(ip)) { - return true - } - } - return false -} - -// --- Key / Token generation --- +// --- Key generation --- func TestGenerateAWSAccessKey_Format(t *testing.T) { key, err := GenerateAWSAccessKey() @@ -77,1181 +50,3 @@ func TestDefaultAccountID(t *testing.T) { assert.Equal(t, "000000000001", id) assert.Len(t, id, 12) } - -func TestGenerateNATSToken_Format(t *testing.T) { - token, err := GenerateNATSToken() - assert.NoError(t, err) - assert.True(t, strings.HasPrefix(token, "nats_")) - assert.Len(t, token, 37) // 5 prefix + 32 random - // URL-safe base64: no '+' or '/' - assert.NotContains(t, token, "+") - assert.NotContains(t, token, "/") -} - -func TestGenerateNATSToken_Uniqueness(t *testing.T) { - t1, err := GenerateNATSToken() - assert.NoError(t, err) - t2, err := GenerateNATSToken() - assert.NoError(t, err) - assert.NotEqual(t, t1, t2) -} - -// --- Config file generation --- - -func TestGenerateConfigFile_Success(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "test.conf") - - tmpl := "region = {{.Region}}\nnode = {{.Node}}" - settings := ConfigSettings{Region: "us-east-1", Node: "node1"} - - err := GenerateConfigFile(path, tmpl, settings) - require.NoError(t, err) - - data, err := os.ReadFile(path) - require.NoError(t, err) - assert.Contains(t, string(data), "region = us-east-1") - assert.Contains(t, string(data), "node = node1") - - info, _ := os.Stat(path) - assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) -} - -func TestGenerateConfigFile_InvalidTemplate(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bad.conf") - err := GenerateConfigFile(path, "{{.Unclosed", ConfigSettings{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse template") -} - -func TestGenerateConfigFile_InvalidPath(t *testing.T) { - err := GenerateConfigFile("/nonexistent/dir/file.conf", "ok", ConfigSettings{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to create config file") -} - -func TestGenerateConfigFile_OverwritesExisting(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "overwrite.conf") - - require.NoError(t, GenerateConfigFile(path, "old={{.Region}}", ConfigSettings{Region: "old"})) - require.NoError(t, GenerateConfigFile(path, "new={{.Region}}", ConfigSettings{Region: "new"})) - - data, _ := os.ReadFile(path) - assert.Contains(t, string(data), "new=new") - assert.NotContains(t, string(data), "old") -} - -func TestGenerateConfigFiles_AllSucceed(t *testing.T) { - dir := t.TempDir() - configs := []ConfigFile{ - {Name: "a", Path: filepath.Join(dir, "a.conf"), Template: "a={{.Region}}"}, - {Name: "b", Path: filepath.Join(dir, "b.conf"), Template: "b={{.Node}}"}, - } - err := GenerateConfigFiles(configs, ConfigSettings{Region: "us-west-2", Node: "n1"}) - require.NoError(t, err) - - for _, cfg := range configs { - assert.True(t, FileExists(cfg.Path)) - } -} - -func TestGenerateConfigFiles_StopsOnFirstError(t *testing.T) { - dir := t.TempDir() - configs := []ConfigFile{ - {Name: "ok", Path: filepath.Join(dir, "ok.conf"), Template: "ok"}, - {Name: "bad", Path: "/nonexistent/dir/bad.conf", Template: "bad"}, - {Name: "never", Path: filepath.Join(dir, "never.conf"), Template: "never"}, - } - err := GenerateConfigFiles(configs, ConfigSettings{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "bad") - assert.False(t, FileExists(filepath.Join(dir, "never.conf"))) -} - -// --- FileExists --- - -func TestFileExists(t *testing.T) { - dir := t.TempDir() - existing := filepath.Join(dir, "exists.txt") - require.NoError(t, os.WriteFile(existing, []byte("hi"), 0644)) - - assert.True(t, FileExists(existing)) - assert.False(t, FileExists(filepath.Join(dir, "nope.txt"))) - assert.True(t, FileExists(dir)) // directory also returns true -} - -// --- UpdateAWSINIFile --- - -func TestUpdateAWSINIFile_CreateNew(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "credentials") - - err := UpdateAWSINIFile(path, "spinifex", map[string]string{ - "aws_access_key_id": "AKIATEST", - "aws_secret_access_key": "secrettest", - }) - require.NoError(t, err) - - data, _ := os.ReadFile(path) - content := string(data) - assert.Contains(t, content, "[spinifex]") - assert.Contains(t, content, "AKIATEST") - assert.Contains(t, content, "secrettest") -} - -func TestUpdateAWSINIFile_UpdateExisting(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "credentials") - - // Create with initial value - require.NoError(t, UpdateAWSINIFile(path, "spinifex", map[string]string{"key": "old"})) - // Update - require.NoError(t, UpdateAWSINIFile(path, "spinifex", map[string]string{"key": "new"})) - - data, _ := os.ReadFile(path) - content := string(data) - assert.Contains(t, content, "new") -} - -func TestUpdateAWSINIFile_AddNewSection(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "credentials") - - require.NoError(t, UpdateAWSINIFile(path, "default", map[string]string{"key": "default-val"})) - require.NoError(t, UpdateAWSINIFile(path, "spinifex", map[string]string{"key": "spinifex-val"})) - - data, _ := os.ReadFile(path) - content := string(data) - assert.Contains(t, content, "[default]") - assert.Contains(t, content, "[spinifex]") - assert.Contains(t, content, "default-val") - assert.Contains(t, content, "spinifex-val") -} - -// --- WriteAWSProfileConfig --- - -// resolveProfileEndpoint resolves sdkID's endpoint through the Go SDK's own -// shared-config loader, the way a client does: the service's entry in the -// profile's services section, else the profile-wide endpoint_url. -func resolveProfileEndpoint(t *testing.T, configPath, profile, sdkID string) string { - t.Helper() - shared, err := awsconfig.LoadSharedConfigProfile(t.Context(), profile, func(o *awsconfig.LoadSharedConfigOptions) { - o.ConfigFiles = []string{configPath} - o.CredentialsFiles = []string{} - }) - require.NoError(t, err) - endpoint, found, err := shared.GetServiceBaseEndpoint(t.Context(), sdkID) - require.NoError(t, err) - if found { - return endpoint - } - return shared.BaseEndpoint -} - -func TestWriteAWSProfileConfig_SendsS3ToPredastoreAndTheRestToTheGateway(t *testing.T) { - path := filepath.Join(t.TempDir(), "config") - require.NoError(t, WriteAWSProfileConfig(path, "spinifex", "ap-southeast-2", "/ca.pem", "10.11.12.1")) - - assert.Equal(t, "https://10.11.12.1:8443", resolveProfileEndpoint(t, path, "spinifex", "S3")) - assert.Equal(t, "https://10.11.12.1:9999", resolveProfileEndpoint(t, path, "spinifex", "EC2")) - assert.Equal(t, "https://10.11.12.1:9999", resolveProfileEndpoint(t, path, "spinifex", "STS")) -} - -// A rewrite must not flatten nested settings an operator added, which is how a -// hand-written s3 block used to be lost on every re-init. -func TestWriteAWSProfileConfig_KeepsNestedSettingsAcrossARewrite(t *testing.T) { - path := filepath.Join(t.TempDir(), "config") - require.NoError(t, os.WriteFile(path, []byte(`[profile spinifex] -s3 = - max_concurrent_requests = 20 -services = spinifex - -[services spinifex] -s3 = - addressing_style = path - endpoint_url = https://stale:8443 -`), 0600)) - - require.NoError(t, WriteAWSProfileConfig(path, "spinifex", "ap-southeast-2", "/ca.pem", "localhost")) - require.NoError(t, WriteAWSProfileConfig(path, "spinifex", "ap-southeast-2", "/ca.pem", "localhost")) - - assert.Equal(t, "https://localhost:8443", resolveProfileEndpoint(t, path, "spinifex", "S3")) - shared, err := awsconfig.LoadSharedConfigProfile(t.Context(), "spinifex", func(o *awsconfig.LoadSharedConfigOptions) { - o.ConfigFiles = []string{path} - o.CredentialsFiles = []string{} - }) - require.NoError(t, err) - assert.Equal(t, map[string]string{"addressing_style": "path", "endpoint_url": "https://localhost:8443"}, - shared.Services.ServiceValues["s3"]) - - data, err := os.ReadFile(path) - require.NoError(t, err) - assert.Contains(t, string(data), "max_concurrent_requests = 20") - assert.NotContains(t, string(data), "stale") -} - -// Account profiles each get their own services section, so a tenant profile -// resolves S3 the same way as the operator's without sharing its section. -func TestWriteAWSProfileConfig_ProfilesDoNotShareAServicesSection(t *testing.T) { - path := filepath.Join(t.TempDir(), "config") - require.NoError(t, WriteAWSProfileConfig(path, "spinifex", "ap-southeast-2", "/ca.pem", "localhost")) - require.NoError(t, WriteAWSProfileConfig(path, "spinifex-acme", "ap-southeast-2", "/ca.pem", "10.0.0.5")) - - assert.Equal(t, "https://localhost:8443", resolveProfileEndpoint(t, path, "spinifex", "S3")) - assert.Equal(t, "https://10.0.0.5:8443", resolveProfileEndpoint(t, path, "spinifex-acme", "S3")) -} - -// --- SetupAWSCredentials --- - -func TestSetupAWSCredentials_CreatesFiles(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - err := SetupAWSCredentials("AKIATEST123", "secret123", "us-east-1", "/path/to/ca.pem", "") - require.NoError(t, err) - - credData, _ := os.ReadFile(filepath.Join(dir, ".aws", "credentials")) - configData, _ := os.ReadFile(filepath.Join(dir, ".aws", "config")) - - assert.Contains(t, string(credData), "AKIATEST123") - assert.Contains(t, string(credData), "secret123") - assert.Contains(t, string(configData), "us-east-1") - assert.Contains(t, string(configData), "https://localhost:9999") - assert.Contains(t, string(configData), "/path/to/ca.pem") -} - -func TestSetupAWSCredentials_PreservesExistingProfiles(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - awsDir := filepath.Join(dir, ".aws") - require.NoError(t, os.MkdirAll(awsDir, 0700)) - require.NoError(t, UpdateAWSINIFile(filepath.Join(awsDir, "credentials"), "default", map[string]string{ - "aws_access_key_id": "EXISTING_KEY", - })) - - err := SetupAWSCredentials("NEWAKEY", "NEWSECRET", "us-west-2", "/ca.pem", "") - require.NoError(t, err) - - data, _ := os.ReadFile(filepath.Join(awsDir, "credentials")) - content := string(data) - assert.Contains(t, content, "EXISTING_KEY") - assert.Contains(t, content, "NEWAKEY") -} - -func TestSetupAWSCredentials_UsesBindIP(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - err := SetupAWSCredentials("AKIATEST123", "secret123", "us-east-1", "/ca.pem", "10.11.12.1") - require.NoError(t, err) - - configPath := filepath.Join(dir, ".aws", "config") - assert.Equal(t, "https://10.11.12.1:9999", resolveProfileEndpoint(t, configPath, "spinifex", "EC2")) - assert.Equal(t, "https://10.11.12.1:8443", resolveProfileEndpoint(t, configPath, "spinifex", "S3")) -} - -func TestSetupAWSCredentials_FallsBackToLocalhostForWildcard(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - err := SetupAWSCredentials("AKIATEST123", "secret123", "us-east-1", "/ca.pem", "0.0.0.0") - require.NoError(t, err) - - configData, _ := os.ReadFile(filepath.Join(dir, ".aws", "config")) - assert.Contains(t, string(configData), "https://localhost:9999") -} - -// On a --force re-init the preserve path passes empty admin credentials: the -// existing ~/.aws/credentials must be left intact while ~/.aws/config is still -// refreshed (endpoint/CA for a changed bind IP). -func TestSetupAWSCredentials_EmptyCredsRefreshesConfigOnly(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - awsDir := filepath.Join(dir, ".aws") - require.NoError(t, os.MkdirAll(awsDir, 0700)) - require.NoError(t, UpdateAWSINIFile(filepath.Join(awsDir, "credentials"), "spinifex", map[string]string{ - "aws_access_key_id": "PRESERVED_KEY", - "aws_secret_access_key": "PRESERVED_SECRET", - })) - - err := SetupAWSCredentials("", "", "ap-southeast-2", "/new/ca.pem", "10.11.12.5") - require.NoError(t, err) - - credData, _ := os.ReadFile(filepath.Join(awsDir, "credentials")) - assert.Contains(t, string(credData), "PRESERVED_KEY", "existing admin credentials must be preserved on re-init") - assert.Contains(t, string(credData), "PRESERVED_SECRET") - - configData, _ := os.ReadFile(filepath.Join(awsDir, "config")) - assert.Contains(t, string(configData), "https://10.11.12.5:9999", "config endpoint must be refreshed") - assert.Contains(t, string(configData), "/new/ca.pem") -} - -// --- Certificate generation --- - -func TestGenerateCACert_CreatesValidCA(t *testing.T) { - t.Parallel() - dir := t.TempDir() - certPath := filepath.Join(dir, "ca.pem") - keyPath := filepath.Join(dir, "ca.key") - - err := GenerateCACert(certPath, keyPath) - require.NoError(t, err) - - // Parse certificate - certPEM, _ := os.ReadFile(certPath) - block, _ := pem.Decode(certPEM) - require.NotNil(t, block) - assert.Equal(t, "CERTIFICATE", block.Type) - - cert, err := x509.ParseCertificate(block.Bytes) - require.NoError(t, err) - assert.True(t, cert.IsCA) - assert.Equal(t, "Spinifex Local CA", cert.Subject.CommonName) - assert.NotZero(t, cert.KeyUsage&x509.KeyUsageCertSign) - - // Parse key - keyPEM, _ := os.ReadFile(keyPath) - keyBlock, _ := pem.Decode(keyPEM) - require.NotNil(t, keyBlock) - assert.Equal(t, "PRIVATE KEY", keyBlock.Type) - - // Verify key file permissions - info, _ := os.Stat(keyPath) - assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) -} - -// TestGenerateSignedCert uses subtests sharing a single CA to avoid repeated 4096-bit key generation. -func TestGenerateSignedCert(t *testing.T) { - t.Parallel() - // Generate CA once for all subtests (~0.7s instead of ~0.7s x 3) - caDir := t.TempDir() - caCertPath := filepath.Join(caDir, "ca.pem") - caKeyPath := filepath.Join(caDir, "ca.key") - require.NoError(t, GenerateCACert(caCertPath, caKeyPath)) - - t.Run("CreatesValidCert", func(t *testing.T) { - t.Parallel() - dir := t.TempDir() - certPath := filepath.Join(dir, "server.pem") - keyPath := filepath.Join(dir, "server.key") - - require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath, nil, nil)) - - certPEM, _ := os.ReadFile(certPath) - block, _ := pem.Decode(certPEM) - require.NotNil(t, block) - - cert, err := x509.ParseCertificate(block.Bytes) - require.NoError(t, err) - assert.False(t, cert.IsCA) - assert.Equal(t, "Spinifex Server", cert.Subject.CommonName) - assert.Contains(t, cert.DNSNames, "localhost") - - hasLoopback := false - for _, ip := range cert.IPAddresses { - if ip.Equal(net.ParseIP("127.0.0.1")) { - hasLoopback = true - } - } - assert.True(t, hasLoopback) - - // Auto-discovered IPs should be present (at least loopback + any interface IPs). - assert.GreaterOrEqual(t, len(cert.IPAddresses), 2) - - // Verify against CA - caCertPEM, _ := os.ReadFile(caCertPath) - caBlock, _ := pem.Decode(caCertPEM) - caCert, _ := x509.ParseCertificate(caBlock.Bytes) - pool := x509.NewCertPool() - pool.AddCert(caCert) - _, err = cert.Verify(x509.VerifyOptions{Roots: pool}) - assert.NoError(t, err) - - info, _ := os.Stat(keyPath) - assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) - }) - - t.Run("ExtraIPs", func(t *testing.T) { - t.Parallel() - dir := t.TempDir() - certPath := filepath.Join(dir, "server.pem") - keyPath := filepath.Join(dir, "server.key") - - require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath, []string{"192.168.1.100"}, nil)) - - certPEM, _ := os.ReadFile(certPath) - block, _ := pem.Decode(certPEM) - cert, _ := x509.ParseCertificate(block.Bytes) - - hasExtraIP := false - for _, ip := range cert.IPAddresses { - if ip.Equal(net.ParseIP("192.168.1.100")) { - hasExtraIP = true - } - } - assert.True(t, hasExtraIP, "cert should contain extra IP 192.168.1.100") - }) - - t.Run("SkipsDuplicateAndSpecialIPs", func(t *testing.T) { - t.Parallel() - dir := t.TempDir() - certPath := filepath.Join(dir, "server.pem") - keyPath := filepath.Join(dir, "server.key") - - require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath, []string{"127.0.0.1", "::1", "0.0.0.0", ""}, nil)) - - certPEM, _ := os.ReadFile(certPath) - block, _ := pem.Decode(certPEM) - cert, _ := x509.ParseCertificate(block.Bytes) - - // Should have loopback (127.0.0.1, ::1) + auto-discovered interface IPs. - // Duplicates (127.0.0.1, ::1) and specials (0.0.0.0, "") must not add extras. - // Count unique IPs from a clean run without extras. - baseDir := t.TempDir() - baseCert := filepath.Join(baseDir, "server.pem") - baseKey := filepath.Join(baseDir, "server.key") - require.NoError(t, GenerateSignedCert(baseCert, baseKey, caCertPath, caKeyPath, nil, nil)) - basePEM, _ := os.ReadFile(baseCert) - baseBlock, _ := pem.Decode(basePEM) - baseParsed, _ := x509.ParseCertificate(baseBlock.Bytes) - - assert.Len(t, cert.IPAddresses, len(baseParsed.IPAddresses), - "passing duplicate/special IPs should not add extra entries") - }) - - t.Run("InvalidCACert", func(t *testing.T) { - t.Parallel() - dir := t.TempDir() - badCACert := filepath.Join(dir, "bad-ca.pem") - require.NoError(t, os.WriteFile(badCACert, []byte("not-a-cert"), 0600)) - - err := GenerateSignedCert(filepath.Join(dir, "s.pem"), filepath.Join(dir, "s.key"), badCACert, caKeyPath, nil, nil) - assert.Error(t, err) - }) -} - -func TestDiscoverLocalIPs(t *testing.T) { - t.Parallel() - ips := DiscoverLocalIPs() - // Should find at least one non-loopback IP on any machine with a network interface. - assert.NotEmpty(t, ips, "expected at least one non-loopback IP") - - for _, ip := range ips { - parsed := net.ParseIP(ip) - require.NotNil(t, parsed, "returned IP should be parseable: %s", ip) - assert.False(t, parsed.IsLoopback(), "should not include loopback: %s", ip) - assert.False(t, parsed.IsLinkLocalUnicast(), "should not include link-local: %s", ip) - } - - // No duplicates. - seen := make(map[string]struct{}) - for _, ip := range ips { - _, dup := seen[ip] - assert.False(t, dup, "duplicate IP: %s", ip) - seen[ip] = struct{}{} - } -} - -func TestDiscoverHostname(t *testing.T) { - t.Parallel() - hostname := DiscoverHostname() - // We can't assert a specific value, but if non-empty it shouldn't be "localhost". - if hostname != "" { - assert.NotEqual(t, "localhost", hostname) - } -} - -func TestGenerateSignedCert_DedupDNS(t *testing.T) { - t.Parallel() - caDir := t.TempDir() - caCertPath := filepath.Join(caDir, "ca.pem") - caKeyPath := filepath.Join(caDir, "ca.key") - require.NoError(t, GenerateCACert(caCertPath, caKeyPath)) - - dir := t.TempDir() - certPath := filepath.Join(dir, "server.pem") - keyPath := filepath.Join(dir, "server.key") - - // Pass "localhost" as extra DNS — should not duplicate the built-in entry. - // Also pass empty strings and duplicates — should be ignored. - extraDNS := []string{"localhost", "", "example.local", "example.local", ""} - require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath, nil, extraDNS)) - - certPEM, _ := os.ReadFile(certPath) - block, _ := pem.Decode(certPEM) - cert, _ := x509.ParseCertificate(block.Bytes) - - // Count occurrences of "localhost" — must be exactly 1. - localhostCount := 0 - exampleCount := 0 - for _, dns := range cert.DNSNames { - if dns == "localhost" { - localhostCount++ - } - if dns == "example.local" { - exampleCount++ - } - } - assert.Equal(t, 1, localhostCount, "localhost should appear exactly once") - assert.Equal(t, 1, exampleCount, "example.local should appear exactly once") - assert.Contains(t, cert.DNSNames, "example.local") -} - -func TestGenerateSignedCert_NilSlices(t *testing.T) { - t.Parallel() - caDir := t.TempDir() - caCertPath := filepath.Join(caDir, "ca.pem") - caKeyPath := filepath.Join(caDir, "ca.key") - require.NoError(t, GenerateCACert(caCertPath, caKeyPath)) - - dir := t.TempDir() - certPath := filepath.Join(dir, "server.pem") - keyPath := filepath.Join(dir, "server.key") - - // Both slices nil — should still produce a valid cert with defaults. - require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath, nil, nil)) - - certPEM, _ := os.ReadFile(certPath) - block, _ := pem.Decode(certPEM) - cert, _ := x509.ParseCertificate(block.Bytes) - - assert.Contains(t, cert.DNSNames, "localhost") - assert.GreaterOrEqual(t, len(cert.IPAddresses), 2, "should have at least loopback IPs") -} - -func TestGenerateSignedCert_InvalidCA(t *testing.T) { - t.Parallel() - dir := t.TempDir() - - // Missing CA cert file. - err := GenerateSignedCert( - filepath.Join(dir, "s.pem"), filepath.Join(dir, "s.key"), - filepath.Join(dir, "missing-ca.pem"), filepath.Join(dir, "missing-ca.key"), - nil, nil, - ) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to read CA cert") - - // Corrupt CA cert. - require.NoError(t, os.WriteFile(filepath.Join(dir, "bad-ca.pem"), []byte("not-pem"), 0600)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "bad-ca.key"), []byte("not-pem"), 0600)) - err = GenerateSignedCert( - filepath.Join(dir, "s.pem"), filepath.Join(dir, "s.key"), - filepath.Join(dir, "bad-ca.pem"), filepath.Join(dir, "bad-ca.key"), - nil, nil, - ) - assert.Error(t, err) -} - -func TestGenerateSignedCert_ExtraDNS(t *testing.T) { - t.Parallel() - caDir := t.TempDir() - caCertPath := filepath.Join(caDir, "ca.pem") - caKeyPath := filepath.Join(caDir, "ca.key") - require.NoError(t, GenerateCACert(caCertPath, caKeyPath)) - - dir := t.TempDir() - certPath := filepath.Join(dir, "server.pem") - keyPath := filepath.Join(dir, "server.key") - - extraIPs := []string{"10.0.0.42"} - extraDNS := []string{"spinifex.local", "node1.spinifex.local"} - require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath, extraIPs, extraDNS)) - - certPEM, _ := os.ReadFile(certPath) - block, _ := pem.Decode(certPEM) - cert, _ := x509.ParseCertificate(block.Bytes) - - assert.Contains(t, cert.DNSNames, "localhost") - assert.Contains(t, cert.DNSNames, "spinifex.local") - assert.Contains(t, cert.DNSNames, "node1.spinifex.local") - - hasExtraIP := false - for _, ip := range cert.IPAddresses { - if ip.Equal(net.ParseIP("10.0.0.42")) { - hasExtraIP = true - } - } - assert.True(t, hasExtraIP, "cert should contain extra IP 10.0.0.42") - - info, _ := os.Stat(keyPath) - assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) -} - -func TestGenerateSignedCert_IncludesHostname(t *testing.T) { - t.Parallel() - caDir := t.TempDir() - caCertPath := filepath.Join(caDir, "ca.pem") - caKeyPath := filepath.Join(caDir, "ca.key") - require.NoError(t, GenerateCACert(caCertPath, caKeyPath)) - - dir := t.TempDir() - certPath := filepath.Join(dir, "server.pem") - keyPath := filepath.Join(dir, "server.key") - require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath, nil, nil)) - - certPEM, _ := os.ReadFile(certPath) - block, _ := pem.Decode(certPEM) - cert, _ := x509.ParseCertificate(block.Bytes) - - assert.Contains(t, cert.DNSNames, "localhost") - hostname := DiscoverHostname() - if hostname != "" { - assert.Contains(t, cert.DNSNames, hostname, - "cert DNS SANs should include the machine hostname") - } -} - -// --- Certificate orchestrator --- - -// TestGenerateCertificatesIfNeeded uses subtests to share the initial generation. -// -//nolint:tparallel // subtests mutate the shared dir in order: SkipsWhenAllExist pins the CA modtime that ForcePreservesCARegeneratesServerCert then regenerates against -func TestGenerateCertificatesIfNeeded(t *testing.T) { - t.Parallel() - dir := t.TempDir() - - // First call generates all certs - caCertPath := GenerateCertificatesIfNeeded(dir, false, "", "us-east-1", "spinifex.internal") - assert.Equal(t, filepath.Join(dir, "ca.pem"), caCertPath) - assert.True(t, FileExists(filepath.Join(dir, "ca.pem"))) - assert.True(t, FileExists(filepath.Join(dir, "ca.key"))) - assert.True(t, FileExists(filepath.Join(dir, "server.pem"))) - assert.True(t, FileExists(filepath.Join(dir, "server.key"))) - - t.Run("SkipsWhenAllExist", func(t *testing.T) { - caInfo, _ := os.Stat(filepath.Join(dir, "ca.pem")) - origModTime := caInfo.ModTime() - - GenerateCertificatesIfNeeded(dir, false, "", "us-east-1", "spinifex.internal") - caInfo2, _ := os.Stat(filepath.Join(dir, "ca.pem")) - assert.Equal(t, origModTime, caInfo2.ModTime()) - }) - - // --force must preserve the CA (trust anchor for joined nodes / baked AMIs) - // and only re-sign the server cert, which stays verifiable against that CA. - t.Run("ForcePreservesCARegeneratesServerCert", func(t *testing.T) { - origCA, _ := os.ReadFile(filepath.Join(dir, "ca.pem")) - origCAKey, _ := os.ReadFile(filepath.Join(dir, "ca.key")) - origServer, _ := os.ReadFile(filepath.Join(dir, "server.pem")) - - GenerateCertificatesIfNeeded(dir, true, "10.9.8.7", "us-east-1", "spinifex.internal") - - newCA, _ := os.ReadFile(filepath.Join(dir, "ca.pem")) - newCAKey, _ := os.ReadFile(filepath.Join(dir, "ca.key")) - newServer, _ := os.ReadFile(filepath.Join(dir, "server.pem")) - assert.Equal(t, origCA, newCA, "CA cert must be preserved on --force") - assert.Equal(t, origCAKey, newCAKey, "CA key must be preserved on --force") - assert.NotEqual(t, origServer, newServer, "server cert must be re-signed on --force") - - // The re-signed server cert must verify against the preserved CA. - caBlock, _ := pem.Decode(newCA) - caCert, err := x509.ParseCertificate(caBlock.Bytes) - require.NoError(t, err) - pool := x509.NewCertPool() - pool.AddCert(caCert) - - srvBlock, _ := pem.Decode(newServer) - srvCert, err := x509.ParseCertificate(srvBlock.Bytes) - require.NoError(t, err) - _, err = srvCert.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}) - assert.NoError(t, err, "re-signed server cert must verify against preserved CA") - }) - - // The mgmt-bridge IP must be a SAN even though br-mgmt is not a live - // interface here (interface enumeration cannot discover it), mirroring a - // host where br-mgmt is down when the cert is minted. Without the explicit - // pin the control-plane publish to https:// would fail cert verify. - t.Run("MgmtBridgeIPAlwaysInSAN", func(t *testing.T) { - certDir := t.TempDir() - GenerateCertificatesIfNeeded(certDir, false, "10.0.0.5", "us-east-1", "spinifex.internal") - assert.True(t, certHasIP(t, filepath.Join(certDir, "server.pem"), config.DefaultMgmtBridgeIP), - "server cert must carry the canonical mgmt-bridge IP SAN regardless of br-mgmt state") - }) -} - -func TestGenerateServerCertOnly(t *testing.T) { - t.Parallel() - // Generate CA once, reuse for subtests - caDir := t.TempDir() - require.NoError(t, GenerateCACert(filepath.Join(caDir, "ca.pem"), filepath.Join(caDir, "ca.key"))) - - t.Run("Success", func(t *testing.T) { - t.Parallel() - dir := t.TempDir() - // Copy CA files into test dir - caCert, _ := os.ReadFile(filepath.Join(caDir, "ca.pem")) - caKey, _ := os.ReadFile(filepath.Join(caDir, "ca.key")) - require.NoError(t, os.WriteFile(filepath.Join(dir, "ca.pem"), caCert, 0600)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "ca.key"), caKey, 0600)) - - err := GenerateServerCertOnly(dir, "10.0.0.5", "us-east-1", "spinifex.internal") - require.NoError(t, err) - assert.True(t, FileExists(filepath.Join(dir, "server.pem"))) - assert.True(t, FileExists(filepath.Join(dir, "server.key"))) - - certPEM, _ := os.ReadFile(filepath.Join(dir, "server.pem")) - block, _ := pem.Decode(certPEM) - cert, _ := x509.ParseCertificate(block.Bytes) - - hasBindIP := false - for _, ip := range cert.IPAddresses { - if ip.Equal(net.ParseIP("10.0.0.5")) { - hasBindIP = true - } - } - assert.True(t, hasBindIP) - - // AWS-parity ECR SANs must be present. - assert.Contains(t, cert.DNSNames, "ecr.us-east-1.spinifex.internal") - assert.Contains(t, cert.DNSNames, "*.dkr.ecr.us-east-1.spinifex.internal") - - // Joining nodes must also pin the mgmt-bridge IP regardless of br-mgmt state. - assert.True(t, certHasIP(t, filepath.Join(dir, "server.pem"), config.DefaultMgmtBridgeIP), - "server cert must carry the canonical mgmt-bridge IP SAN") - }) - - t.Run("MissingCA", func(t *testing.T) { - t.Parallel() - dir := t.TempDir() - err := GenerateServerCertOnly(dir, "10.0.0.5", "us-east-1", "spinifex.internal") - assert.Error(t, err) - assert.Contains(t, err.Error(), "CA files not found") - }) -} - -func TestAWSGWServiceDNSNames(t *testing.T) { - t.Parallel() - - got := AWSGWServiceDNSNames("us-east-1", "spinifex.internal") - assert.Equal(t, []string{ - "ecr.us-east-1.spinifex.internal", - "*.dkr.ecr.us-east-1.spinifex.internal", - "ec2.us-east-1.spinifex.internal", - "sts.us-east-1.spinifex.internal", - "elasticloadbalancing.us-east-1.spinifex.internal", - "ecs.us-east-1.spinifex.internal", - "eks.us-east-1.spinifex.internal", - "acm.us-east-1.spinifex.internal", - }, got) - - // SANs and DNS records must share one list so they can never drift. - for _, svc := range config.AWSGWServiceNames { - assert.Contains(t, got, svc+".us-east-1.spinifex.internal") - } - - assert.Nil(t, AWSGWServiceDNSNames("", "spinifex.internal")) - assert.Nil(t, AWSGWServiceDNSNames("us-east-1", "")) - assert.Nil(t, AWSGWServiceDNSNames("", "")) -} - -// --- Directory creation --- - -func TestCreateServiceDirectories_CreatesAll(t *testing.T) { - dir := t.TempDir() - CreateServiceDirectories(dir) - - expected := []string{"images", "amis", "volumes", "state", "logs", "nats", "predastore", "viperblock", "spinifex"} - for _, name := range expected { - path := filepath.Join(dir, name) - info, err := os.Stat(path) - assert.NoError(t, err, "directory %s should exist", name) - if err == nil { - assert.True(t, info.IsDir()) - } - } -} - -func TestCreateServiceDirectories_Idempotent(t *testing.T) { - dir := t.TempDir() - CreateServiceDirectories(dir) - // Should not error on second call - CreateServiceDirectories(dir) - - info, err := os.Stat(filepath.Join(dir, "images")) - assert.NoError(t, err) - assert.True(t, info.IsDir()) -} - -// --- Predastore multi-node config --- - -func TestGenerateMultiNodePredastoreConfig_Success(t *testing.T) { - tmpl := `{{range .Nodes}}[[host]] -id = {{.ID}} -addr = "{{.Host}}" -data_dir = "{{$.PredastoreDataDir}}" -{{end}}` - nodes := []formation.PredastoreNodeConfig{ - {ID: 1, Host: "10.0.0.1"}, - {ID: 2, Host: "10.0.0.2"}, - {ID: 3, Host: "10.0.0.3"}, - } - - result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, NorthstarCredentials{}) - require.NoError(t, err) - assert.Contains(t, result, `addr = "10.0.0.1"`) - assert.Contains(t, result, `addr = "10.0.0.3"`) - assert.Contains(t, result, `data_dir = "/var/lib/spinifex/predastore/cluster"`) -} - -// Each machine hosts a gate, a blob node and a meta node, with node IDs unique -// across all three roles so the topology validates. -func TestGenerateMultiNodePredastoreConfig_Topology(t *testing.T) { - tmpl := `{{range .ClusterNodes}}[[host.node]] -id = {{.ID}} -host_id = {{.HostID}} -role = "{{.Role}}" -port = {{.Port}} -{{end}}` - nodes := []formation.PredastoreNodeConfig{ - {ID: 1, Host: "10.0.0.1"}, - {ID: 2, Host: "10.0.0.2"}, - {ID: 3, Host: "10.0.0.3"}, - } - - result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, NorthstarCredentials{}) - require.NoError(t, err) - - assert.Contains(t, result, "id = 1\nhost_id = 1\nrole = \"gate\"\nport = 8443") - assert.Contains(t, result, "id = 4\nhost_id = 1\nrole = \"blob\"\nport = 6660") - assert.Contains(t, result, "id = 6\nhost_id = 3\nrole = \"blob\"\nport = 6660") - assert.Contains(t, result, "id = 7\nhost_id = 1\nrole = \"meta\"\nport = 7660") - assert.Contains(t, result, "id = 9\nhost_id = 3\nrole = \"meta\"\nport = 7660") -} - -func TestPredastoreTopology_UniqueIDsAcrossRoles(t *testing.T) { - topology := PredastoreTopology([]formation.PredastoreNodeConfig{ - {ID: 1, Host: "10.0.0.1"}, - {ID: 2, Host: "10.0.0.2"}, - }) - - require.Len(t, topology, 6) - seen := map[int]bool{} - for _, n := range topology { - assert.False(t, seen[n.ID], "duplicate node id %d", n.ID) - seen[n.ID] = true - assert.Contains(t, []int{1, 2}, n.HostID) - } - assert.Equal(t, "gate", topology[0].Role) - assert.Equal(t, "blob", topology[2].Role) - assert.Equal(t, "meta", topology[4].Role) -} - -// The northstar template fields were declared but never assigned, so every -// multi-node predastore config silently rendered the empty-key path: no zone -// bucket and no credential the resolver could authenticate with. -func TestGenerateMultiNodePredastoreConfig_NorthstarCredentialsReachTemplate(t *testing.T) { - tmpl := `access = "{{.NorthstarAccessKey}}" secret = "{{.NorthstarSecretKey}}" bucket = "{{.NorthstarBucket}}"` - nodes := []formation.PredastoreNodeConfig{ - {ID: 1, Host: "10.0.0.1"}, - {ID: 2, Host: "10.0.0.2"}, - } - - result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, - NorthstarCredentials{AccessKey: "NSAK", SecretKey: "NSSK", Bucket: "northstar"}) - require.NoError(t, err) - assert.Equal(t, `access = "NSAK" secret = "NSSK" bucket = "northstar"`, result) -} - -// A zero credential must leave every northstar field empty, which is what the -// production template's guards key off to omit the stanzas entirely. -func TestGenerateMultiNodePredastoreConfig_NoNorthstarCredentials(t *testing.T) { - tmpl := `access = "{{.NorthstarAccessKey}}" secret = "{{.NorthstarSecretKey}}" bucket = "{{.NorthstarBucket}}"` - nodes := []formation.PredastoreNodeConfig{ - {ID: 1, Host: "10.0.0.1"}, - {ID: 2, Host: "10.0.0.2"}, - } - - result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, - NorthstarCredentials{}) - require.NoError(t, err) - assert.Equal(t, `access = "" secret = "" bucket = ""`, result) -} - -func TestGenerateMultiNodePredastoreConfig_MinimumNodes(t *testing.T) { - tmpl := "{{range .Nodes}}{{.ID}}{{end}}" - - _, err := GenerateMultiNodePredastoreConfig(tmpl, []formation.PredastoreNodeConfig{ - {ID: 1, Host: "10.0.0.1"}, - }, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, NorthstarCredentials{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "at least 2 nodes") -} - -func TestGenerateMultiNodePredastoreConfig_InvalidTemplate(t *testing.T) { - _, err := GenerateMultiNodePredastoreConfig("{{.Unclosed", []formation.PredastoreNodeConfig{ - {ID: 1, Host: "a"}, {ID: 2, Host: "b"}, {ID: 3, Host: "c"}, - }, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, NorthstarCredentials{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse") -} - -// --- FindNodeIDByIP --- - -func TestFindNodeIDByIP(t *testing.T) { - nodes := []formation.PredastoreNodeConfig{ - {ID: 1, Host: "10.0.0.1"}, - {ID: 2, Host: "10.0.0.2"}, - {ID: 3, Host: "10.0.0.3"}, - } - - assert.Equal(t, 2, FindNodeIDByIP(nodes, "10.0.0.2")) - assert.Equal(t, 0, FindNodeIDByIP(nodes, "10.0.0.99")) - assert.Equal(t, 0, FindNodeIDByIP(nil, "10.0.0.1")) -} - -// --- ParsePredastoreHostIDFromConfig --- - -func TestParsePredastoreHostIDFromConfig(t *testing.T) { - tomlContent := ` -[[host]] -id = 1 -bind_addr = "0.0.0.0" -addr = "10.0.0.1" - -[[host.node]] -id = 1 -role = "gate" -port = 8443 - -[[host]] -id = 2 -bind_addr = "0.0.0.0" -addr = "10.0.0.2" - -[[host]] -id = 3 -bind_addr = "0.0.0.0" -addr = "10.0.0.3" -` - assert.Equal(t, 2, ParsePredastoreHostIDFromConfig(tomlContent, "10.0.0.2")) - assert.Equal(t, 0, ParsePredastoreHostIDFromConfig(tomlContent, "10.0.0.99")) - assert.Equal(t, 0, ParsePredastoreHostIDFromConfig("invalid toml {{{", "10.0.0.1")) - assert.Equal(t, 0, ParsePredastoreHostIDFromConfig("", "10.0.0.1")) -} - -// A host addr never carries a port, so one written with a node's port attached -// names no machine and must not resolve to a host. -func TestParsePredastoreHostIDFromConfig_AddressWithPortDoesNotMatch(t *testing.T) { - tomlContent := ` -[[host]] -id = 7 -addr = "10.0.0.7:6660" -` - assert.Equal(t, 0, ParsePredastoreHostIDFromConfig(tomlContent, "10.0.0.7")) -} - -// --- Integration: Full config generation flow --- - -func TestGenerateConfigFile_SpinifexTomlTemplate(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "spinifex.toml") - - tmpl := `version = "1.0" -epoch = 1 -node = "{{.Node}}" - -[nodes.{{.Node}}] -region = "{{.Region}}" -accesskey = "{{.AccessKey}}" -secretkey = "{{.SecretKey}}" -` - - settings := ConfigSettings{ - Node: "node1", - Region: "us-east-1", - AccessKey: "AKIATEST", - SecretKey: "SECRET", - } - - require.NoError(t, GenerateConfigFile(path, tmpl, settings)) - - data, _ := os.ReadFile(path) - content := string(data) - assert.Contains(t, content, `node = "node1"`) - assert.Contains(t, content, `region = "us-east-1"`) - assert.Contains(t, content, fmt.Sprintf(`accesskey = "%s"`, settings.AccessKey)) -} - -func TestChownRecursive_InvalidUser(t *testing.T) { - // ChownRecursive must report a non-nil error naming the user when the - // user cannot be resolved, rather than silently doing nothing. - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("test"), 0644) - - const badUser = "nonexistent-user-that-does-not-exist-12345" - err := ChownRecursive(tmpDir, badUser) - require.Error(t, err) - assert.Contains(t, err.Error(), badUser) - - // File should still exist and be readable - _, readErr := os.ReadFile(testFile) - assert.NoError(t, readErr) -} - -func TestChownRecursive_CurrentUser(t *testing.T) { - tmpDir := t.TempDir() - subDir := filepath.Join(tmpDir, "sub") - os.MkdirAll(subDir, 0755) - testFile := filepath.Join(subDir, "test.txt") - os.WriteFile(testFile, []byte("test"), 0644) - - // Chown to current user should succeed (no-op effectively) - currentUser := os.Getenv("USER") - if currentUser == "" { - t.Skip("USER env not set") - } - - assert.NoError(t, ChownRecursive(tmpDir, currentUser)) - - // Verify files are still accessible - _, err := os.ReadFile(testFile) - assert.NoError(t, err) -} - -func TestChownRecursive_NonExistentPath(t *testing.T) { - // Should not panic on a path that doesn't exist; OpenRoot fails and the - // fallback Lchown fails too, but both are logged, not returned — the - // user itself resolved fine, so this is not one of the abort cases. - currentUser := os.Getenv("USER") - if currentUser == "" { - t.Skip("USER env not set") - } - assert.NoError(t, ChownRecursive("/tmp/nonexistent-path-12345", currentUser)) -} - -func TestChownServicePaths_MissingUser(t *testing.T) { - // chownServicePaths is the map-walking core extracted from - // SetServiceOwnership so it can be exercised against a small map - // instead of the hardcoded /etc/spinifex and /var/lib/spinifex paths, - // which are not writable/creatable without root in CI. - tmpDir := t.TempDir() - require.NoError(t, os.MkdirAll(tmpDir, 0755)) - - const badUser = "nonexistent-user-that-does-not-exist-12345" - err := chownServicePaths(map[string]string{tmpDir: badUser}) - require.Error(t, err) - assert.Contains(t, err.Error(), badUser) - assert.Contains(t, err.Error(), tmpDir) -} - -func TestChownServicePaths_SkipsMissingPath(t *testing.T) { - // A path that doesn't exist on disk is never attempted, so it can't - // produce a "user not found" failure even for a bogus user. - err := chownServicePaths(map[string]string{ - "/nonexistent-path-12345": "nonexistent-user-that-does-not-exist-12345", - }) - assert.NoError(t, err) -} - -// --- SetGPUPassthrough --- - -func writeToml(t *testing.T, content string) string { - t.Helper() - f, err := os.CreateTemp(t.TempDir(), "spinifex*.toml") - require.NoError(t, err) - _, err = f.WriteString(content) - require.NoError(t, err) - require.NoError(t, f.Close()) - return f.Name() -} - -func readToml(t *testing.T, path string) string { - t.Helper() - b, err := os.ReadFile(path) - require.NoError(t, err) - return string(b) -} - -func TestSetGPUPassthrough_NoOp(t *testing.T) { - toml := "[nodes.node1.daemon]\ngpu_passthrough = true\n" - path := writeToml(t, toml) - require.NoError(t, SetGPUPassthrough(path, "node1", true)) - assert.Equal(t, toml, readToml(t, path)) -} - -func TestSetGPUPassthrough_Flip(t *testing.T) { - path := writeToml(t, "[nodes.node1.daemon]\ngpu_passthrough = false\n") - require.NoError(t, SetGPUPassthrough(path, "node1", true)) - assert.Contains(t, readToml(t, path), "gpu_passthrough = true") -} - -func TestSetGPUPassthrough_AddKeyToSection(t *testing.T) { - path := writeToml(t, "[nodes.node1.daemon]\nsome_other = true\n") - require.NoError(t, SetGPUPassthrough(path, "node1", true)) - got := readToml(t, path) - assert.Contains(t, got, "gpu_passthrough = true") - assert.Contains(t, got, "some_other = true") -} - -func TestSetGPUPassthrough_AppendSection(t *testing.T) { - path := writeToml(t, "[nodes.node1.network]\ncidr = \"10.0.0.0/24\"\n") - require.NoError(t, SetGPUPassthrough(path, "node1", true)) - got := readToml(t, path) - assert.Contains(t, got, "[nodes.node1.daemon]") - assert.Contains(t, got, "gpu_passthrough = true") -} - -func TestSetGPUPassthrough_DisableNoOp(t *testing.T) { - toml := "[nodes.node1.daemon]\ngpu_passthrough = false\n" - path := writeToml(t, toml) - require.NoError(t, SetGPUPassthrough(path, "node1", false)) - assert.Equal(t, toml, readToml(t, path)) -} - -func TestSetGPUPassthrough_ReadError(t *testing.T) { - err := SetGPUPassthrough("/nonexistent/path/spinifex.toml", "node1", true) - require.Error(t, err) -} - -func TestSetGPUPassthrough_SectionBoundary(t *testing.T) { - // gpu_passthrough = true exists but in a DIFFERENT node's section; should still write. - path := writeToml(t, "[nodes.node2.daemon]\ngpu_passthrough = true\n[nodes.node1.daemon]\nsome_key = 1\n") - require.NoError(t, SetGPUPassthrough(path, "node1", true)) - got := readToml(t, path) - // node1 section should now have the key - assert.Contains(t, got, "[nodes.node1.daemon]\ngpu_passthrough = true") -} - -// --- SetMIGProfile --- - -func TestSetMIGProfile_SectionDoesNotExist_CreatesWithProfile(t *testing.T) { - path := writeToml(t, "[nodes.node1.network]\ncidr = \"10.0.0.0/24\"\n") - require.NoError(t, SetMIGProfile(path, "node1", "1g.10gb")) - got := readToml(t, path) - assert.Contains(t, got, "[nodes.node1.daemon]") - assert.Contains(t, got, `mig_profile = "1g.10gb"`) -} - -func TestSetMIGProfile_SectionExistsMIGProfileMissing_AddsIt(t *testing.T) { - path := writeToml(t, "[nodes.node1.daemon]\nsome_other = true\n") - require.NoError(t, SetMIGProfile(path, "node1", "1g.10gb")) - got := readToml(t, path) - assert.Contains(t, got, `mig_profile = "1g.10gb"`) - assert.Contains(t, got, "some_other = true") -} - -func TestSetMIGProfile_AlreadyCorrectValue_Idempotent(t *testing.T) { - toml := "[nodes.node1.daemon]\nmig_profile = \"1g.10gb\"\n" - path := writeToml(t, toml) - require.NoError(t, SetMIGProfile(path, "node1", "1g.10gb")) - assert.Equal(t, toml, readToml(t, path)) -} - -func TestSetMIGProfile_DifferentValue_UpdatesIt(t *testing.T) { - path := writeToml(t, "[nodes.node1.daemon]\nmig_profile = \"1g.10gb\"\n") - require.NoError(t, SetMIGProfile(path, "node1", "7g.80gb")) - got := readToml(t, path) - assert.Contains(t, got, `mig_profile = "7g.80gb"`) - assert.NotContains(t, got, `mig_profile = "1g.10gb"`) -} - -func TestSetMIGProfile_FileDoesNotExist_ReturnsError(t *testing.T) { - err := SetMIGProfile(filepath.Join(t.TempDir(), "nonexistent.toml"), "node1", "1g.10gb") - require.Error(t, err) -} diff --git a/spinifex/admin/images_promote.go b/spinifex/admin/images_promote.go index 7a7f08f59..128c6c98b 100644 --- a/spinifex/admin/images_promote.go +++ b/spinifex/admin/images_promote.go @@ -46,7 +46,7 @@ func PromoteSystemImage(store objectstore.ObjectStore, bucket string, opts Promo return nil, errors.New(awserrors.ErrorInvalidAMIIDMalformed) } - meta, err := readAMI(store, bucket, opts.ImageID) + meta, err := ebsmetadata.NewStore(store, bucket).GetAMI(context.Background(), opts.ImageID) switch { case err == nil: // ok @@ -130,7 +130,7 @@ func readPromotedSnapshot(store *ebsmetadata.Store, owner, snapshotID string) (e // GetAMIMetadata reads and returns the control-plane document for the given // image ID. Returns ErrorInvalidAMIIDNotFound for missing or corrupt documents. func GetAMIMetadata(store objectstore.ObjectStore, bucket, imageID string) (ebsmetadata.AMI, error) { - meta, err := readAMI(store, bucket, imageID) + meta, err := ebsmetadata.NewStore(store, bucket).GetAMI(context.Background(), imageID) switch { case err == nil: return meta, nil diff --git a/spinifex/admin/images_promote_test.go b/spinifex/admin/images_promote_test.go index a96cec960..13f96a255 100644 --- a/spinifex/admin/images_promote_test.go +++ b/spinifex/admin/images_promote_test.go @@ -2,6 +2,7 @@ package admin import ( "bytes" + "context" awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" "testing" @@ -14,6 +15,55 @@ import ( "github.com/stretchr/testify/require" ) +const ( + testRemoveBucket = "test-bucket" + testRemoveAccountID = "000000000001" +) + +// putAMI, putCorruptAMI and putSnapMetadata are duplicated from +// operator/admin/images_remove_test.go: that package now owns removal, and +// this one cannot import its _test.go fixtures across the package split. + +// putAMI registers an AMI the only way the control plane knows one: as an +// ebsmetadata document. +func putAMI(t *testing.T, store *objectstore.MemoryObjectStore, imageID, name, owner, snapshotID string) { + t.Helper() + require.NoError(t, ebsmetadata.NewStore(store, testRemoveBucket).PutAMI(t.Context(), ebsmetadata.AMI{ + ImageID: imageID, + Name: name, + ImageOwnerAlias: owner, + SnapshotID: snapshotID, + VolumeSizeGiB: 8, + })) +} + +// putCorruptAMI writes an undecodable document at the AMI's key: present, so +// not not-found, but unreadable. +func putCorruptAMI(t *testing.T, store *objectstore.MemoryObjectStore, imageID string) { + t.Helper() + key, err := ebsmetadata.AMIKey(imageID) + require.NoError(t, err) + _, err = store.PutObject(t.Context(), &awss3.PutObjectInput{ + Bucket: aws.String(testRemoveBucket), + Key: aws.String(key), + Body: bytes.NewReader([]byte("{not valid json")), + }) + require.NoError(t, err) +} + +// putSnapMetadata writes a CopyImage-derived EC2 snapshot metadata that +// points VolumeID back at an admin-imported AMI's ID. +func putSnapMetadata(t *testing.T, store *objectstore.MemoryObjectStore, snapID, volumeID string) { + t.Helper() + require.NoError(t, ebsmetadata.NewStore(store, testRemoveBucket).PutSnapshot(t.Context(), ebsmetadata.Snapshot{ + SnapshotID: snapID, + VolumeID: volumeID, + VolumeSize: 8, + State: "completed", + OwnerID: testRemoveAccountID, + })) +} + func TestPromoteSystemImage_HappyPath(t *testing.T) { store := objectstore.NewMemoryObjectStore() const id = "ami-user-001" @@ -35,7 +85,7 @@ func TestPromoteSystemImage_HappyPath(t *testing.T) { assert.Equal(t, testRemoveAccountID, result.PreviousOwner) // Verify the persisted document now carries the system alias. - meta, err := readAMI(store, testRemoveBucket, id) + meta, err := ebsmetadata.NewStore(store, testRemoveBucket).GetAMI(context.Background(), id) require.NoError(t, err) assert.Equal(t, SystemOwnerAlias, meta.ImageOwnerAlias) // Other fields must be preserved. @@ -78,7 +128,7 @@ func TestPromoteSystemImage_NoSnapshotDocument_Promotes(t *testing.T) { _, err := PromoteSystemImage(store, testRemoveBucket, PromoteImageOpts{ImageID: id}) require.NoError(t, err) - meta, err := readAMI(store, testRemoveBucket, id) + meta, err := ebsmetadata.NewStore(store, testRemoveBucket).GetAMI(context.Background(), id) require.NoError(t, err) assert.Equal(t, SystemOwnerAlias, meta.ImageOwnerAlias) } @@ -103,7 +153,7 @@ func TestPromoteSystemImage_CorruptSnapshotDocument_Refused(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "corrupt") - meta, err := readAMI(store, testRemoveBucket, id) + meta, err := ebsmetadata.NewStore(store, testRemoveBucket).GetAMI(context.Background(), id) require.NoError(t, err) assert.Equal(t, testRemoveAccountID, meta.ImageOwnerAlias, "the alias must not move without its snapshot") } diff --git a/spinifex/admin/admin.go b/spinifex/operator/admin/admin.go similarity index 97% rename from spinifex/admin/admin.go rename to spinifex/operator/admin/admin.go index 89498d93b..38d32294f 100644 --- a/spinifex/admin/admin.go +++ b/spinifex/operator/admin/admin.go @@ -606,33 +606,6 @@ func saveAWSINIFile(cfg *ini.File, path string) error { return nil } -// GenerateAWSAccessKey generates an AWS-style access key (AKIA + 16 random alphanumeric chars). -func GenerateAWSAccessKey() (string, error) { - const prefix = "AKIA" - const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - const length = 16 - - result := make([]byte, length) - for i := range result { - num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) - if err != nil { - return "", fmt.Errorf("crypto/rand failure: %w", err) - } - result[i] = charset[num.Int64()] - } - - return prefix + string(result), nil -} - -// GenerateAWSSecretKey generates a 40-character base64-encoded AWS-style secret key. -func GenerateAWSSecretKey() (string, error) { - bytes := make([]byte, 30) // 30 bytes = 40 chars in base64 - if _, err := rand.Read(bytes); err != nil { - return "", fmt.Errorf("crypto/rand failure: %w", err) - } - return base64.StdEncoding.EncodeToString(bytes), nil -} - // Northstar DNS defaults baked into config files at init time. const ( // NorthstarBucketName is the S3 bucket holding DNS zone files. @@ -657,17 +630,6 @@ type NorthstarCredentials struct { Bucket string } -// DefaultAccountID returns the default admin account ID (000000000001). -// This is the first human-facing account created during bootstrap. -func DefaultAccountID() string { - return "000000000001" -} - -// DefaultAccountName returns the default admin account name ("spinifex"). -func DefaultAccountName() string { - return "spinifex" -} - // GenerateNATSToken generates a secure random token for NATS. func GenerateNATSToken() (string, error) { bytes := make([]byte, 32) diff --git a/spinifex/operator/admin/admin_test.go b/spinifex/operator/admin/admin_test.go new file mode 100644 index 000000000..d143cf054 --- /dev/null +++ b/spinifex/operator/admin/admin_test.go @@ -0,0 +1,1215 @@ +package admin + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/mulgadc/spinifex/spinifex/bootstrap/config" + "github.com/mulgadc/spinifex/spinifex/runtime/formation" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// certHasIP reports whether a PEM-encoded cert carries ip as an IP SAN. +func certHasIP(t *testing.T, certPath, ip string) bool { + t.Helper() + certPEM, err := os.ReadFile(certPath) + require.NoError(t, err) + block, _ := pem.Decode(certPEM) + require.NotNil(t, block) + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + for _, got := range cert.IPAddresses { + if got.Equal(net.ParseIP(ip)) { + return true + } + } + return false +} + +// --- Key / Token generation --- + +func TestGenerateNATSToken_Format(t *testing.T) { + token, err := GenerateNATSToken() + assert.NoError(t, err) + assert.True(t, strings.HasPrefix(token, "nats_")) + assert.Len(t, token, 37) // 5 prefix + 32 random + // URL-safe base64: no '+' or '/' + assert.NotContains(t, token, "+") + assert.NotContains(t, token, "/") +} + +func TestGenerateNATSToken_Uniqueness(t *testing.T) { + t1, err := GenerateNATSToken() + assert.NoError(t, err) + t2, err := GenerateNATSToken() + assert.NoError(t, err) + assert.NotEqual(t, t1, t2) +} + +// --- Config file generation --- + +func TestGenerateConfigFile_Success(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.conf") + + tmpl := "region = {{.Region}}\nnode = {{.Node}}" + settings := ConfigSettings{Region: "us-east-1", Node: "node1"} + + err := GenerateConfigFile(path, tmpl, settings) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(data), "region = us-east-1") + assert.Contains(t, string(data), "node = node1") + + info, _ := os.Stat(path) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) +} + +func TestGenerateConfigFile_InvalidTemplate(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.conf") + err := GenerateConfigFile(path, "{{.Unclosed", ConfigSettings{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse template") +} + +func TestGenerateConfigFile_InvalidPath(t *testing.T) { + err := GenerateConfigFile("/nonexistent/dir/file.conf", "ok", ConfigSettings{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create config file") +} + +func TestGenerateConfigFile_OverwritesExisting(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "overwrite.conf") + + require.NoError(t, GenerateConfigFile(path, "old={{.Region}}", ConfigSettings{Region: "old"})) + require.NoError(t, GenerateConfigFile(path, "new={{.Region}}", ConfigSettings{Region: "new"})) + + data, _ := os.ReadFile(path) + assert.Contains(t, string(data), "new=new") + assert.NotContains(t, string(data), "old") +} + +func TestGenerateConfigFiles_AllSucceed(t *testing.T) { + dir := t.TempDir() + configs := []ConfigFile{ + {Name: "a", Path: filepath.Join(dir, "a.conf"), Template: "a={{.Region}}"}, + {Name: "b", Path: filepath.Join(dir, "b.conf"), Template: "b={{.Node}}"}, + } + err := GenerateConfigFiles(configs, ConfigSettings{Region: "us-west-2", Node: "n1"}) + require.NoError(t, err) + + for _, cfg := range configs { + assert.True(t, FileExists(cfg.Path)) + } +} + +func TestGenerateConfigFiles_StopsOnFirstError(t *testing.T) { + dir := t.TempDir() + configs := []ConfigFile{ + {Name: "ok", Path: filepath.Join(dir, "ok.conf"), Template: "ok"}, + {Name: "bad", Path: "/nonexistent/dir/bad.conf", Template: "bad"}, + {Name: "never", Path: filepath.Join(dir, "never.conf"), Template: "never"}, + } + err := GenerateConfigFiles(configs, ConfigSettings{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "bad") + assert.False(t, FileExists(filepath.Join(dir, "never.conf"))) +} + +// --- FileExists --- + +func TestFileExists(t *testing.T) { + dir := t.TempDir() + existing := filepath.Join(dir, "exists.txt") + require.NoError(t, os.WriteFile(existing, []byte("hi"), 0644)) + + assert.True(t, FileExists(existing)) + assert.False(t, FileExists(filepath.Join(dir, "nope.txt"))) + assert.True(t, FileExists(dir)) // directory also returns true +} + +// --- UpdateAWSINIFile --- + +func TestUpdateAWSINIFile_CreateNew(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "credentials") + + err := UpdateAWSINIFile(path, "spinifex", map[string]string{ + "aws_access_key_id": "AKIATEST", + "aws_secret_access_key": "secrettest", + }) + require.NoError(t, err) + + data, _ := os.ReadFile(path) + content := string(data) + assert.Contains(t, content, "[spinifex]") + assert.Contains(t, content, "AKIATEST") + assert.Contains(t, content, "secrettest") +} + +func TestUpdateAWSINIFile_UpdateExisting(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "credentials") + + // Create with initial value + require.NoError(t, UpdateAWSINIFile(path, "spinifex", map[string]string{"key": "old"})) + // Update + require.NoError(t, UpdateAWSINIFile(path, "spinifex", map[string]string{"key": "new"})) + + data, _ := os.ReadFile(path) + content := string(data) + assert.Contains(t, content, "new") +} + +func TestUpdateAWSINIFile_AddNewSection(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "credentials") + + require.NoError(t, UpdateAWSINIFile(path, "default", map[string]string{"key": "default-val"})) + require.NoError(t, UpdateAWSINIFile(path, "spinifex", map[string]string{"key": "spinifex-val"})) + + data, _ := os.ReadFile(path) + content := string(data) + assert.Contains(t, content, "[default]") + assert.Contains(t, content, "[spinifex]") + assert.Contains(t, content, "default-val") + assert.Contains(t, content, "spinifex-val") +} + +// --- WriteAWSProfileConfig --- + +// resolveProfileEndpoint resolves sdkID's endpoint through the Go SDK's own +// shared-config loader, the way a client does: the service's entry in the +// profile's services section, else the profile-wide endpoint_url. +func resolveProfileEndpoint(t *testing.T, configPath, profile, sdkID string) string { + t.Helper() + shared, err := awsconfig.LoadSharedConfigProfile(t.Context(), profile, func(o *awsconfig.LoadSharedConfigOptions) { + o.ConfigFiles = []string{configPath} + o.CredentialsFiles = []string{} + }) + require.NoError(t, err) + endpoint, found, err := shared.GetServiceBaseEndpoint(t.Context(), sdkID) + require.NoError(t, err) + if found { + return endpoint + } + return shared.BaseEndpoint +} + +func TestWriteAWSProfileConfig_SendsS3ToPredastoreAndTheRestToTheGateway(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + require.NoError(t, WriteAWSProfileConfig(path, "spinifex", "ap-southeast-2", "/ca.pem", "10.11.12.1")) + + assert.Equal(t, "https://10.11.12.1:8443", resolveProfileEndpoint(t, path, "spinifex", "S3")) + assert.Equal(t, "https://10.11.12.1:9999", resolveProfileEndpoint(t, path, "spinifex", "EC2")) + assert.Equal(t, "https://10.11.12.1:9999", resolveProfileEndpoint(t, path, "spinifex", "STS")) +} + +// A rewrite must not flatten nested settings an operator added, which is how a +// hand-written s3 block used to be lost on every re-init. +func TestWriteAWSProfileConfig_KeepsNestedSettingsAcrossARewrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + require.NoError(t, os.WriteFile(path, []byte(`[profile spinifex] +s3 = + max_concurrent_requests = 20 +services = spinifex + +[services spinifex] +s3 = + addressing_style = path + endpoint_url = https://stale:8443 +`), 0600)) + + require.NoError(t, WriteAWSProfileConfig(path, "spinifex", "ap-southeast-2", "/ca.pem", "localhost")) + require.NoError(t, WriteAWSProfileConfig(path, "spinifex", "ap-southeast-2", "/ca.pem", "localhost")) + + assert.Equal(t, "https://localhost:8443", resolveProfileEndpoint(t, path, "spinifex", "S3")) + shared, err := awsconfig.LoadSharedConfigProfile(t.Context(), "spinifex", func(o *awsconfig.LoadSharedConfigOptions) { + o.ConfigFiles = []string{path} + o.CredentialsFiles = []string{} + }) + require.NoError(t, err) + assert.Equal(t, map[string]string{"addressing_style": "path", "endpoint_url": "https://localhost:8443"}, + shared.Services.ServiceValues["s3"]) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(data), "max_concurrent_requests = 20") + assert.NotContains(t, string(data), "stale") +} + +// Account profiles each get their own services section, so a tenant profile +// resolves S3 the same way as the operator's without sharing its section. +func TestWriteAWSProfileConfig_ProfilesDoNotShareAServicesSection(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + require.NoError(t, WriteAWSProfileConfig(path, "spinifex", "ap-southeast-2", "/ca.pem", "localhost")) + require.NoError(t, WriteAWSProfileConfig(path, "spinifex-acme", "ap-southeast-2", "/ca.pem", "10.0.0.5")) + + assert.Equal(t, "https://localhost:8443", resolveProfileEndpoint(t, path, "spinifex", "S3")) + assert.Equal(t, "https://10.0.0.5:8443", resolveProfileEndpoint(t, path, "spinifex-acme", "S3")) +} + +// --- SetupAWSCredentials --- + +func TestSetupAWSCredentials_CreatesFiles(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + err := SetupAWSCredentials("AKIATEST123", "secret123", "us-east-1", "/path/to/ca.pem", "") + require.NoError(t, err) + + credData, _ := os.ReadFile(filepath.Join(dir, ".aws", "credentials")) + configData, _ := os.ReadFile(filepath.Join(dir, ".aws", "config")) + + assert.Contains(t, string(credData), "AKIATEST123") + assert.Contains(t, string(credData), "secret123") + assert.Contains(t, string(configData), "us-east-1") + assert.Contains(t, string(configData), "https://localhost:9999") + assert.Contains(t, string(configData), "/path/to/ca.pem") +} + +func TestSetupAWSCredentials_PreservesExistingProfiles(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + awsDir := filepath.Join(dir, ".aws") + require.NoError(t, os.MkdirAll(awsDir, 0700)) + require.NoError(t, UpdateAWSINIFile(filepath.Join(awsDir, "credentials"), "default", map[string]string{ + "aws_access_key_id": "EXISTING_KEY", + })) + + err := SetupAWSCredentials("NEWAKEY", "NEWSECRET", "us-west-2", "/ca.pem", "") + require.NoError(t, err) + + data, _ := os.ReadFile(filepath.Join(awsDir, "credentials")) + content := string(data) + assert.Contains(t, content, "EXISTING_KEY") + assert.Contains(t, content, "NEWAKEY") +} + +func TestSetupAWSCredentials_UsesBindIP(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + err := SetupAWSCredentials("AKIATEST123", "secret123", "us-east-1", "/ca.pem", "10.11.12.1") + require.NoError(t, err) + + configPath := filepath.Join(dir, ".aws", "config") + assert.Equal(t, "https://10.11.12.1:9999", resolveProfileEndpoint(t, configPath, "spinifex", "EC2")) + assert.Equal(t, "https://10.11.12.1:8443", resolveProfileEndpoint(t, configPath, "spinifex", "S3")) +} + +func TestSetupAWSCredentials_FallsBackToLocalhostForWildcard(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + err := SetupAWSCredentials("AKIATEST123", "secret123", "us-east-1", "/ca.pem", "0.0.0.0") + require.NoError(t, err) + + configData, _ := os.ReadFile(filepath.Join(dir, ".aws", "config")) + assert.Contains(t, string(configData), "https://localhost:9999") +} + +// On a --force re-init the preserve path passes empty admin credentials: the +// existing ~/.aws/credentials must be left intact while ~/.aws/config is still +// refreshed (endpoint/CA for a changed bind IP). +func TestSetupAWSCredentials_EmptyCredsRefreshesConfigOnly(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + awsDir := filepath.Join(dir, ".aws") + require.NoError(t, os.MkdirAll(awsDir, 0700)) + require.NoError(t, UpdateAWSINIFile(filepath.Join(awsDir, "credentials"), "spinifex", map[string]string{ + "aws_access_key_id": "PRESERVED_KEY", + "aws_secret_access_key": "PRESERVED_SECRET", + })) + + err := SetupAWSCredentials("", "", "ap-southeast-2", "/new/ca.pem", "10.11.12.5") + require.NoError(t, err) + + credData, _ := os.ReadFile(filepath.Join(awsDir, "credentials")) + assert.Contains(t, string(credData), "PRESERVED_KEY", "existing admin credentials must be preserved on re-init") + assert.Contains(t, string(credData), "PRESERVED_SECRET") + + configData, _ := os.ReadFile(filepath.Join(awsDir, "config")) + assert.Contains(t, string(configData), "https://10.11.12.5:9999", "config endpoint must be refreshed") + assert.Contains(t, string(configData), "/new/ca.pem") +} + +// --- Certificate generation --- + +func TestGenerateCACert_CreatesValidCA(t *testing.T) { + t.Parallel() + dir := t.TempDir() + certPath := filepath.Join(dir, "ca.pem") + keyPath := filepath.Join(dir, "ca.key") + + err := GenerateCACert(certPath, keyPath) + require.NoError(t, err) + + // Parse certificate + certPEM, _ := os.ReadFile(certPath) + block, _ := pem.Decode(certPEM) + require.NotNil(t, block) + assert.Equal(t, "CERTIFICATE", block.Type) + + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + assert.True(t, cert.IsCA) + assert.Equal(t, "Spinifex Local CA", cert.Subject.CommonName) + assert.NotZero(t, cert.KeyUsage&x509.KeyUsageCertSign) + + // Parse key + keyPEM, _ := os.ReadFile(keyPath) + keyBlock, _ := pem.Decode(keyPEM) + require.NotNil(t, keyBlock) + assert.Equal(t, "PRIVATE KEY", keyBlock.Type) + + // Verify key file permissions + info, _ := os.Stat(keyPath) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) +} + +// TestGenerateSignedCert uses subtests sharing a single CA to avoid repeated 4096-bit key generation. +func TestGenerateSignedCert(t *testing.T) { + t.Parallel() + // Generate CA once for all subtests (~0.7s instead of ~0.7s x 3) + caDir := t.TempDir() + caCertPath := filepath.Join(caDir, "ca.pem") + caKeyPath := filepath.Join(caDir, "ca.key") + require.NoError(t, GenerateCACert(caCertPath, caKeyPath)) + + t.Run("CreatesValidCert", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + certPath := filepath.Join(dir, "server.pem") + keyPath := filepath.Join(dir, "server.key") + + require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath, nil, nil)) + + certPEM, _ := os.ReadFile(certPath) + block, _ := pem.Decode(certPEM) + require.NotNil(t, block) + + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + assert.False(t, cert.IsCA) + assert.Equal(t, "Spinifex Server", cert.Subject.CommonName) + assert.Contains(t, cert.DNSNames, "localhost") + + hasLoopback := false + for _, ip := range cert.IPAddresses { + if ip.Equal(net.ParseIP("127.0.0.1")) { + hasLoopback = true + } + } + assert.True(t, hasLoopback) + + // Auto-discovered IPs should be present (at least loopback + any interface IPs). + assert.GreaterOrEqual(t, len(cert.IPAddresses), 2) + + // Verify against CA + caCertPEM, _ := os.ReadFile(caCertPath) + caBlock, _ := pem.Decode(caCertPEM) + caCert, _ := x509.ParseCertificate(caBlock.Bytes) + pool := x509.NewCertPool() + pool.AddCert(caCert) + _, err = cert.Verify(x509.VerifyOptions{Roots: pool}) + assert.NoError(t, err) + + info, _ := os.Stat(keyPath) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) + }) + + t.Run("ExtraIPs", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + certPath := filepath.Join(dir, "server.pem") + keyPath := filepath.Join(dir, "server.key") + + require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath, []string{"192.168.1.100"}, nil)) + + certPEM, _ := os.ReadFile(certPath) + block, _ := pem.Decode(certPEM) + cert, _ := x509.ParseCertificate(block.Bytes) + + hasExtraIP := false + for _, ip := range cert.IPAddresses { + if ip.Equal(net.ParseIP("192.168.1.100")) { + hasExtraIP = true + } + } + assert.True(t, hasExtraIP, "cert should contain extra IP 192.168.1.100") + }) + + t.Run("SkipsDuplicateAndSpecialIPs", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + certPath := filepath.Join(dir, "server.pem") + keyPath := filepath.Join(dir, "server.key") + + require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath, []string{"127.0.0.1", "::1", "0.0.0.0", ""}, nil)) + + certPEM, _ := os.ReadFile(certPath) + block, _ := pem.Decode(certPEM) + cert, _ := x509.ParseCertificate(block.Bytes) + + // Should have loopback (127.0.0.1, ::1) + auto-discovered interface IPs. + // Duplicates (127.0.0.1, ::1) and specials (0.0.0.0, "") must not add extras. + // Count unique IPs from a clean run without extras. + baseDir := t.TempDir() + baseCert := filepath.Join(baseDir, "server.pem") + baseKey := filepath.Join(baseDir, "server.key") + require.NoError(t, GenerateSignedCert(baseCert, baseKey, caCertPath, caKeyPath, nil, nil)) + basePEM, _ := os.ReadFile(baseCert) + baseBlock, _ := pem.Decode(basePEM) + baseParsed, _ := x509.ParseCertificate(baseBlock.Bytes) + + assert.Len(t, cert.IPAddresses, len(baseParsed.IPAddresses), + "passing duplicate/special IPs should not add extra entries") + }) + + t.Run("InvalidCACert", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + badCACert := filepath.Join(dir, "bad-ca.pem") + require.NoError(t, os.WriteFile(badCACert, []byte("not-a-cert"), 0600)) + + err := GenerateSignedCert(filepath.Join(dir, "s.pem"), filepath.Join(dir, "s.key"), badCACert, caKeyPath, nil, nil) + assert.Error(t, err) + }) +} + +func TestDiscoverLocalIPs(t *testing.T) { + t.Parallel() + ips := DiscoverLocalIPs() + // Should find at least one non-loopback IP on any machine with a network interface. + assert.NotEmpty(t, ips, "expected at least one non-loopback IP") + + for _, ip := range ips { + parsed := net.ParseIP(ip) + require.NotNil(t, parsed, "returned IP should be parseable: %s", ip) + assert.False(t, parsed.IsLoopback(), "should not include loopback: %s", ip) + assert.False(t, parsed.IsLinkLocalUnicast(), "should not include link-local: %s", ip) + } + + // No duplicates. + seen := make(map[string]struct{}) + for _, ip := range ips { + _, dup := seen[ip] + assert.False(t, dup, "duplicate IP: %s", ip) + seen[ip] = struct{}{} + } +} + +func TestDiscoverHostname(t *testing.T) { + t.Parallel() + hostname := DiscoverHostname() + // We can't assert a specific value, but if non-empty it shouldn't be "localhost". + if hostname != "" { + assert.NotEqual(t, "localhost", hostname) + } +} + +func TestGenerateSignedCert_DedupDNS(t *testing.T) { + t.Parallel() + caDir := t.TempDir() + caCertPath := filepath.Join(caDir, "ca.pem") + caKeyPath := filepath.Join(caDir, "ca.key") + require.NoError(t, GenerateCACert(caCertPath, caKeyPath)) + + dir := t.TempDir() + certPath := filepath.Join(dir, "server.pem") + keyPath := filepath.Join(dir, "server.key") + + // Pass "localhost" as extra DNS — should not duplicate the built-in entry. + // Also pass empty strings and duplicates — should be ignored. + extraDNS := []string{"localhost", "", "example.local", "example.local", ""} + require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath, nil, extraDNS)) + + certPEM, _ := os.ReadFile(certPath) + block, _ := pem.Decode(certPEM) + cert, _ := x509.ParseCertificate(block.Bytes) + + // Count occurrences of "localhost" — must be exactly 1. + localhostCount := 0 + exampleCount := 0 + for _, dns := range cert.DNSNames { + if dns == "localhost" { + localhostCount++ + } + if dns == "example.local" { + exampleCount++ + } + } + assert.Equal(t, 1, localhostCount, "localhost should appear exactly once") + assert.Equal(t, 1, exampleCount, "example.local should appear exactly once") + assert.Contains(t, cert.DNSNames, "example.local") +} + +func TestGenerateSignedCert_NilSlices(t *testing.T) { + t.Parallel() + caDir := t.TempDir() + caCertPath := filepath.Join(caDir, "ca.pem") + caKeyPath := filepath.Join(caDir, "ca.key") + require.NoError(t, GenerateCACert(caCertPath, caKeyPath)) + + dir := t.TempDir() + certPath := filepath.Join(dir, "server.pem") + keyPath := filepath.Join(dir, "server.key") + + // Both slices nil — should still produce a valid cert with defaults. + require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath, nil, nil)) + + certPEM, _ := os.ReadFile(certPath) + block, _ := pem.Decode(certPEM) + cert, _ := x509.ParseCertificate(block.Bytes) + + assert.Contains(t, cert.DNSNames, "localhost") + assert.GreaterOrEqual(t, len(cert.IPAddresses), 2, "should have at least loopback IPs") +} + +func TestGenerateSignedCert_InvalidCA(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Missing CA cert file. + err := GenerateSignedCert( + filepath.Join(dir, "s.pem"), filepath.Join(dir, "s.key"), + filepath.Join(dir, "missing-ca.pem"), filepath.Join(dir, "missing-ca.key"), + nil, nil, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read CA cert") + + // Corrupt CA cert. + require.NoError(t, os.WriteFile(filepath.Join(dir, "bad-ca.pem"), []byte("not-pem"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "bad-ca.key"), []byte("not-pem"), 0600)) + err = GenerateSignedCert( + filepath.Join(dir, "s.pem"), filepath.Join(dir, "s.key"), + filepath.Join(dir, "bad-ca.pem"), filepath.Join(dir, "bad-ca.key"), + nil, nil, + ) + assert.Error(t, err) +} + +func TestGenerateSignedCert_ExtraDNS(t *testing.T) { + t.Parallel() + caDir := t.TempDir() + caCertPath := filepath.Join(caDir, "ca.pem") + caKeyPath := filepath.Join(caDir, "ca.key") + require.NoError(t, GenerateCACert(caCertPath, caKeyPath)) + + dir := t.TempDir() + certPath := filepath.Join(dir, "server.pem") + keyPath := filepath.Join(dir, "server.key") + + extraIPs := []string{"10.0.0.42"} + extraDNS := []string{"spinifex.local", "node1.spinifex.local"} + require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath, extraIPs, extraDNS)) + + certPEM, _ := os.ReadFile(certPath) + block, _ := pem.Decode(certPEM) + cert, _ := x509.ParseCertificate(block.Bytes) + + assert.Contains(t, cert.DNSNames, "localhost") + assert.Contains(t, cert.DNSNames, "spinifex.local") + assert.Contains(t, cert.DNSNames, "node1.spinifex.local") + + hasExtraIP := false + for _, ip := range cert.IPAddresses { + if ip.Equal(net.ParseIP("10.0.0.42")) { + hasExtraIP = true + } + } + assert.True(t, hasExtraIP, "cert should contain extra IP 10.0.0.42") + + info, _ := os.Stat(keyPath) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) +} + +func TestGenerateSignedCert_IncludesHostname(t *testing.T) { + t.Parallel() + caDir := t.TempDir() + caCertPath := filepath.Join(caDir, "ca.pem") + caKeyPath := filepath.Join(caDir, "ca.key") + require.NoError(t, GenerateCACert(caCertPath, caKeyPath)) + + dir := t.TempDir() + certPath := filepath.Join(dir, "server.pem") + keyPath := filepath.Join(dir, "server.key") + require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath, nil, nil)) + + certPEM, _ := os.ReadFile(certPath) + block, _ := pem.Decode(certPEM) + cert, _ := x509.ParseCertificate(block.Bytes) + + assert.Contains(t, cert.DNSNames, "localhost") + hostname := DiscoverHostname() + if hostname != "" { + assert.Contains(t, cert.DNSNames, hostname, + "cert DNS SANs should include the machine hostname") + } +} + +// --- Certificate orchestrator --- + +// TestGenerateCertificatesIfNeeded uses subtests to share the initial generation. +// +//nolint:tparallel // subtests mutate the shared dir in order: SkipsWhenAllExist pins the CA modtime that ForcePreservesCARegeneratesServerCert then regenerates against +func TestGenerateCertificatesIfNeeded(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // First call generates all certs + caCertPath := GenerateCertificatesIfNeeded(dir, false, "", "us-east-1", "spinifex.internal") + assert.Equal(t, filepath.Join(dir, "ca.pem"), caCertPath) + assert.True(t, FileExists(filepath.Join(dir, "ca.pem"))) + assert.True(t, FileExists(filepath.Join(dir, "ca.key"))) + assert.True(t, FileExists(filepath.Join(dir, "server.pem"))) + assert.True(t, FileExists(filepath.Join(dir, "server.key"))) + + t.Run("SkipsWhenAllExist", func(t *testing.T) { + caInfo, _ := os.Stat(filepath.Join(dir, "ca.pem")) + origModTime := caInfo.ModTime() + + GenerateCertificatesIfNeeded(dir, false, "", "us-east-1", "spinifex.internal") + caInfo2, _ := os.Stat(filepath.Join(dir, "ca.pem")) + assert.Equal(t, origModTime, caInfo2.ModTime()) + }) + + // --force must preserve the CA (trust anchor for joined nodes / baked AMIs) + // and only re-sign the server cert, which stays verifiable against that CA. + t.Run("ForcePreservesCARegeneratesServerCert", func(t *testing.T) { + origCA, _ := os.ReadFile(filepath.Join(dir, "ca.pem")) + origCAKey, _ := os.ReadFile(filepath.Join(dir, "ca.key")) + origServer, _ := os.ReadFile(filepath.Join(dir, "server.pem")) + + GenerateCertificatesIfNeeded(dir, true, "10.9.8.7", "us-east-1", "spinifex.internal") + + newCA, _ := os.ReadFile(filepath.Join(dir, "ca.pem")) + newCAKey, _ := os.ReadFile(filepath.Join(dir, "ca.key")) + newServer, _ := os.ReadFile(filepath.Join(dir, "server.pem")) + assert.Equal(t, origCA, newCA, "CA cert must be preserved on --force") + assert.Equal(t, origCAKey, newCAKey, "CA key must be preserved on --force") + assert.NotEqual(t, origServer, newServer, "server cert must be re-signed on --force") + + // The re-signed server cert must verify against the preserved CA. + caBlock, _ := pem.Decode(newCA) + caCert, err := x509.ParseCertificate(caBlock.Bytes) + require.NoError(t, err) + pool := x509.NewCertPool() + pool.AddCert(caCert) + + srvBlock, _ := pem.Decode(newServer) + srvCert, err := x509.ParseCertificate(srvBlock.Bytes) + require.NoError(t, err) + _, err = srvCert.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}) + assert.NoError(t, err, "re-signed server cert must verify against preserved CA") + }) + + // The mgmt-bridge IP must be a SAN even though br-mgmt is not a live + // interface here (interface enumeration cannot discover it), mirroring a + // host where br-mgmt is down when the cert is minted. Without the explicit + // pin the control-plane publish to https:// would fail cert verify. + t.Run("MgmtBridgeIPAlwaysInSAN", func(t *testing.T) { + certDir := t.TempDir() + GenerateCertificatesIfNeeded(certDir, false, "10.0.0.5", "us-east-1", "spinifex.internal") + assert.True(t, certHasIP(t, filepath.Join(certDir, "server.pem"), config.DefaultMgmtBridgeIP), + "server cert must carry the canonical mgmt-bridge IP SAN regardless of br-mgmt state") + }) +} + +func TestGenerateServerCertOnly(t *testing.T) { + t.Parallel() + // Generate CA once, reuse for subtests + caDir := t.TempDir() + require.NoError(t, GenerateCACert(filepath.Join(caDir, "ca.pem"), filepath.Join(caDir, "ca.key"))) + + t.Run("Success", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Copy CA files into test dir + caCert, _ := os.ReadFile(filepath.Join(caDir, "ca.pem")) + caKey, _ := os.ReadFile(filepath.Join(caDir, "ca.key")) + require.NoError(t, os.WriteFile(filepath.Join(dir, "ca.pem"), caCert, 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "ca.key"), caKey, 0600)) + + err := GenerateServerCertOnly(dir, "10.0.0.5", "us-east-1", "spinifex.internal") + require.NoError(t, err) + assert.True(t, FileExists(filepath.Join(dir, "server.pem"))) + assert.True(t, FileExists(filepath.Join(dir, "server.key"))) + + certPEM, _ := os.ReadFile(filepath.Join(dir, "server.pem")) + block, _ := pem.Decode(certPEM) + cert, _ := x509.ParseCertificate(block.Bytes) + + hasBindIP := false + for _, ip := range cert.IPAddresses { + if ip.Equal(net.ParseIP("10.0.0.5")) { + hasBindIP = true + } + } + assert.True(t, hasBindIP) + + // AWS-parity ECR SANs must be present. + assert.Contains(t, cert.DNSNames, "ecr.us-east-1.spinifex.internal") + assert.Contains(t, cert.DNSNames, "*.dkr.ecr.us-east-1.spinifex.internal") + + // Joining nodes must also pin the mgmt-bridge IP regardless of br-mgmt state. + assert.True(t, certHasIP(t, filepath.Join(dir, "server.pem"), config.DefaultMgmtBridgeIP), + "server cert must carry the canonical mgmt-bridge IP SAN") + }) + + t.Run("MissingCA", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + err := GenerateServerCertOnly(dir, "10.0.0.5", "us-east-1", "spinifex.internal") + assert.Error(t, err) + assert.Contains(t, err.Error(), "CA files not found") + }) +} + +func TestAWSGWServiceDNSNames(t *testing.T) { + t.Parallel() + + got := AWSGWServiceDNSNames("us-east-1", "spinifex.internal") + assert.Equal(t, []string{ + "ecr.us-east-1.spinifex.internal", + "*.dkr.ecr.us-east-1.spinifex.internal", + "ec2.us-east-1.spinifex.internal", + "sts.us-east-1.spinifex.internal", + "elasticloadbalancing.us-east-1.spinifex.internal", + "ecs.us-east-1.spinifex.internal", + "eks.us-east-1.spinifex.internal", + "acm.us-east-1.spinifex.internal", + }, got) + + // SANs and DNS records must share one list so they can never drift. + for _, svc := range config.AWSGWServiceNames { + assert.Contains(t, got, svc+".us-east-1.spinifex.internal") + } + + assert.Nil(t, AWSGWServiceDNSNames("", "spinifex.internal")) + assert.Nil(t, AWSGWServiceDNSNames("us-east-1", "")) + assert.Nil(t, AWSGWServiceDNSNames("", "")) +} + +// --- Directory creation --- + +func TestCreateServiceDirectories_CreatesAll(t *testing.T) { + dir := t.TempDir() + CreateServiceDirectories(dir) + + expected := []string{"images", "amis", "volumes", "state", "logs", "nats", "predastore", "viperblock", "spinifex"} + for _, name := range expected { + path := filepath.Join(dir, name) + info, err := os.Stat(path) + assert.NoError(t, err, "directory %s should exist", name) + if err == nil { + assert.True(t, info.IsDir()) + } + } +} + +func TestCreateServiceDirectories_Idempotent(t *testing.T) { + dir := t.TempDir() + CreateServiceDirectories(dir) + // Should not error on second call + CreateServiceDirectories(dir) + + info, err := os.Stat(filepath.Join(dir, "images")) + assert.NoError(t, err) + assert.True(t, info.IsDir()) +} + +// --- Predastore multi-node config --- + +func TestGenerateMultiNodePredastoreConfig_Success(t *testing.T) { + tmpl := `{{range .Nodes}}[[host]] +id = {{.ID}} +addr = "{{.Host}}" +data_dir = "{{$.PredastoreDataDir}}" +{{end}}` + nodes := []formation.PredastoreNodeConfig{ + {ID: 1, Host: "10.0.0.1"}, + {ID: 2, Host: "10.0.0.2"}, + {ID: 3, Host: "10.0.0.3"}, + } + + result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, NorthstarCredentials{}) + require.NoError(t, err) + assert.Contains(t, result, `addr = "10.0.0.1"`) + assert.Contains(t, result, `addr = "10.0.0.3"`) + assert.Contains(t, result, `data_dir = "/var/lib/spinifex/predastore/cluster"`) +} + +// Each machine hosts a gate, a blob node and a meta node, with node IDs unique +// across all three roles so the topology validates. +func TestGenerateMultiNodePredastoreConfig_Topology(t *testing.T) { + tmpl := `{{range .ClusterNodes}}[[host.node]] +id = {{.ID}} +host_id = {{.HostID}} +role = "{{.Role}}" +port = {{.Port}} +{{end}}` + nodes := []formation.PredastoreNodeConfig{ + {ID: 1, Host: "10.0.0.1"}, + {ID: 2, Host: "10.0.0.2"}, + {ID: 3, Host: "10.0.0.3"}, + } + + result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, NorthstarCredentials{}) + require.NoError(t, err) + + assert.Contains(t, result, "id = 1\nhost_id = 1\nrole = \"gate\"\nport = 8443") + assert.Contains(t, result, "id = 4\nhost_id = 1\nrole = \"blob\"\nport = 6660") + assert.Contains(t, result, "id = 6\nhost_id = 3\nrole = \"blob\"\nport = 6660") + assert.Contains(t, result, "id = 7\nhost_id = 1\nrole = \"meta\"\nport = 7660") + assert.Contains(t, result, "id = 9\nhost_id = 3\nrole = \"meta\"\nport = 7660") +} + +func TestPredastoreTopology_UniqueIDsAcrossRoles(t *testing.T) { + topology := PredastoreTopology([]formation.PredastoreNodeConfig{ + {ID: 1, Host: "10.0.0.1"}, + {ID: 2, Host: "10.0.0.2"}, + }) + + require.Len(t, topology, 6) + seen := map[int]bool{} + for _, n := range topology { + assert.False(t, seen[n.ID], "duplicate node id %d", n.ID) + seen[n.ID] = true + assert.Contains(t, []int{1, 2}, n.HostID) + } + assert.Equal(t, "gate", topology[0].Role) + assert.Equal(t, "blob", topology[2].Role) + assert.Equal(t, "meta", topology[4].Role) +} + +// The northstar template fields were declared but never assigned, so every +// multi-node predastore config silently rendered the empty-key path: no zone +// bucket and no credential the resolver could authenticate with. +func TestGenerateMultiNodePredastoreConfig_NorthstarCredentialsReachTemplate(t *testing.T) { + tmpl := `access = "{{.NorthstarAccessKey}}" secret = "{{.NorthstarSecretKey}}" bucket = "{{.NorthstarBucket}}"` + nodes := []formation.PredastoreNodeConfig{ + {ID: 1, Host: "10.0.0.1"}, + {ID: 2, Host: "10.0.0.2"}, + } + + result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, + NorthstarCredentials{AccessKey: "NSAK", SecretKey: "NSSK", Bucket: "northstar"}) + require.NoError(t, err) + assert.Equal(t, `access = "NSAK" secret = "NSSK" bucket = "northstar"`, result) +} + +// A zero credential must leave every northstar field empty, which is what the +// production template's guards key off to omit the stanzas entirely. +func TestGenerateMultiNodePredastoreConfig_NoNorthstarCredentials(t *testing.T) { + tmpl := `access = "{{.NorthstarAccessKey}}" secret = "{{.NorthstarSecretKey}}" bucket = "{{.NorthstarBucket}}"` + nodes := []formation.PredastoreNodeConfig{ + {ID: 1, Host: "10.0.0.1"}, + {ID: 2, Host: "10.0.0.2"}, + } + + result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, + NorthstarCredentials{}) + require.NoError(t, err) + assert.Equal(t, `access = "" secret = "" bucket = ""`, result) +} + +func TestGenerateMultiNodePredastoreConfig_MinimumNodes(t *testing.T) { + tmpl := "{{range .Nodes}}{{.ID}}{{end}}" + + _, err := GenerateMultiNodePredastoreConfig(tmpl, []formation.PredastoreNodeConfig{ + {ID: 1, Host: "10.0.0.1"}, + }, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, NorthstarCredentials{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least 2 nodes") +} + +func TestGenerateMultiNodePredastoreConfig_InvalidTemplate(t *testing.T) { + _, err := GenerateMultiNodePredastoreConfig("{{.Unclosed", []formation.PredastoreNodeConfig{ + {ID: 1, Host: "a"}, {ID: 2, Host: "b"}, {ID: 3, Host: "c"}, + }, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, NorthstarCredentials{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse") +} + +// --- FindNodeIDByIP --- + +func TestFindNodeIDByIP(t *testing.T) { + nodes := []formation.PredastoreNodeConfig{ + {ID: 1, Host: "10.0.0.1"}, + {ID: 2, Host: "10.0.0.2"}, + {ID: 3, Host: "10.0.0.3"}, + } + + assert.Equal(t, 2, FindNodeIDByIP(nodes, "10.0.0.2")) + assert.Equal(t, 0, FindNodeIDByIP(nodes, "10.0.0.99")) + assert.Equal(t, 0, FindNodeIDByIP(nil, "10.0.0.1")) +} + +// --- ParsePredastoreHostIDFromConfig --- + +func TestParsePredastoreHostIDFromConfig(t *testing.T) { + tomlContent := ` +[[host]] +id = 1 +bind_addr = "0.0.0.0" +addr = "10.0.0.1" + +[[host.node]] +id = 1 +role = "gate" +port = 8443 + +[[host]] +id = 2 +bind_addr = "0.0.0.0" +addr = "10.0.0.2" + +[[host]] +id = 3 +bind_addr = "0.0.0.0" +addr = "10.0.0.3" +` + assert.Equal(t, 2, ParsePredastoreHostIDFromConfig(tomlContent, "10.0.0.2")) + assert.Equal(t, 0, ParsePredastoreHostIDFromConfig(tomlContent, "10.0.0.99")) + assert.Equal(t, 0, ParsePredastoreHostIDFromConfig("invalid toml {{{", "10.0.0.1")) + assert.Equal(t, 0, ParsePredastoreHostIDFromConfig("", "10.0.0.1")) +} + +// A host addr never carries a port, so one written with a node's port attached +// names no machine and must not resolve to a host. +func TestParsePredastoreHostIDFromConfig_AddressWithPortDoesNotMatch(t *testing.T) { + tomlContent := ` +[[host]] +id = 7 +addr = "10.0.0.7:6660" +` + assert.Equal(t, 0, ParsePredastoreHostIDFromConfig(tomlContent, "10.0.0.7")) +} + +// --- Integration: Full config generation flow --- + +func TestGenerateConfigFile_SpinifexTomlTemplate(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "spinifex.toml") + + tmpl := `version = "1.0" +epoch = 1 +node = "{{.Node}}" + +[nodes.{{.Node}}] +region = "{{.Region}}" +accesskey = "{{.AccessKey}}" +secretkey = "{{.SecretKey}}" +` + + settings := ConfigSettings{ + Node: "node1", + Region: "us-east-1", + AccessKey: "AKIATEST", + SecretKey: "SECRET", + } + + require.NoError(t, GenerateConfigFile(path, tmpl, settings)) + + data, _ := os.ReadFile(path) + content := string(data) + assert.Contains(t, content, `node = "node1"`) + assert.Contains(t, content, `region = "us-east-1"`) + assert.Contains(t, content, fmt.Sprintf(`accesskey = "%s"`, settings.AccessKey)) +} + +func TestChownRecursive_InvalidUser(t *testing.T) { + // ChownRecursive must report a non-nil error naming the user when the + // user cannot be resolved, rather than silently doing nothing. + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.txt") + os.WriteFile(testFile, []byte("test"), 0644) + + const badUser = "nonexistent-user-that-does-not-exist-12345" + err := ChownRecursive(tmpDir, badUser) + require.Error(t, err) + assert.Contains(t, err.Error(), badUser) + + // File should still exist and be readable + _, readErr := os.ReadFile(testFile) + assert.NoError(t, readErr) +} + +func TestChownRecursive_CurrentUser(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, "sub") + os.MkdirAll(subDir, 0755) + testFile := filepath.Join(subDir, "test.txt") + os.WriteFile(testFile, []byte("test"), 0644) + + // Chown to current user should succeed (no-op effectively) + currentUser := os.Getenv("USER") + if currentUser == "" { + t.Skip("USER env not set") + } + + assert.NoError(t, ChownRecursive(tmpDir, currentUser)) + + // Verify files are still accessible + _, err := os.ReadFile(testFile) + assert.NoError(t, err) +} + +func TestChownRecursive_NonExistentPath(t *testing.T) { + // Should not panic on a path that doesn't exist; OpenRoot fails and the + // fallback Lchown fails too, but both are logged, not returned — the + // user itself resolved fine, so this is not one of the abort cases. + currentUser := os.Getenv("USER") + if currentUser == "" { + t.Skip("USER env not set") + } + assert.NoError(t, ChownRecursive("/tmp/nonexistent-path-12345", currentUser)) +} + +func TestChownServicePaths_MissingUser(t *testing.T) { + // chownServicePaths is the map-walking core extracted from + // SetServiceOwnership so it can be exercised against a small map + // instead of the hardcoded /etc/spinifex and /var/lib/spinifex paths, + // which are not writable/creatable without root in CI. + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(tmpDir, 0755)) + + const badUser = "nonexistent-user-that-does-not-exist-12345" + err := chownServicePaths(map[string]string{tmpDir: badUser}) + require.Error(t, err) + assert.Contains(t, err.Error(), badUser) + assert.Contains(t, err.Error(), tmpDir) +} + +func TestChownServicePaths_SkipsMissingPath(t *testing.T) { + // A path that doesn't exist on disk is never attempted, so it can't + // produce a "user not found" failure even for a bogus user. + err := chownServicePaths(map[string]string{ + "/nonexistent-path-12345": "nonexistent-user-that-does-not-exist-12345", + }) + assert.NoError(t, err) +} + +// --- SetGPUPassthrough --- + +func writeToml(t *testing.T, content string) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "spinifex*.toml") + require.NoError(t, err) + _, err = f.WriteString(content) + require.NoError(t, err) + require.NoError(t, f.Close()) + return f.Name() +} + +func readToml(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + return string(b) +} + +func TestSetGPUPassthrough_NoOp(t *testing.T) { + toml := "[nodes.node1.daemon]\ngpu_passthrough = true\n" + path := writeToml(t, toml) + require.NoError(t, SetGPUPassthrough(path, "node1", true)) + assert.Equal(t, toml, readToml(t, path)) +} + +func TestSetGPUPassthrough_Flip(t *testing.T) { + path := writeToml(t, "[nodes.node1.daemon]\ngpu_passthrough = false\n") + require.NoError(t, SetGPUPassthrough(path, "node1", true)) + assert.Contains(t, readToml(t, path), "gpu_passthrough = true") +} + +func TestSetGPUPassthrough_AddKeyToSection(t *testing.T) { + path := writeToml(t, "[nodes.node1.daemon]\nsome_other = true\n") + require.NoError(t, SetGPUPassthrough(path, "node1", true)) + got := readToml(t, path) + assert.Contains(t, got, "gpu_passthrough = true") + assert.Contains(t, got, "some_other = true") +} + +func TestSetGPUPassthrough_AppendSection(t *testing.T) { + path := writeToml(t, "[nodes.node1.network]\ncidr = \"10.0.0.0/24\"\n") + require.NoError(t, SetGPUPassthrough(path, "node1", true)) + got := readToml(t, path) + assert.Contains(t, got, "[nodes.node1.daemon]") + assert.Contains(t, got, "gpu_passthrough = true") +} + +func TestSetGPUPassthrough_DisableNoOp(t *testing.T) { + toml := "[nodes.node1.daemon]\ngpu_passthrough = false\n" + path := writeToml(t, toml) + require.NoError(t, SetGPUPassthrough(path, "node1", false)) + assert.Equal(t, toml, readToml(t, path)) +} + +func TestSetGPUPassthrough_ReadError(t *testing.T) { + err := SetGPUPassthrough("/nonexistent/path/spinifex.toml", "node1", true) + require.Error(t, err) +} + +func TestSetGPUPassthrough_SectionBoundary(t *testing.T) { + // gpu_passthrough = true exists but in a DIFFERENT node's section; should still write. + path := writeToml(t, "[nodes.node2.daemon]\ngpu_passthrough = true\n[nodes.node1.daemon]\nsome_key = 1\n") + require.NoError(t, SetGPUPassthrough(path, "node1", true)) + got := readToml(t, path) + // node1 section should now have the key + assert.Contains(t, got, "[nodes.node1.daemon]\ngpu_passthrough = true") +} + +// --- SetMIGProfile --- + +func TestSetMIGProfile_SectionDoesNotExist_CreatesWithProfile(t *testing.T) { + path := writeToml(t, "[nodes.node1.network]\ncidr = \"10.0.0.0/24\"\n") + require.NoError(t, SetMIGProfile(path, "node1", "1g.10gb")) + got := readToml(t, path) + assert.Contains(t, got, "[nodes.node1.daemon]") + assert.Contains(t, got, `mig_profile = "1g.10gb"`) +} + +func TestSetMIGProfile_SectionExistsMIGProfileMissing_AddsIt(t *testing.T) { + path := writeToml(t, "[nodes.node1.daemon]\nsome_other = true\n") + require.NoError(t, SetMIGProfile(path, "node1", "1g.10gb")) + got := readToml(t, path) + assert.Contains(t, got, `mig_profile = "1g.10gb"`) + assert.Contains(t, got, "some_other = true") +} + +func TestSetMIGProfile_AlreadyCorrectValue_Idempotent(t *testing.T) { + toml := "[nodes.node1.daemon]\nmig_profile = \"1g.10gb\"\n" + path := writeToml(t, toml) + require.NoError(t, SetMIGProfile(path, "node1", "1g.10gb")) + assert.Equal(t, toml, readToml(t, path)) +} + +func TestSetMIGProfile_DifferentValue_UpdatesIt(t *testing.T) { + path := writeToml(t, "[nodes.node1.daemon]\nmig_profile = \"1g.10gb\"\n") + require.NoError(t, SetMIGProfile(path, "node1", "7g.80gb")) + got := readToml(t, path) + assert.Contains(t, got, `mig_profile = "7g.80gb"`) + assert.NotContains(t, got, `mig_profile = "1g.10gb"`) +} + +func TestSetMIGProfile_FileDoesNotExist_ReturnsError(t *testing.T) { + err := SetMIGProfile(filepath.Join(t.TempDir(), "nonexistent.toml"), "node1", "1g.10gb") + require.Error(t, err) +} diff --git a/spinifex/admin/email.go b/spinifex/operator/admin/email.go similarity index 100% rename from spinifex/admin/email.go rename to spinifex/operator/admin/email.go diff --git a/spinifex/admin/email_test.go b/spinifex/operator/admin/email_test.go similarity index 100% rename from spinifex/admin/email_test.go rename to spinifex/operator/admin/email_test.go diff --git a/spinifex/admin/image_import.go b/spinifex/operator/admin/image_import.go similarity index 100% rename from spinifex/admin/image_import.go rename to spinifex/operator/admin/image_import.go diff --git a/spinifex/admin/image_import_test.go b/spinifex/operator/admin/image_import_test.go similarity index 100% rename from spinifex/admin/image_import_test.go rename to spinifex/operator/admin/image_import_test.go diff --git a/spinifex/admin/images_remove.go b/spinifex/operator/admin/images_remove.go similarity index 100% rename from spinifex/admin/images_remove.go rename to spinifex/operator/admin/images_remove.go diff --git a/spinifex/admin/images_remove_test.go b/spinifex/operator/admin/images_remove_test.go similarity index 100% rename from spinifex/admin/images_remove_test.go rename to spinifex/operator/admin/images_remove_test.go diff --git a/spinifex/admin/main_test.go b/spinifex/operator/admin/main_test.go similarity index 100% rename from spinifex/admin/main_test.go rename to spinifex/operator/admin/main_test.go diff --git a/spinifex/admin/network_detect.go b/spinifex/operator/admin/network_detect.go similarity index 100% rename from spinifex/admin/network_detect.go rename to spinifex/operator/admin/network_detect.go diff --git a/spinifex/admin/network_detect_test.go b/spinifex/operator/admin/network_detect_test.go similarity index 100% rename from spinifex/admin/network_detect_test.go rename to spinifex/operator/admin/network_detect_test.go diff --git a/spinifex/admin/orphan_volumes.go b/spinifex/operator/admin/orphan_volumes.go similarity index 100% rename from spinifex/admin/orphan_volumes.go rename to spinifex/operator/admin/orphan_volumes.go diff --git a/spinifex/admin/orphan_volumes_test.go b/spinifex/operator/admin/orphan_volumes_test.go similarity index 100% rename from spinifex/admin/orphan_volumes_test.go rename to spinifex/operator/admin/orphan_volumes_test.go diff --git a/spinifex/admin/telemetry.go b/spinifex/operator/admin/telemetry.go similarity index 100% rename from spinifex/admin/telemetry.go rename to spinifex/operator/admin/telemetry.go diff --git a/spinifex/admin/telemetry_test.go b/spinifex/operator/admin/telemetry_test.go similarity index 100% rename from spinifex/admin/telemetry_test.go rename to spinifex/operator/admin/telemetry_test.go diff --git a/spinifex/operator/cli/admin.go b/spinifex/operator/cli/admin.go index 8975a2980..daef3f5fd 100644 --- a/spinifex/operator/cli/admin.go +++ b/spinifex/operator/cli/admin.go @@ -29,7 +29,7 @@ import ( "github.com/mulgadc/bluebottle/pkg/masterkey" "github.com/mulgadc/bluebottle/pkg/safecast" - "github.com/mulgadc/spinifex/spinifex/admin" + legacyadmin "github.com/mulgadc/spinifex/spinifex/admin" "github.com/mulgadc/spinifex/spinifex/bootstrap/config" "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata" ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc" @@ -37,6 +37,7 @@ import ( awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" "github.com/mulgadc/spinifex/spinifex/foundation/state/clustersize" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + "github.com/mulgadc/spinifex/spinifex/operator/admin" "github.com/mulgadc/spinifex/spinifex/operator/imagecatalog" operatorprogress "github.com/mulgadc/spinifex/spinifex/operator/progress" "github.com/mulgadc/spinifex/spinifex/providers/ebs" @@ -1121,7 +1122,7 @@ func runimagesPromoteCmd(cmd *cobra.Command, args []string) { bucket := node.Predastore.Bucket // Read current metadata for the confirmation prompt. - meta, err := admin.GetAMIMetadata(store, bucket, imageID) + meta, err := legacyadmin.GetAMIMetadata(store, bucket, imageID) if err != nil { fmt.Fprintln(os.Stderr, "Failed to inspect AMI:", err) os.Exit(1) @@ -1132,7 +1133,7 @@ func runimagesPromoteCmd(cmd *cobra.Command, args []string) { fmt.Printf(" Image ID: %s\n", imageID) fmt.Printf(" Name: %s\n", meta.Name) fmt.Printf(" Current owner: %s\n", meta.ImageOwnerAlias) - fmt.Printf(" New owner: %s\n", admin.SystemOwnerAlias) + fmt.Printf(" New owner: %s\n", legacyadmin.SystemOwnerAlias) if !meta.CreationDate.IsZero() { fmt.Printf(" Created: %s\n", meta.CreationDate.UTC().Format("2006-01-02T15:04:05Z")) } @@ -1149,12 +1150,12 @@ func runimagesPromoteCmd(cmd *cobra.Command, args []string) { } } - if _, err := admin.PromoteSystemImage(store, bucket, admin.PromoteImageOpts{ImageID: imageID}); err != nil { + if _, err := legacyadmin.PromoteSystemImage(store, bucket, legacyadmin.PromoteImageOpts{ImageID: imageID}); err != nil { fmt.Fprintln(os.Stderr, "Promote failed:", err) os.Exit(1) } - fmt.Printf("✅ Promoted %s to system image (owner: %s).\n", imageID, admin.SystemOwnerAlias) + fmt.Printf("✅ Promoted %s to system image (owner: %s).\n", imageID, legacyadmin.SystemOwnerAlias) } func runimagesDescribeCmd(cmd *cobra.Command, args []string) { @@ -1179,7 +1180,7 @@ func runimagesDescribeCmd(cmd *cobra.Command, args []string) { node.Predastore.SecretKey, ) - meta, err := admin.GetAMIMetadata(store, node.Predastore.Bucket, imageID) + meta, err := legacyadmin.GetAMIMetadata(store, node.Predastore.Bucket, imageID) if err != nil { fmt.Fprintln(os.Stderr, "Failed to describe AMI:", err) os.Exit(1) @@ -1632,12 +1633,12 @@ func runAdminInit(cmd *cobra.Command, args []string) { fmt.Printf(" Admin credentials reissued in %s\n", filepath.Join(bootstrapDir, "bootstrap.json")) } else { // Fresh install: mint system + admin credentials and seed the bootstrap files. - accessKey, err = admin.GenerateAWSAccessKey() + accessKey, err = legacyadmin.GenerateAWSAccessKey() if err != nil { fmt.Fprintf(os.Stderr, "Error generating access key: %v\n", err) os.Exit(1) } - secretKey, err = admin.GenerateAWSSecretKey() + secretKey, err = legacyadmin.GenerateAWSSecretKey() if err != nil { fmt.Fprintf(os.Stderr, "Error generating secret key: %v\n", err) os.Exit(1) @@ -1685,7 +1686,7 @@ func runAdminInit(cmd *cobra.Command, args []string) { // which persists them for the life of the node. They are recoverable from // bootstrap.json and ~/.aws/credentials. fmt.Printf("\n🔑 Generated admin credentials (written to ~/.aws/credentials)\n") - fmt.Printf(" Account: %s (%s)\n", admin.DefaultAccountName(), admin.DefaultAccountID()) + fmt.Printf(" Account: %s (%s)\n", legacyadmin.DefaultAccountName(), legacyadmin.DefaultAccountID()) fmt.Printf(" AWS Profile: spinifex\n") } @@ -1727,12 +1728,12 @@ func runAdminInit(cmd *cobra.Command, args []string) { // Generated above the multi-node dispatch because the pair is cluster-wide: // a node's predastore only honours the keys rendered into its own config, so // every node must present this same pair to read the distributed zone bucket. - northstarAccessKey, err := admin.GenerateAWSAccessKey() + northstarAccessKey, err := legacyadmin.GenerateAWSAccessKey() if err != nil { fmt.Fprintf(os.Stderr, "Error generating northstar access key: %v\n", err) os.Exit(1) } - northstarSecretKey, err := admin.GenerateAWSSecretKey() + northstarSecretKey, err := legacyadmin.GenerateAWSSecretKey() if err != nil { fmt.Fprintf(os.Stderr, "Error generating northstar secret key: %v\n", err) os.Exit(1) @@ -1761,7 +1762,7 @@ func runAdminInit(cmd *cobra.Command, args []string) { networkConfig.ExternalMode = externalMode networkConfig.Pools = externalPools networkConfig.PoolDNSServers = dnsServers - networkConfig.BootstrapAccountId = admin.DefaultAccountID() + networkConfig.BootstrapAccountId = legacyadmin.DefaultAccountID() networkConfig.BootstrapVpcId = bootstrapVpcId networkConfig.BootstrapSubnetId = bootstrapSubnetId networkConfig.BootstrapIgwId = bootstrapIgwId @@ -1881,7 +1882,7 @@ func runAdminInit(cmd *cobra.Command, args []string) { Pools: externalPools, OperatorEmail: email, - BootstrapAccountId: admin.DefaultAccountID(), + BootstrapAccountId: legacyadmin.DefaultAccountID(), BootstrapVpcId: bootstrapVpcId, BootstrapSubnetId: bootstrapSubnetId, BootstrapIgwId: bootstrapIgwId, @@ -3345,11 +3346,11 @@ func loadSystemCredentials(configDir string) (accessKey, secretKey string, err e // files (master.key to configDir, bootstrap.json to bootstrapDir). // Used by init flows (single and multi-node). func writeBootstrapFiles(configDir, bootstrapDir string, masterKey []byte, accessKey, secretKey, accountID string) (*writeBootstrapResult, error) { - adminAccessKey, err := admin.GenerateAWSAccessKey() + adminAccessKey, err := legacyadmin.GenerateAWSAccessKey() if err != nil { return nil, fmt.Errorf("generate admin access key: %w", err) } - adminSecretKey, err := admin.GenerateAWSSecretKey() + adminSecretKey, err := legacyadmin.GenerateAWSSecretKey() if err != nil { return nil, fmt.Errorf("generate admin secret key: %w", err) } @@ -3474,8 +3475,8 @@ func writeBootstrapFilesWithAdmin(configDir, bootstrapDir string, masterKey []by EncryptedSecret: encryptedSecret, AccountID: accountID, Admin: &handlers_iam.AdminBootstrapData{ - AccountID: admin.DefaultAccountID(), - AccountName: admin.DefaultAccountName(), + AccountID: legacyadmin.DefaultAccountID(), + AccountName: legacyadmin.DefaultAccountName(), UserName: "admin", AccessKeyID: adminAccessKey, EncryptedSecret: adminEncryptedSecret, diff --git a/spinifex/operator/cli/admin_acm.go b/spinifex/operator/cli/admin_acm.go index 5c9cbb348..a95ded811 100644 --- a/spinifex/operator/cli/admin_acm.go +++ b/spinifex/operator/cli/admin_acm.go @@ -9,8 +9,9 @@ import ( "strings" "time" - "github.com/mulgadc/spinifex/spinifex/admin" + legacyadmin "github.com/mulgadc/spinifex/spinifex/admin" acmdomain "github.com/mulgadc/spinifex/spinifex/domains/acm" + "github.com/mulgadc/spinifex/spinifex/operator/admin" "github.com/spf13/cobra" ) @@ -72,7 +73,7 @@ func runCertForceRenew(cmd *cobra.Command, _ []string) { certArn, _ := cmd.Flags().GetString("arn") accountID, _ := cmd.Flags().GetString("account") if accountID == "" { - accountID = admin.DefaultAccountID() + accountID = legacyadmin.DefaultAccountID() } _, nc, err := loadConfigAndConnect() diff --git a/spinifex/operator/cli/admin_gpu.go b/spinifex/operator/cli/admin_gpu.go index a2e752287..2612af9ee 100644 --- a/spinifex/operator/cli/admin_gpu.go +++ b/spinifex/operator/cli/admin_gpu.go @@ -11,8 +11,8 @@ import ( "time" clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1" - "github.com/mulgadc/spinifex/spinifex/admin" "github.com/mulgadc/spinifex/spinifex/bootstrap/config" + "github.com/mulgadc/spinifex/spinifex/operator/admin" "github.com/mulgadc/spinifex/spinifex/runtime/compute/gpu" "github.com/spf13/cobra" "github.com/spf13/viper" diff --git a/spinifex/operator/cli/admin_nat_test.go b/spinifex/operator/cli/admin_nat_test.go index 8cf540000..dc55b14b7 100644 --- a/spinifex/operator/cli/admin_nat_test.go +++ b/spinifex/operator/cli/admin_nat_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/mulgadc/spinifex/spinifex/admin" + "github.com/mulgadc/spinifex/spinifex/operator/admin" "github.com/mulgadc/spinifex/spinifex/runtime/formation" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/spinifex/operator/cli/admin_ochre.go b/spinifex/operator/cli/admin_ochre.go index 70d47aa3e..b14fe6786 100644 --- a/spinifex/operator/cli/admin_ochre.go +++ b/spinifex/operator/cli/admin_ochre.go @@ -18,12 +18,12 @@ import ( "github.com/aws/aws-sdk-go/service/s3" "github.com/mulgadc/bluebottle/pkg/masterkey" "github.com/mulgadc/bluebottle/pkg/safecast" - "github.com/mulgadc/spinifex/spinifex/admin" "github.com/mulgadc/spinifex/spinifex/bootstrap/config" "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata" "github.com/mulgadc/spinifex/spinifex/domains/ochre/hfhub" awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" gateway_bedrock "github.com/mulgadc/spinifex/spinifex/gateway/bedrock" + "github.com/mulgadc/spinifex/spinifex/operator/admin" "github.com/mulgadc/spinifex/spinifex/providers/ebs" "github.com/mulgadc/spinifex/spinifex/providers/objectstore" "github.com/nats-io/nats.go" diff --git a/spinifex/operator/cli/admin_test.go b/spinifex/operator/cli/admin_test.go index 16e57beef..e97b90a24 100644 --- a/spinifex/operator/cli/admin_test.go +++ b/spinifex/operator/cli/admin_test.go @@ -15,11 +15,11 @@ import ( "github.com/mulgadc/northstar/pkg/backend" nsconfig "github.com/mulgadc/northstar/pkg/config" - "github.com/mulgadc/spinifex/spinifex/admin" "github.com/mulgadc/spinifex/spinifex/bootstrap/config" "github.com/mulgadc/spinifex/spinifex/domains/dns" "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + "github.com/mulgadc/spinifex/spinifex/operator/admin" "github.com/mulgadc/spinifex/spinifex/operator/imagecatalog" "github.com/mulgadc/spinifex/spinifex/runtime/formation" "github.com/mulgadc/spinifex/spinifex/runtime/host/dns" diff --git a/spinifex/operator/cli/northstar_template_test.go b/spinifex/operator/cli/northstar_template_test.go index 78fade1ea..18c3383c1 100644 --- a/spinifex/operator/cli/northstar_template_test.go +++ b/spinifex/operator/cli/northstar_template_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - "github.com/mulgadc/spinifex/spinifex/admin" + "github.com/mulgadc/spinifex/spinifex/operator/admin" toml "github.com/pelletier/go-toml/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/spinifex/operator/cli/predastore_planes_test.go b/spinifex/operator/cli/predastore_planes_test.go index 2a93009ea..8402bcca4 100644 --- a/spinifex/operator/cli/predastore_planes_test.go +++ b/spinifex/operator/cli/predastore_planes_test.go @@ -6,7 +6,7 @@ import ( "testing" pds "github.com/mulgadc/predastore" - "github.com/mulgadc/spinifex/spinifex/admin" + "github.com/mulgadc/spinifex/spinifex/operator/admin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/spinifex/operator/installer/ui/ui.go b/spinifex/operator/installer/ui/ui.go index 004fa1e29..d199982e0 100644 --- a/spinifex/operator/installer/ui/ui.go +++ b/spinifex/operator/installer/ui/ui.go @@ -14,7 +14,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/mulgadc/spinifex/spinifex/admin" + "github.com/mulgadc/spinifex/spinifex/operator/admin" "github.com/mulgadc/spinifex/spinifex/operator/installer/branding" "github.com/mulgadc/spinifex/spinifex/operator/installer/install" "github.com/mulgadc/spinifex/spinifex/operator/installer/netprobe" diff --git a/spinifex/runtime/roles/nats/nats_test.go b/spinifex/runtime/roles/nats/nats_test.go index 1be99002a..5f63b0e96 100644 --- a/spinifex/runtime/roles/nats/nats_test.go +++ b/spinifex/runtime/roles/nats/nats_test.go @@ -9,7 +9,7 @@ import ( "text/template" "time" - "github.com/mulgadc/spinifex/spinifex/admin" + "github.com/mulgadc/spinifex/spinifex/operator/admin" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert"