Compare commits
No commits in common. "769028c5874739a87bf0edbd3ce046c6bee33e6f" and "c80b860fcb3838d79ad3e50d4d7961a88ea969c0" have entirely different histories.
769028c587
...
c80b860fcb
|
|
@ -13,8 +13,7 @@ var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string
|
||||||
var freshCreateOrganization, freshEnableDelivery, freshYes bool
|
var freshCreateOrganization, freshEnableDelivery, freshYes bool
|
||||||
var freshMode string
|
var freshMode string
|
||||||
|
|
||||||
var loadFreshConfig = config.Load
|
var loadFreshConfig = config.LoadRaw
|
||||||
var loadAppOnboardConfig = config.LoadRaw
|
|
||||||
var runFreshOrganization = bootstrap.RunFreshOrganization
|
var runFreshOrganization = bootstrap.RunFreshOrganization
|
||||||
var resolveAppOnboarding = config.ResolveAppOnboarding
|
var resolveAppOnboarding = config.ResolveAppOnboarding
|
||||||
var ensureOnboardCheckoutClean = forgejo.EnsureCleanCheckout
|
var ensureOnboardCheckoutClean = forgejo.EnsureCleanCheckout
|
||||||
|
|
@ -74,7 +73,7 @@ func runBootstrapInit(cmd *cobra.Command, _ []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAppOnboard(_ *cobra.Command, _ []string) error {
|
func runAppOnboard(_ *cobra.Command, _ []string) error {
|
||||||
cfg, err := loadAppOnboardConfig(onboardConfigPath)
|
cfg, err := loadFreshConfig(onboardConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,76 +2,21 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
|
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBootstrapInitAppliesFluxDefaultsBeforeFreshValidation(t *testing.T) {
|
|
||||||
originalRun := runFreshOrganization
|
|
||||||
originalConfigPath, originalOrganization := freshConfigPath, freshOrganization
|
|
||||||
originalCreate, originalDelivery, originalMode, originalYes := freshCreateOrganization, freshEnableDelivery, freshMode, freshYes
|
|
||||||
t.Cleanup(func() {
|
|
||||||
runFreshOrganization = originalRun
|
|
||||||
freshConfigPath, freshOrganization = originalConfigPath, originalOrganization
|
|
||||||
freshCreateOrganization, freshEnableDelivery, freshMode, freshYes = originalCreate, originalDelivery, originalMode, originalYes
|
|
||||||
})
|
|
||||||
|
|
||||||
workspace := t.TempDir()
|
|
||||||
cfg := config.Config{
|
|
||||||
ClusterID: "test-cluster",
|
|
||||||
WorkspaceDir: workspace,
|
|
||||||
Git: config.GitConfig{Provider: "forgejo", BaseURL: "https://git.example.test", Username: "bot", Token: "test-token", Owner: "new-org", CloneParent: filepath.Join(workspace, "checkouts")},
|
|
||||||
Flux: config.FluxConfig{RepoName: "cluster", ClusterDomain: "example.test"},
|
|
||||||
Talos: config.TalosConfig{
|
|
||||||
Proxmox: config.TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "test-secret"},
|
|
||||||
Cluster: config.TalosClusterConfig{Name: "test-cluster", Domain: "example.test"},
|
|
||||||
Image: config.TalosImageConfig{TalosVersion: "v1.13.6", SchematicID: "abcdefghijkl"},
|
|
||||||
Nodes: []config.TalosNode{{Name: "cp-01", VMID: 100, Role: "controlplane", Networks: []config.TalosNetwork{{IP: "192.168.45.3", CIDR: "192.168.45.0/28", Gateway: "192.168.45.1", VLANID: 45}, {IP: "192.168.45.18", CIDR: "192.168.45.16/28", VLANID: 451}}}},
|
|
||||||
},
|
|
||||||
Cilium: config.CiliumConfig{LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"},
|
|
||||||
DemocraticCSI: config.DemocraticCSIConfig{TrueNASAPIKey: "test-key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test"},
|
|
||||||
}
|
|
||||||
data, err := yaml.Marshal(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
||||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
runFreshOrganization = func(got config.Config, options bootstrap.FreshOrganizationOptions) (bootstrap.FreshOrganizationPlan, error) {
|
|
||||||
if got.Flux.Branch != "main" || got.Flux.ClusterPath != "./clusters/maidn-cd-0" || got.Flux.ManifestsRepo != "cicd-deployment-manifests" || got.Flux.TektonCatalogRepo != "tekton-pipelines" {
|
|
||||||
t.Fatalf("fresh init Flux defaults = %#v", got.Flux)
|
|
||||||
}
|
|
||||||
_, plan, err := bootstrap.PlanFreshOrganization(got, options)
|
|
||||||
return plan, err
|
|
||||||
}
|
|
||||||
freshConfigPath, freshOrganization = path, "new-org"
|
|
||||||
freshCreateOrganization, freshEnableDelivery, freshMode, freshYes = true, false, string(bootstrap.Reconcile), false
|
|
||||||
command := &cobra.Command{}
|
|
||||||
command.SetOut(io.Discard)
|
|
||||||
if err := runBootstrapInit(command, nil); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) {
|
func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) {
|
||||||
originalConfig, originalResolve, originalClean := loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
|
originalConfig, originalResolve, originalClean := loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
|
||||||
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
loadAppOnboardConfig = originalConfig
|
loadFreshConfig = originalConfig
|
||||||
resolveAppOnboarding = originalResolve
|
resolveAppOnboarding = originalResolve
|
||||||
ensureOnboardCheckoutClean = originalClean
|
ensureOnboardCheckoutClean = originalClean
|
||||||
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
||||||
})
|
})
|
||||||
loadAppOnboardConfig = func(string) (config.Config, error) { return config.Config{}, nil }
|
loadFreshConfig = func(string) (config.Config, error) { return config.Config{}, nil }
|
||||||
resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") }
|
resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") }
|
||||||
ensureOnboardCheckoutClean = func(string) error {
|
ensureOnboardCheckoutClean = func(string) error {
|
||||||
t.Fatal("onboarding inspected checkout before validating config")
|
t.Fatal("onboarding inspected checkout before validating config")
|
||||||
|
|
@ -84,16 +29,16 @@ func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppOnboardScaffoldsOnlyTheValidatedCheckout(t *testing.T) {
|
func TestAppOnboardScaffoldsOnlyTheValidatedCheckout(t *testing.T) {
|
||||||
originalConfig, originalResolve, originalClean := loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
|
originalConfig, originalResolve, originalClean := loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
|
||||||
originalOrigin, originalBranch, originalGenerate := onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery
|
originalOrigin, originalBranch, originalGenerate := onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery
|
||||||
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean = originalConfig, originalResolve, originalClean
|
loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean = originalConfig, originalResolve, originalClean
|
||||||
onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery = originalOrigin, originalBranch, originalGenerate
|
onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery = originalOrigin, originalBranch, originalGenerate
|
||||||
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
||||||
})
|
})
|
||||||
cfg := config.Config{Delivery: config.DeliveryConfig{AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main"}}
|
cfg := config.Config{Delivery: config.DeliveryConfig{AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main"}}
|
||||||
loadAppOnboardConfig = func(string) (config.Config, error) { return cfg, nil }
|
loadFreshConfig = func(string) (config.Config, error) { return cfg, nil }
|
||||||
resolveAppOnboarding = func(config.Config) (config.Config, error) { return cfg, nil }
|
resolveAppOnboarding = func(config.Config) (config.Config, error) { return cfg, nil }
|
||||||
ensureOnboardCheckoutClean = func(path string) error {
|
ensureOnboardCheckoutClean = func(path string) error {
|
||||||
if path != "app-checkout" {
|
if path != "app-checkout" {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ type Runner struct {
|
||||||
RegisterWebhook bool
|
RegisterWebhook bool
|
||||||
EnableDelivery bool
|
EnableDelivery bool
|
||||||
SkipDeliveryScaffolding bool
|
SkipDeliveryScaffolding bool
|
||||||
AutoMergeBootstrapMigration bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type operationalSecrets struct {
|
type operationalSecrets struct {
|
||||||
|
|
@ -271,8 +270,8 @@ func (r Runner) Run() error {
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := r.reconcileBootstrapMigration(manager); err != nil {
|
if manager.MigrationPending {
|
||||||
return err
|
return errors.New("existing repository migration PR created; merge and rerun bootstrap before infrastructure changes")
|
||||||
}
|
}
|
||||||
if err := r.reconcileCloudflareTunnel(); err != nil {
|
if err := r.reconcileCloudflareTunnel(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -332,19 +331,6 @@ func (r Runner) Run() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) reconcileBootstrapMigration(manager *forgejo.RepoManager) error {
|
|
||||||
if !manager.MigrationPending {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !r.AutoMergeBootstrapMigration {
|
|
||||||
return errors.New("existing repository migration PR created; merge and rerun bootstrap before infrastructure changes")
|
|
||||||
}
|
|
||||||
if err := manager.MergePullRequest(r.Config.Flux.RepoName, manager.MigrationBranch); err != nil {
|
|
||||||
return fmt.Errorf("merge bootstrap migration PR: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveLifecycleMode(mode Mode, confirmRebuild bool) (Mode, error) {
|
func resolveLifecycleMode(mode Mode, confirmRebuild bool) (Mode, error) {
|
||||||
if mode == "" {
|
if mode == "" {
|
||||||
mode = Reconcile
|
mode = Reconcile
|
||||||
|
|
@ -1104,9 +1090,6 @@ func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery boo
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyClusterTemplate(source, destination string) error {
|
func copyClusterTemplate(source, destination string) error {
|
||||||
if err := os.MkdirAll(destination, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
entries, err := os.ReadDir(destination)
|
entries, err := os.ReadDir(destination)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -749,55 +749,6 @@ func TestEnsureClusterKustomizationsUsesTemplateExternalSecretsResource(t *testi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCopyClusterTemplateCopiesCurrentClusterContract(t *testing.T) {
|
|
||||||
templateDir := t.TempDir()
|
|
||||||
clusterTemplate := filepath.Join(templateDir, "clusters", "template")
|
|
||||||
resources := []string{
|
|
||||||
"snapshot-crds-kustomization.yaml", "democratic-csi-kustomization.yaml", "cert-manager-kustomization.yaml", "cluster-issuers-kustomization.yaml", "gateway-api-kustomization.yaml", "cilium-kustomization.yaml", "cilium-config-kustomization.yaml", "openbao-kustomization.yaml", "external-secrets-kustomization.yaml", "external-secrets-config-kustomization.yaml", "cnpg-kustomization.yaml", "gateway-kustomization.yaml", "external-dns-kustomization.yaml", "cloudflare-tunnel-kustomization.yaml", "monitoring-kustomization.yaml", "tekton-kustomization.yaml", "tekton-triggers-kustomization.yaml",
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(clusterTemplate, 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var content strings.Builder
|
|
||||||
content.WriteString("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n")
|
|
||||||
for _, resource := range resources {
|
|
||||||
content.WriteString(" - " + resource + "\n")
|
|
||||||
if err := os.WriteFile(filepath.Join(clusterTemplate, resource), []byte("apiVersion: v1\nkind: ConfigMap\n"), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(clusterTemplate, "kustomization.yaml"), []byte(content.String()), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
clusterDir := filepath.Join(t.TempDir(), "clusters", "maidn-cd-0")
|
|
||||||
if err := copyClusterTemplate(clusterTemplate, clusterDir); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(clusterDir, "cicd-manifests-repo.yaml"), []byte("apiVersion: v1\nkind: ConfigMap\n"), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := ensureClusterKustomizations(clusterDir, true); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
first, err := os.ReadFile(filepath.Join(clusterDir, "kustomization.yaml"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := ensureClusterKustomizations(clusterDir, true); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
second, err := os.ReadFile(filepath.Join(clusterDir, "kustomization.yaml"))
|
|
||||||
if err != nil || string(first) != string(second) {
|
|
||||||
t.Fatalf("cluster Kustomization was not generated idempotently: %q, %v", second, err)
|
|
||||||
}
|
|
||||||
for _, resource := range append(resources, "cicd-manifests-repo.yaml") {
|
|
||||||
if !strings.Contains(string(second), " - "+resource+"\n") {
|
|
||||||
t.Fatalf("cluster Kustomization omitted template resource %q: %q", resource, second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhookTargetTimeoutExceedsExternalSecretRefreshInterval(t *testing.T) {
|
func TestWebhookTargetTimeoutExceedsExternalSecretRefreshInterval(t *testing.T) {
|
||||||
if webhookTargetTimeout <= time.Hour {
|
if webhookTargetTimeout <= time.Hour {
|
||||||
t.Fatal("webhook target timeout must exceed the one-hour ExternalSecret refresh interval")
|
t.Fatal("webhook target timeout must exceed the one-hour ExternalSecret refresh interval")
|
||||||
|
|
@ -861,52 +812,6 @@ func TestRunnerEnableDeliveryAppliesDefaults(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunnerAutoMergesBootstrapMigration(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
switch request.Method + " " + request.URL.Path {
|
|
||||||
case http.MethodGet + " /api/v1/repos/test-org/cluster/pulls":
|
|
||||||
if request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/bootstrap-test-cluster" {
|
|
||||||
t.Fatalf("unexpected migration lookup query: %q", request.URL.RawQuery)
|
|
||||||
}
|
|
||||||
_ = json.NewEncoder(writer).Encode([]struct {
|
|
||||||
Number int `json:"number"`
|
|
||||||
}{{Number: 4}})
|
|
||||||
case http.MethodPost + " /api/v1/repos/test-org/cluster/pulls/4/merge":
|
|
||||||
var body struct {
|
|
||||||
Do string `json:"Do"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Do != "merge" {
|
|
||||||
t.Fatalf("unexpected migration merge request: %#v, %v", body, err)
|
|
||||||
}
|
|
||||||
writer.WriteHeader(http.StatusOK)
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected Forgejo request %s %s", request.Method, request.URL.Path)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
manager := forgejo.NewRepoManager(server.URL, "test-token", "test-org", "bot", "manifests", "cluster", "main", "maidn/bootstrap-test-cluster")
|
|
||||||
manager.HTTPClient = server.Client()
|
|
||||||
manager.MigrationPending = true
|
|
||||||
if err := (Runner{Config: config.Config{Flux: config.FluxConfig{RepoName: "cluster"}}, AutoMergeBootstrapMigration: true}).reconcileBootstrapMigration(manager); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunnerRetainsMigrationApprovalGate(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
||||||
t.Fatal("normal bootstrap must not call Forgejo to merge a migration")
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
manager := forgejo.NewRepoManager(server.URL, "test-token", "test-org", "bot", "manifests", "cluster", "main", "maidn/bootstrap-test-cluster")
|
|
||||||
manager.HTTPClient = server.Client()
|
|
||||||
manager.MigrationPending = true
|
|
||||||
if err := (Runner{Config: config.Config{Flux: config.FluxConfig{RepoName: "cluster"}}}).reconcileBootstrapMigration(manager); err == nil || !strings.Contains(err.Error(), "merge and rerun bootstrap") {
|
|
||||||
t.Fatalf("normal bootstrap migration gate = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
|
func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
|
||||||
originalPreflight := preflight
|
originalPreflight := preflight
|
||||||
originalGit := runGit
|
originalGit := runGit
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
||||||
|
|
@ -91,7 +90,6 @@ func freshLifecycleRunner(cfg config.Config, options FreshOrganizationOptions) R
|
||||||
ConfirmRebuild: options.ConfirmRebuild,
|
ConfirmRebuild: options.ConfirmRebuild,
|
||||||
EnableDelivery: options.EnableDelivery,
|
EnableDelivery: options.EnableDelivery,
|
||||||
SkipDeliveryScaffolding: !options.EnableDelivery,
|
SkipDeliveryScaffolding: !options.EnableDelivery,
|
||||||
AutoMergeBootstrapMigration: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,147 +98,30 @@ func validateFreshWorkspace(cfg config.Config) error {
|
||||||
if err != nil || filepath.Dir(cloneRelative) != "." {
|
if err != nil || filepath.Dir(cloneRelative) != "." {
|
||||||
return errors.New("git cloneParent must be a direct child of isolated workspaceDir")
|
return errors.New("git cloneParent must be a direct child of isolated workspaceDir")
|
||||||
}
|
}
|
||||||
secretFiles, secretDirectories, err := freshWorkspaceSecretPaths(cfg, cloneRelative)
|
entries, err := os.ReadDir(cfg.WorkspaceDir)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
info, err := os.Lstat(cfg.WorkspaceDir)
|
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("inspect workspaceDir: %w", err)
|
return fmt.Errorf("inspect workspaceDir: %w", err)
|
||||||
}
|
}
|
||||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
||||||
return errors.New("workspaceDir must be an isolated directory")
|
|
||||||
}
|
|
||||||
entries, err := os.ReadDir(cfg.WorkspaceDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("inspect workspaceDir: %w", err)
|
|
||||||
}
|
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
lockPath := filepath.Join(cfg.WorkspaceDir, "maidn-template-revisions.yaml")
|
lock, err := readTemplateRevisionLock(filepath.Join(cfg.WorkspaceDir, "maidn-template-revisions.yaml"))
|
||||||
if !freshWorkspaceRegularFile(lockPath) {
|
|
||||||
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
|
|
||||||
}
|
|
||||||
lock, err := readTemplateRevisionLock(lockPath)
|
|
||||||
if err != nil || !sameTemplateSource(lock.CICD, templateCheckout{Repository: cfg.Templates.CICDRepoURL, Ref: cfg.Templates.CICDRepoRef}) || !sameTemplateSource(lock.Manifests, templateCheckout{Repository: cfg.Templates.ManifestsRepoURL, Ref: cfg.Templates.ManifestsRepoRef}) || !sameTemplateSource(lock.Talos, templateCheckout{Repository: cfg.Templates.TalosRepoURL, Ref: cfg.Templates.TalosRepoRef}) {
|
if err != nil || !sameTemplateSource(lock.CICD, templateCheckout{Repository: cfg.Templates.CICDRepoURL, Ref: cfg.Templates.CICDRepoRef}) || !sameTemplateSource(lock.Manifests, templateCheckout{Repository: cfg.Templates.ManifestsRepoURL, Ref: cfg.Templates.ManifestsRepoRef}) || !sameTemplateSource(lock.Talos, templateCheckout{Repository: cfg.Templates.TalosRepoURL, Ref: cfg.Templates.TalosRepoRef}) {
|
||||||
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
|
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
|
||||||
}
|
}
|
||||||
allowedFiles := map[string]bool{
|
allowed := map[string]bool{
|
||||||
"maidn-template-revisions.yaml": true,
|
"maidn-template-revisions.yaml": true,
|
||||||
"maidn-bootstrap.resolved.yaml": true,
|
|
||||||
}
|
|
||||||
allowedDirectories := map[string]bool{
|
|
||||||
"maidn-cicd-cluster-template": true,
|
"maidn-cicd-cluster-template": true,
|
||||||
"cicd-deployment-manifests-template": true,
|
"cicd-deployment-manifests-template": true,
|
||||||
cloneRelative: true,
|
cloneRelative: true,
|
||||||
}
|
}
|
||||||
for path := range secretFiles {
|
|
||||||
if filepath.Dir(path) == "." {
|
|
||||||
allowedFiles[path] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for path := range secretDirectories {
|
|
||||||
if filepath.Dir(path) == "." {
|
|
||||||
allowedDirectories[path] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if allowedFiles[entry.Name()] {
|
if !allowed[entry.Name()] {
|
||||||
if !freshWorkspaceRegularFile(filepath.Join(cfg.WorkspaceDir, entry.Name())) {
|
|
||||||
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !allowedDirectories[entry.Name()] || !freshWorkspaceDirectory(filepath.Join(cfg.WorkspaceDir, entry.Name())) {
|
|
||||||
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
|
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return validateFreshWorkspaceSecretDirectories(cfg.WorkspaceDir, secretFiles, secretDirectories)
|
|
||||||
}
|
|
||||||
|
|
||||||
func freshWorkspaceSecretPaths(cfg config.Config, cloneRelative string) (map[string]bool, map[string]bool, error) {
|
|
||||||
files := map[string]bool{}
|
|
||||||
directories := map[string]bool{}
|
|
||||||
protected := map[string]bool{
|
|
||||||
"maidn-template-revisions.yaml": true,
|
|
||||||
"maidn-bootstrap.resolved.yaml": true,
|
|
||||||
"maidn-cicd-cluster-template": true,
|
|
||||||
"cicd-deployment-manifests-template": true,
|
|
||||||
cloneRelative: true,
|
|
||||||
}
|
|
||||||
for _, path := range []string{cfg.SOPS.AgeKeyPath, cfg.SOPS.BootstrapSecretsPath, cfg.SOPS.OperationalSecretsPath, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath} {
|
|
||||||
if path == "" || !filepath.IsAbs(path) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
relative, err := filepath.Rel(cfg.WorkspaceDir, path)
|
|
||||||
if err != nil || relative == "." || relative == ".." || filepath.IsAbs(relative) || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.Split(filepath.ToSlash(relative), "/")
|
|
||||||
if protected[parts[0]] {
|
|
||||||
return nil, nil, errors.New("SOPS and recovery paths must not use protected workspace paths")
|
|
||||||
}
|
|
||||||
files[relative] = true
|
|
||||||
for parent := filepath.Dir(relative); parent != "."; parent = filepath.Dir(parent) {
|
|
||||||
directories[parent] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for path := range files {
|
|
||||||
if directories[path] {
|
|
||||||
return nil, nil, errors.New("SOPS and recovery paths must not overlap")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files, directories, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateFreshWorkspaceSecretDirectories(workspace string, files, directories map[string]bool) error {
|
|
||||||
for directory := range directories {
|
|
||||||
if directories[filepath.Dir(directory)] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := validateFreshWorkspaceSecretDirectory(workspace, directory, files, directories); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateFreshWorkspaceSecretDirectory(workspace, directory string, files, directories map[string]bool) error {
|
|
||||||
path := filepath.Join(workspace, directory)
|
|
||||||
entries, err := os.ReadDir(path)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil || !freshWorkspaceDirectory(path) {
|
|
||||||
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
|
|
||||||
}
|
|
||||||
for _, entry := range entries {
|
|
||||||
relative := filepath.Join(directory, entry.Name())
|
|
||||||
path := filepath.Join(workspace, relative)
|
|
||||||
if files[relative] && freshWorkspaceRegularFile(path) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if directories[relative] && freshWorkspaceDirectory(path) {
|
|
||||||
if err := validateFreshWorkspaceSecretDirectory(workspace, relative, files, directories); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func freshWorkspaceRegularFile(path string) bool {
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
return err == nil && info.Mode()&os.ModeSymlink == 0 && info.Mode().IsRegular()
|
|
||||||
}
|
|
||||||
|
|
||||||
func freshWorkspaceDirectory(path string) bool {
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
return err == nil && info.Mode()&os.ModeSymlink == 0 && info.IsDir()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,7 @@ func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) {
|
||||||
{FreshOrganizationOptions{Mode: Rebuild, ConfirmRebuild: true, EnableDelivery: true}, Rebuild},
|
{FreshOrganizationOptions{Mode: Rebuild, ConfirmRebuild: true, EnableDelivery: true}, Rebuild},
|
||||||
} {
|
} {
|
||||||
runner := freshLifecycleRunner(cfg, test.options)
|
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 {
|
if runner.Mode != test.mode || runner.ConfirmRebuild != test.options.ConfirmRebuild || runner.EnableDelivery != test.options.EnableDelivery || runner.SkipDeliveryScaffolding == test.options.EnableDelivery {
|
||||||
t.Fatalf("fresh lifecycle runner = %#v", runner)
|
t.Fatalf("fresh lifecycle runner = %#v", runner)
|
||||||
}
|
}
|
||||||
preflight = func(got config.Config) error {
|
preflight = func(got config.Config) error {
|
||||||
|
|
@ -201,61 +201,10 @@ func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) {
|
||||||
|
|
||||||
func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) {
|
func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) {
|
||||||
cfg := freshPlanConfig(t)
|
cfg := freshPlanConfig(t)
|
||||||
prepareResumableFreshWorkspace(t, &cfg)
|
if err := os.WriteFile(filepath.Join(cfg.WorkspaceDir, "leftover"), []byte("state"), 0600); err != nil {
|
||||||
if err := os.WriteFile(filepath.Join(cfg.WorkspaceDir, ".age", "leftover"), []byte("state"), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, _, err := PlanFreshOrganization(cfg, FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true}); err == nil {
|
if _, _, err := PlanFreshOrganization(cfg, FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true}); err == nil {
|
||||||
t.Fatal("ambiguous workspace state was accepted")
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue