Compare commits
4 commits
c80b860fcb
...
eb840a5468
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb840a5468 | ||
|
|
d0df184af4 | ||
|
|
769028c587 | ||
|
|
0b8147dd4f |
|
|
@ -13,7 +13,8 @@ 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.LoadRaw
|
var loadFreshConfig = config.Load
|
||||||
|
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
|
||||||
|
|
@ -73,7 +74,7 @@ func runBootstrapInit(cmd *cobra.Command, _ []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAppOnboard(_ *cobra.Command, _ []string) error {
|
func runAppOnboard(_ *cobra.Command, _ []string) error {
|
||||||
cfg, err := loadFreshConfig(onboardConfigPath)
|
cfg, err := loadAppOnboardConfig(onboardConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,76 @@ 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 := loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
|
originalConfig, originalResolve, originalClean := loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
|
||||||
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
loadFreshConfig = originalConfig
|
loadAppOnboardConfig = originalConfig
|
||||||
resolveAppOnboarding = originalResolve
|
resolveAppOnboarding = originalResolve
|
||||||
ensureOnboardCheckoutClean = originalClean
|
ensureOnboardCheckoutClean = originalClean
|
||||||
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
||||||
})
|
})
|
||||||
loadFreshConfig = func(string) (config.Config, error) { return config.Config{}, nil }
|
loadAppOnboardConfig = 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")
|
||||||
|
|
@ -29,16 +84,16 @@ func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppOnboardScaffoldsOnlyTheValidatedCheckout(t *testing.T) {
|
func TestAppOnboardScaffoldsOnlyTheValidatedCheckout(t *testing.T) {
|
||||||
originalConfig, originalResolve, originalClean := loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
|
originalConfig, originalResolve, originalClean := loadAppOnboardConfig, 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() {
|
||||||
loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean = originalConfig, originalResolve, originalClean
|
loadAppOnboardConfig, 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"}}
|
||||||
loadFreshConfig = func(string) (config.Config, error) { return cfg, nil }
|
loadAppOnboardConfig = 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" {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,22 @@ go run . bootstrap --config <private-bootstrap-config> --mode=rebuild --yes
|
||||||
The rebuild replaces Terraform-managed Talos VMs only. It does not manage or
|
The rebuild replaces Terraform-managed Talos VMs only. It does not manage or
|
||||||
delete TrueNAS datasets or unrelated infrastructure.
|
delete TrueNAS datasets or unrelated infrastructure.
|
||||||
|
|
||||||
|
## Planned Cluster Removal
|
||||||
|
|
||||||
|
There is currently no `maidn cluster remove` or `maidn uninstall` command.
|
||||||
|
Do not substitute direct Terraform or Kubernetes deletion for a supported
|
||||||
|
workflow. A future remove command must require the named-cluster confirmation,
|
||||||
|
limit its Terraform scope to Maidn Talos VMs, and leave external storage and
|
||||||
|
shared infrastructure under their respective operator procedures.
|
||||||
|
|
||||||
|
The command must handle partial removal and retry safely: verify each VM's
|
||||||
|
actual Proxmox state, accept already-absent Maidn VMs, report API timeouts as
|
||||||
|
incomplete rather than successful removal, and leave a sanitized removal
|
||||||
|
record in the isolated workspace. It must not delete shared network bridges,
|
||||||
|
TrueNAS datasets, SOPS identities, or unrelated infrastructure. Until that
|
||||||
|
command exists, an operator must use the authorized recovery procedure and
|
||||||
|
resolve Proxmox API failures before retrying bootstrap.
|
||||||
|
|
||||||
## OpenBao And Webhooks
|
## OpenBao And Webhooks
|
||||||
|
|
||||||
After a rebuild or an OpenBao restart, refresh Kubernetes auth and reseed the
|
After a rebuild or an OpenBao restart, refresh Kubernetes auth and reseed the
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ type Runner struct {
|
||||||
RegisterWebhook bool
|
RegisterWebhook bool
|
||||||
EnableDelivery bool
|
EnableDelivery bool
|
||||||
SkipDeliveryScaffolding bool
|
SkipDeliveryScaffolding bool
|
||||||
|
AutoMergeBootstrapMigration bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type operationalSecrets struct {
|
type operationalSecrets struct {
|
||||||
|
|
@ -270,8 +271,8 @@ func (r Runner) Run() error {
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if manager.MigrationPending {
|
if err := r.reconcileBootstrapMigration(manager); err != nil {
|
||||||
return errors.New("existing repository migration PR created; merge and rerun bootstrap before infrastructure changes")
|
return err
|
||||||
}
|
}
|
||||||
if err := r.reconcileCloudflareTunnel(); err != nil {
|
if err := r.reconcileCloudflareTunnel(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -331,6 +332,19 @@ 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
|
||||||
|
|
@ -1090,6 +1104,9 @@ 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,6 +749,55 @@ 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")
|
||||||
|
|
@ -812,6 +861,52 @@ 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,6 +5,7 @@ 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"
|
||||||
|
|
@ -90,6 +91,7 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,30 +100,147 @@ 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")
|
||||||
}
|
}
|
||||||
entries, err := os.ReadDir(cfg.WorkspaceDir)
|
secretFiles, secretDirectories, err := freshWorkspaceSecretPaths(cfg, cloneRelative)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
lock, err := readTemplateRevisionLock(filepath.Join(cfg.WorkspaceDir, "maidn-template-revisions.yaml"))
|
lockPath := 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")
|
||||||
}
|
}
|
||||||
allowed := map[string]bool{
|
allowedFiles := 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 !allowed[entry.Name()] {
|
if allowedFiles[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 {
|
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)
|
t.Fatalf("fresh lifecycle runner = %#v", runner)
|
||||||
}
|
}
|
||||||
preflight = func(got config.Config) error {
|
preflight = func(got config.Config) error {
|
||||||
|
|
@ -201,10 +201,61 @@ func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) {
|
||||||
|
|
||||||
func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) {
|
func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) {
|
||||||
cfg := freshPlanConfig(t)
|
cfg := freshPlanConfig(t)
|
||||||
if err := os.WriteFile(filepath.Join(cfg.WorkspaceDir, "leftover"), []byte("state"), 0600); err != nil {
|
prepareResumableFreshWorkspace(t, &cfg)
|
||||||
|
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