maidn-cli/internal/bootstrap/fresh_test.go

262 lines
12 KiB
Go

package bootstrap
import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
)
func freshPlanConfig(t *testing.T) config.Config {
t.Helper()
workspace := t.TempDir()
return config.Config{
WorkspaceDir: workspace,
Git: config.GitConfig{Provider: "forgejo", BaseURL: "https://git.example.test", Username: "bot", Token: "token", CloneParent: filepath.Join(workspace, "checkouts")},
Flux: config.FluxConfig{RepoName: "cluster", Branch: "main", ClusterPath: "./clusters/cluster", ClusterDomain: "cluster.example.test", ManifestsRepo: "manifests", TektonCatalogRepo: "catalog"},
Talos: config.TalosConfig{RepoDirName: "talos", GeneratedDir: "generated"},
Delivery: config.DeliveryConfig{AppName: "app", AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/new-org/app", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.cluster.example.test", WebhookPath: "/"},
Templates: config.TemplateConfig{
TalosRepoURL: "https://git.example.test/templates/talos.git", TalosRepoRef: "main",
CICDRepoURL: "https://git.example.test/templates/cicd.git", CICDRepoRef: "main",
ManifestsRepoURL: "https://git.example.test/templates/manifests.git", ManifestsRepoRef: "main",
TektonCatalogRepoURL: "https://git.example.test/templates/catalog.git", TektonCatalogRepoRef: "main",
},
}
}
func TestPlanFreshOrganizationOrdersOnlyFreshPhases(t *testing.T) {
cfg := freshPlanConfig(t)
resolved, plan, err := PlanFreshOrganization(cfg, FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true, EnableDelivery: true})
if err != nil {
t.Fatal(err)
}
if resolved.Git.Owner != "new-org" {
t.Fatal("organization was not bound to the fresh configuration")
}
want := []string{
"validate isolated workspace and configuration",
"lock template revisions in the isolated workspace",
"ensure the Forgejo organization",
"ensure baseline Flux and manifests repositories",
"initialize the user-managed Tekton catalog repository",
"reconcile the CI/CD cluster",
}
if !reflect.DeepEqual(plan.Phases, want) {
t.Fatalf("plan phases = %#v, want %#v", plan.Phases, want)
}
}
func TestPlanFreshOrganizationUsesSelectedLifecycleMode(t *testing.T) {
for _, test := range []struct {
name string
options FreshOrganizationOptions
phase string
}{
{"default", FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true}, "reconcile the CI/CD cluster"},
{"rebuild", FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true, Mode: Rebuild, ConfirmRebuild: true}, "rebuild the CI/CD cluster"},
} {
t.Run(test.name, func(t *testing.T) {
_, plan, err := PlanFreshOrganization(freshPlanConfig(t), test.options)
if err != nil {
t.Fatal(err)
}
if got := plan.Phases[len(plan.Phases)-1]; got != test.phase {
t.Fatalf("lifecycle phase = %q, want %q", got, test.phase)
}
})
}
}
func TestPlanFreshOrganizationRejectsUnconfirmedRebuild(t *testing.T) {
_, _, err := PlanFreshOrganization(freshPlanConfig(t), FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true, Mode: Rebuild})
if err == nil || !strings.Contains(err.Error(), "--mode=rebuild --yes") {
t.Fatalf("PlanFreshOrganization() error = %v", err)
}
}
func TestRunFreshOrganizationOrdersSourceControlBeforeLifecycle(t *testing.T) {
originalLock, originalManager, originalLifecycle := ensureFreshTemplateRevisions, newFreshRepoManager, runFreshLifecycle
t.Cleanup(func() {
ensureFreshTemplateRevisions, newFreshRepoManager, runFreshLifecycle = originalLock, originalManager, originalLifecycle
})
var phases []string
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method + " " + request.URL.Path {
case http.MethodGet + " /api/v1/orgs/new-org":
phases = append(phases, "organization lookup")
writer.WriteHeader(http.StatusNotFound)
case http.MethodPost + " /api/v1/orgs":
if !reflect.DeepEqual(phases, []string{"template lock", "organization lookup"}) {
t.Fatalf("organization creation phase order = %#v", phases)
}
phases = append(phases, "organization create")
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected Forgejo request %s %s", request.Method, request.URL.Path)
}
}))
defer server.Close()
ensureFreshTemplateRevisions = func(config.Config) error {
phases = append(phases, "template lock")
return nil
}
newFreshRepoManager = func(_ string, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch string) *forgejo.RepoManager {
manager := forgejo.NewRepoManager(server.URL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch)
manager.HTTPClient = server.Client()
return manager
}
runFreshLifecycle = func(cfg config.Config, options FreshOrganizationOptions) error {
if !options.EnableDelivery || cfg.Git.Owner != "new-org" || !reflect.DeepEqual(phases, []string{"template lock", "organization lookup", "organization create"}) {
t.Fatal("lifecycle ran before the locked Forgejo source-control preflight")
}
phases = append(phases, "lifecycle")
return nil
}
if _, err := RunFreshOrganization(freshPlanConfig(t), FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true, EnableDelivery: true}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(phases, []string{"template lock", "organization lookup", "organization create", "lifecycle"}) {
t.Fatalf("fresh bootstrap phases = %#v", phases)
}
}
func TestRunFreshOrganizationStopsBeforeLifecycleOnPreflightFailure(t *testing.T) {
originalLock, originalLifecycle := ensureFreshTemplateRevisions, runFreshLifecycle
t.Cleanup(func() {
ensureFreshTemplateRevisions, runFreshLifecycle = originalLock, originalLifecycle
})
ensureFreshTemplateRevisions = func(config.Config) error { return errors.New("unavailable") }
runFreshLifecycle = func(config.Config, FreshOrganizationOptions) error {
t.Fatal("lifecycle ran after template lock failure")
return nil
}
_, err := RunFreshOrganization(freshPlanConfig(t), FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true})
if err == nil || !strings.Contains(err.Error(), "lock template revisions") {
t.Fatalf("RunFreshOrganization() error = %v", err)
}
}
func TestRunFreshOrganizationStopsBeforeLifecycleOnForgejoFailure(t *testing.T) {
originalLock, originalManager, originalLifecycle := ensureFreshTemplateRevisions, newFreshRepoManager, runFreshLifecycle
t.Cleanup(func() {
ensureFreshTemplateRevisions, newFreshRepoManager, runFreshLifecycle = originalLock, originalManager, originalLifecycle
})
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
ensureFreshTemplateRevisions = func(config.Config) error { return nil }
newFreshRepoManager = func(_ string, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch string) *forgejo.RepoManager {
manager := forgejo.NewRepoManager(server.URL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch)
manager.HTTPClient = server.Client()
return manager
}
runFreshLifecycle = func(config.Config, FreshOrganizationOptions) error {
t.Fatal("lifecycle ran after Forgejo preflight failure")
return nil
}
_, err := RunFreshOrganization(freshPlanConfig(t), FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true})
if err == nil || !strings.Contains(err.Error(), "ensure Forgejo organization") {
t.Fatalf("RunFreshOrganization() error = %v", err)
}
}
func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) {
originalPreflight := preflight
t.Cleanup(func() { preflight = originalPreflight })
cfg := runnerTestConfig(t.TempDir(), "")
for _, test := range []struct {
options FreshOrganizationOptions
mode Mode
}{
{FreshOrganizationOptions{}, Reconcile},
{FreshOrganizationOptions{Mode: Rebuild, ConfirmRebuild: true, EnableDelivery: true}, Rebuild},
} {
runner := freshLifecycleRunner(cfg, test.options)
if runner.Mode != test.mode || runner.ConfirmRebuild != test.options.ConfirmRebuild || runner.EnableDelivery != test.options.EnableDelivery || runner.SkipDeliveryScaffolding == test.options.EnableDelivery || !runner.AutoMergeBootstrapMigration {
t.Fatalf("fresh lifecycle runner = %#v", runner)
}
preflight = func(got config.Config) error {
if got.Delivery.Configured() != test.options.EnableDelivery {
t.Fatal("fresh lifecycle did not gate delivery scaffolding with --enable-delivery")
}
return errors.New("stop")
}
if err := runner.Run(); err == nil || !strings.Contains(err.Error(), "preflight: stop") {
t.Fatalf("fresh lifecycle run = %v", err)
}
}
}
func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) {
cfg := freshPlanConfig(t)
prepareResumableFreshWorkspace(t, &cfg)
if err := os.WriteFile(filepath.Join(cfg.WorkspaceDir, ".age", "leftover"), []byte("state"), 0600); err != nil {
t.Fatal(err)
}
if _, _, err := PlanFreshOrganization(cfg, FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true}); err == nil {
t.Fatal("ambiguous workspace state was accepted")
}
}
func TestPlanFreshOrganizationResumesKnownWorkspaceState(t *testing.T) {
cfg := freshPlanConfig(t)
prepareResumableFreshWorkspace(t, &cfg)
if _, _, err := PlanFreshOrganization(cfg, FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true}); err != nil {
t.Fatalf("resumable workspace state was rejected: %v", err)
}
}
func prepareResumableFreshWorkspace(t *testing.T, cfg *config.Config) {
t.Helper()
cfg.SOPS = config.SOPSConfig{
AgeKeyPath: filepath.Join(cfg.WorkspaceDir, ".age", "key.txt"),
BootstrapSecretsPath: filepath.Join(cfg.WorkspaceDir, "bootstrap-secrets.sops.yaml"),
OperationalSecretsPath: filepath.Join(cfg.WorkspaceDir, "operational-secrets.sops.yaml"),
RecoveryIdentityPath: filepath.Join(cfg.WorkspaceDir, ".age", "recovery-key.txt"),
RecoveryBundlePath: filepath.Join(cfg.WorkspaceDir, ".recovery", "openbao-recovery.age"),
}
for _, directory := range []string{
filepath.Join(cfg.WorkspaceDir, "maidn-cicd-cluster-template"),
filepath.Join(cfg.WorkspaceDir, "cicd-deployment-manifests-template"),
cfg.Git.CloneParent,
filepath.Join(cfg.WorkspaceDir, ".age"),
filepath.Join(cfg.WorkspaceDir, ".recovery"),
} {
if err := os.MkdirAll(directory, 0700); err != nil {
t.Fatal(err)
}
}
if err := writeTemplateRevisionLock(filepath.Join(cfg.WorkspaceDir, "maidn-template-revisions.yaml"), templateRevisionLock{
Version: 1,
CICD: templateRevision{Repository: cfg.Templates.CICDRepoURL, Ref: cfg.Templates.CICDRepoRef, Commit: strings.Repeat("a", 40)},
Manifests: templateRevision{Repository: cfg.Templates.ManifestsRepoURL, Ref: cfg.Templates.ManifestsRepoRef, Commit: strings.Repeat("b", 40)},
Talos: templateRevision{Repository: cfg.Templates.TalosRepoURL, Ref: cfg.Templates.TalosRepoRef, Commit: strings.Repeat("c", 40)},
}); err != nil {
t.Fatal(err)
}
for _, path := range []string{
filepath.Join(cfg.WorkspaceDir, "maidn-bootstrap.resolved.yaml"),
cfg.SOPS.AgeKeyPath,
cfg.SOPS.BootstrapSecretsPath,
cfg.SOPS.OperationalSecretsPath,
cfg.SOPS.RecoveryIdentityPath,
cfg.SOPS.RecoveryBundlePath,
} {
if err := os.WriteFile(path, nil, 0600); err != nil {
t.Fatal(err)
}
}
}