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.
This commit is contained in:
joshsiv-mulga
2026-10-09 14:51:14 +10:00
parent abf734ef18
commit 6b01f39e78
30 changed files with 1349 additions and 1278 deletions
-2
View File
@@ -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"},
}
+49
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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
+53 -3
View File
@@ -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")
}
@@ -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)
File diff suppressed because it is too large Load Diff
+18 -17
View File
@@ -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,
+3 -2
View File
@@ -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()
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
@@ -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"
@@ -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"
)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"