Merge pull request 'feat: scaffold fresh organization bootstrap' (#25) from feat/fresh-organization-bootstrap into main

Reviewed-on: #25
This commit is contained in:
eding 2026-09-05 21:16:03 +02:00
commit f09cdf4ee9
10 changed files with 732 additions and 16 deletions

View file

@ -16,6 +16,8 @@ dlv version
- `cicd-tool repo init --org <org> --flux-repo <repo>` creates the manifests and Flux repos - `cicd-tool repo init --org <org> --flux-repo <repo>` creates the manifests and Flux repos
- `cicd-tool bootstrap` runs a shorter Forgejo-first wizard, asks for a Forgejo PAT, asks where local repos should be cloned, discovers Proxmox nodes/storage/networks, retries without losing entered answers when discovery fails, shows the latest Talos version, derives the standardized Talos factory URL, schematic, and required extensions automatically from the chosen version, writes `terraform.tfvars`, stages Talos images on Proxmox, and can execute Terraform, Talos bootstrap, and Flux bootstrap - `cicd-tool bootstrap` runs a shorter Forgejo-first wizard, asks for a Forgejo PAT, asks where local repos should be cloned, discovers Proxmox nodes/storage/networks, retries without losing entered answers when discovery fails, shows the latest Talos version, derives the standardized Talos factory URL, schematic, and required extensions automatically from the chosen version, writes `terraform.tfvars`, stages Talos images on Proxmox, and can execute Terraform, Talos bootstrap, and Flux bootstrap
- `cicd-tool bootstrap --config maidn-bootstrap.yaml` skips the wizard and uses the saved config - `cicd-tool bootstrap --config maidn-bootstrap.yaml` skips the wizard and uses the saved config
- `maidn bootstrap init --config <private-config> --organization <new-org> --create-organization --enable-delivery` locks an isolated workspace, initializes the Forgejo repositories, then runs the non-destructive bootstrap reconcile lifecycle; use `--mode=rebuild --yes` for an authorized rebuild. Delivery scaffolding requires `--enable-delivery`.
- `maidn app onboard --config <private-config> --from <app-checkout>` validates a clean configured checkout and adds its `.tekton` delivery contract.
See `docs/operations.md` for the authorized operating and verification runbook. See `docs/operations.md` for the authorized operating and verification runbook.

102
cmd/fresh.go Normal file
View file

@ -0,0 +1,102 @@
package cmd
import (
"fmt"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
"github.com/spf13/cobra"
)
var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string
var freshCreateOrganization, freshEnableDelivery, freshYes bool
var freshMode string
var loadFreshConfig = config.LoadRaw
var runFreshOrganization = bootstrap.RunFreshOrganization
var resolveAppOnboarding = config.ResolveAppOnboarding
var ensureOnboardCheckoutClean = forgejo.EnsureCleanCheckout
var onboardCheckoutOrigin = forgejo.CheckoutOrigin
var onboardCheckoutBranch = forgejo.CurrentBranch
var generateAppDelivery = bootstrap.GenerateAppDelivery
var bootstrapInitCmd = &cobra.Command{
Use: "init",
Short: "Create and lock a fresh Forgejo organization bootstrap workspace.",
RunE: runBootstrapInit,
}
var appCmd = &cobra.Command{
Use: "app",
Short: "Manage application delivery scaffolding.",
}
var appOnboardCmd = &cobra.Command{
Use: "onboard",
Short: "Validate an application checkout and add its source-owned delivery contract.",
RunE: runAppOnboard,
}
func init() {
bootstrapCmd.AddCommand(bootstrapInitCmd)
bootstrapInitCmd.Flags().StringVar(&freshConfigPath, "config", "", "Path to private bootstrap config YAML")
bootstrapInitCmd.Flags().StringVar(&freshOrganization, "organization", "", "New Forgejo organization name")
bootstrapInitCmd.Flags().BoolVar(&freshCreateOrganization, "create-organization", false, "Create the Forgejo organization when absent")
bootstrapInitCmd.Flags().BoolVar(&freshEnableDelivery, "enable-delivery", false, "Initialize delivery repository prerequisites")
bootstrapInitCmd.Flags().StringVar(&freshMode, "mode", string(bootstrap.Reconcile), "Lifecycle mode: reconcile or rebuild")
bootstrapInitCmd.Flags().BoolVar(&freshYes, "yes", false, "Confirm destructive rebuild")
_ = bootstrapInitCmd.MarkFlagRequired("config")
_ = bootstrapInitCmd.MarkFlagRequired("organization")
rootCmd.AddCommand(appCmd)
appCmd.AddCommand(appOnboardCmd)
appOnboardCmd.Flags().StringVar(&onboardConfigPath, "config", "", "Path to private bootstrap config YAML")
appOnboardCmd.Flags().StringVar(&onboardFrom, "from", "", "Clean application checkout to scaffold")
_ = appOnboardCmd.MarkFlagRequired("config")
_ = appOnboardCmd.MarkFlagRequired("from")
}
func runBootstrapInit(cmd *cobra.Command, _ []string) error {
cfg, err := loadFreshConfig(freshConfigPath)
if err != nil {
return err
}
plan, err := runFreshOrganization(cfg, bootstrap.FreshOrganizationOptions{Organization: freshOrganization, CreateOrganization: freshCreateOrganization, EnableDelivery: freshEnableDelivery, Mode: bootstrap.Mode(freshMode), ConfirmRebuild: freshYes})
if err != nil {
return err
}
for _, phase := range plan.Phases {
fmt.Fprintf(cmd.OutOrStdout(), "[PLAN] %s\n", phase)
}
return nil
}
func runAppOnboard(_ *cobra.Command, _ []string) error {
cfg, err := loadFreshConfig(onboardConfigPath)
if err != nil {
return err
}
cfg, err = resolveAppOnboarding(cfg)
if err != nil {
return err
}
if err := ensureOnboardCheckoutClean(onboardFrom); err != nil {
return err
}
origin, err := onboardCheckoutOrigin(onboardFrom)
if err != nil {
return err
}
if config.RedactURL(origin) != cfg.Delivery.AppRepoURL {
return fmt.Errorf("--from origin does not match delivery appRepoUrl")
}
branch, err := onboardCheckoutBranch(onboardFrom)
if err != nil {
return err
}
if branch != cfg.Delivery.AppRepoRef {
return fmt.Errorf("--from branch must match delivery appRepoRef")
}
return generateAppDelivery(onboardFrom, cfg)
}

63
cmd/fresh_test.go Normal file
View file

@ -0,0 +1,63 @@
package cmd
import (
"errors"
"testing"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
)
func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) {
originalConfig, originalResolve, originalClean := loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
t.Cleanup(func() {
loadFreshConfig = originalConfig
resolveAppOnboarding = originalResolve
ensureOnboardCheckoutClean = originalClean
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
})
loadFreshConfig = func(string) (config.Config, error) { return config.Config{}, nil }
resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") }
ensureOnboardCheckoutClean = func(string) error {
t.Fatal("onboarding inspected checkout before validating config")
return nil
}
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
if err := runAppOnboard(nil, nil); err == nil {
t.Fatal("onboarding accepted invalid configuration")
}
}
func TestAppOnboardScaffoldsOnlyTheValidatedCheckout(t *testing.T) {
originalConfig, originalResolve, originalClean := loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
originalOrigin, originalBranch, originalGenerate := onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
t.Cleanup(func() {
loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean = originalConfig, originalResolve, originalClean
onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery = originalOrigin, originalBranch, originalGenerate
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
})
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 }
resolveAppOnboarding = func(config.Config) (config.Config, error) { return cfg, nil }
ensureOnboardCheckoutClean = func(path string) error {
if path != "app-checkout" {
t.Fatal("onboarding checked the wrong checkout")
}
return nil
}
onboardCheckoutOrigin = func(string) (string, error) { return cfg.Delivery.AppRepoURL, nil }
onboardCheckoutBranch = func(string) (string, error) { return cfg.Delivery.AppRepoRef, nil }
generations := 0
generateAppDelivery = func(path string, got config.Config) error {
if path != "app-checkout" || got.Delivery.AppRepoURL != cfg.Delivery.AppRepoURL {
t.Fatal("onboarding generated delivery for the wrong checkout or config")
}
generations++
return nil
}
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
if err := runAppOnboard(nil, nil); err != nil || generations != 1 {
t.Fatalf("runAppOnboard() = %v, generations = %d", err, generations)
}
}

View file

@ -45,6 +45,7 @@ type Runner struct {
ConfirmRebuild bool ConfirmRebuild bool
RegisterWebhook bool RegisterWebhook bool
EnableDelivery bool EnableDelivery bool
SkipDeliveryScaffolding bool
} }
type operationalSecrets struct { type operationalSecrets struct {
@ -139,6 +140,9 @@ var generatedTemplateFiles = map[string]map[string]bool{
var requiredClusterKustomizations = []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", "cnpg-kustomization.yaml", "gateway-kustomization.yaml", "external-dns-kustomization.yaml", "cloudflare-tunnel-kustomization.yaml", "monitoring-kustomization.yaml", "tekton-kustomization.yaml", "tekton-triggers-kustomization.yaml", "cicd-manifests-repo.yaml"} var requiredClusterKustomizations = []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", "cnpg-kustomization.yaml", "gateway-kustomization.yaml", "external-dns-kustomization.yaml", "cloudflare-tunnel-kustomization.yaml", "monitoring-kustomization.yaml", "tekton-kustomization.yaml", "tekton-triggers-kustomization.yaml", "cicd-manifests-repo.yaml"}
func (r Runner) Run() error { func (r Runner) Run() error {
if r.SkipDeliveryScaffolding {
r.Config.Delivery = config.DeliveryConfig{}
}
resolved, err := config.Resolve(r.Config) resolved, err := config.Resolve(r.Config)
if err != nil { if err != nil {
return err return err
@ -166,14 +170,8 @@ func (r Runner) Run() error {
return errors.New("SOPS recoveryRecipient is required") return errors.New("SOPS recoveryRecipient is required")
} }
} }
if r.Mode == "" { if r.Mode, err = resolveLifecycleMode(r.Mode, r.ConfirmRebuild); err != nil {
r.Mode = Reconcile return err
}
if r.Mode != Reconcile && r.Mode != Rebuild {
return fmt.Errorf("unsupported bootstrap mode %q", r.Mode)
}
if r.Mode == Rebuild && !r.ConfirmRebuild {
return fmt.Errorf("rebuild is destructive; rerun with --mode=rebuild --yes")
} }
if r.RegisterWebhook { if r.RegisterWebhook {
return r.reconcileWebhook(filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName, r.Config.Talos.GeneratedDir)) return r.reconcileWebhook(filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName, r.Config.Talos.GeneratedDir))
@ -191,10 +189,12 @@ func (r Runner) Run() error {
if err := EnsureTemplateRevisions(r.Config); err != nil { if err := EnsureTemplateRevisions(r.Config); err != nil {
return err return err
} }
if !r.SkipDeliveryScaffolding {
catalogManager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, "", "", r.Config.Templates.TektonCatalogRepoRef, "") catalogManager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, "", "", r.Config.Templates.TektonCatalogRepoRef, "")
if _, err := catalogManager.EnsureRepositoryCopy(r.Config.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", r.Config.Templates.TektonCatalogRepoURL); err != nil { if _, err := catalogManager.EnsureRepositoryCopy(r.Config.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", r.Config.Templates.TektonCatalogRepoURL); err != nil {
return fmt.Errorf("initialize Tekton catalog repository: %w", err) return fmt.Errorf("initialize Tekton catalog repository: %w", err)
} }
}
if err := config.WriteRedacted(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil { if err := config.WriteRedacted(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil {
return err return err
} }
@ -331,6 +331,19 @@ func (r Runner) Run() error {
return nil return nil
} }
func resolveLifecycleMode(mode Mode, confirmRebuild bool) (Mode, error) {
if mode == "" {
mode = Reconcile
}
if mode != Reconcile && mode != Rebuild {
return "", fmt.Errorf("unsupported bootstrap mode %q", mode)
}
if mode == Rebuild && !confirmRebuild {
return "", fmt.Errorf("rebuild is destructive; rerun with --mode=rebuild --yes")
}
return mode, nil
}
func (r Runner) reconcileCloudflareTunnel() error { func (r Runner) reconcileCloudflareTunnel() error {
secrets, err := readCloudflareOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath) secrets, err := readCloudflareOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath)
if err != nil { if err != nil {

127
internal/bootstrap/fresh.go Normal file
View file

@ -0,0 +1,127 @@
package bootstrap
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
)
type FreshOrganizationOptions struct {
Organization string
CreateOrganization bool
EnableDelivery bool
Mode Mode
ConfirmRebuild bool
}
type FreshOrganizationPlan struct {
Phases []string
}
var ensureFreshTemplateRevisions = EnsureTemplateRevisions
var newFreshRepoManager = forgejo.NewRepoManager
var runFreshLifecycle = reconcileFreshOrganization
// PlanFreshOrganization validates the fresh, reversible setup phases before
// any Forgejo or Git boundary is reached.
func PlanFreshOrganization(cfg config.Config, options FreshOrganizationOptions) (config.Config, FreshOrganizationPlan, error) {
mode, err := resolveLifecycleMode(options.Mode, options.ConfirmRebuild)
if err != nil {
return cfg, FreshOrganizationPlan{}, err
}
resolved, err := config.ResolveFreshBootstrap(cfg, options.Organization, options.EnableDelivery)
if err != nil {
return cfg, FreshOrganizationPlan{}, err
}
if !options.CreateOrganization {
return cfg, FreshOrganizationPlan{}, errors.New("--create-organization is required for fresh organization bootstrap")
}
if err := validateFreshWorkspace(resolved); err != nil {
return cfg, FreshOrganizationPlan{}, err
}
phases := []string{
"validate isolated workspace and configuration",
"lock template revisions in the isolated workspace",
"ensure the Forgejo organization",
"ensure baseline Flux and manifests repositories",
}
if options.EnableDelivery {
phases = append(phases, "initialize the user-managed Tekton catalog repository")
}
phases = append(phases, fmt.Sprintf("%s the CI/CD cluster", mode))
return resolved, FreshOrganizationPlan{Phases: phases}, nil
}
// RunFreshOrganization completes a fresh bootstrap through the selected lifecycle.
func RunFreshOrganization(cfg config.Config, options FreshOrganizationOptions) (FreshOrganizationPlan, error) {
resolved, plan, err := PlanFreshOrganization(cfg, options)
if err != nil {
return FreshOrganizationPlan{}, err
}
if err := ensureFreshTemplateRevisions(resolved); err != nil {
return plan, fmt.Errorf("lock template revisions: %w", err)
}
manager := newFreshRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "")
if _, err := manager.EnsureOrganization(options.CreateOrganization); err != nil {
return plan, fmt.Errorf("ensure Forgejo organization: %w", err)
}
if err := runFreshLifecycle(resolved, options); err != nil {
return plan, fmt.Errorf("run fresh CI/CD bootstrap: %w", err)
}
return plan, nil
}
func reconcileFreshOrganization(cfg config.Config, options FreshOrganizationOptions) error {
return freshLifecycleRunner(cfg, options).Run()
}
func freshLifecycleRunner(cfg config.Config, options FreshOrganizationOptions) Runner {
mode := options.Mode
if mode == "" {
mode = Reconcile
}
return Runner{
Config: cfg,
Mode: mode,
ConfirmRebuild: options.ConfirmRebuild,
EnableDelivery: options.EnableDelivery,
SkipDeliveryScaffolding: !options.EnableDelivery,
}
}
func validateFreshWorkspace(cfg config.Config) error {
cloneRelative, err := filepath.Rel(cfg.WorkspaceDir, cfg.Git.CloneParent)
if err != nil || filepath.Dir(cloneRelative) != "." {
return errors.New("git cloneParent must be a direct child of isolated workspaceDir")
}
entries, err := os.ReadDir(cfg.WorkspaceDir)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("inspect workspaceDir: %w", err)
}
if len(entries) == 0 {
return nil
}
lock, err := readTemplateRevisionLock(filepath.Join(cfg.WorkspaceDir, "maidn-template-revisions.yaml"))
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")
}
allowed := map[string]bool{
"maidn-template-revisions.yaml": true,
"maidn-cicd-cluster-template": true,
"cicd-deployment-manifests-template": true,
cloneRelative: true,
}
for _, entry := range entries {
if !allowed[entry.Name()] {
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
}
}
return nil
}

View file

@ -0,0 +1,210 @@
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 {
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)
if err := os.WriteFile(filepath.Join(cfg.WorkspaceDir, "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")
}
}

View file

@ -106,6 +106,94 @@ func ResolveDelivery(cfg Config) (Config, error) {
return cfg, ValidateDelivery(cfg) return cfg, ValidateDelivery(cfg)
} }
// ResolveFreshBootstrap validates the scoped contract needed before a new
// Forgejo organization can be scaffolded. It deliberately does not validate
// recovery, SOPS, Proxmox, or Talos state.
func ResolveFreshBootstrap(cfg Config, organization string, enableDelivery bool) (Config, error) {
if !validRepositoryPart(organization) {
return cfg, errors.New("organization must be a Forgejo owner name")
}
if cfg.Git.Owner != "" && cfg.Git.Owner != organization {
return cfg, errors.New("git owner must match --organization")
}
cfg.Git.Owner = organization
if cfg.WorkspaceDir == "" || !filepath.IsAbs(cfg.WorkspaceDir) {
return cfg, errors.New("workspaceDir must be an absolute isolated workspace path")
}
if cfg.Git.CloneParent == "" || !filepath.IsAbs(cfg.Git.CloneParent) || !isChildPath(cfg.WorkspaceDir, cfg.Git.CloneParent) {
return cfg, errors.New("git cloneParent must be an absolute child of workspaceDir")
}
if cfg.Git.Provider != "forgejo" || cfg.Git.BaseURL == "" || cfg.Git.Username == "" || cfg.Git.Token == "" {
return cfg, errors.New("git provider, baseUrl, username, and token are required for fresh bootstrap")
}
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
return cfg, err
}
if !validRepositoryPart(cfg.Flux.RepoName) || !validRepositoryPart(cfg.Flux.ManifestsRepo) || !validRepositoryPart(cfg.Flux.TektonCatalogRepo) || cfg.Flux.Branch == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ClusterDomain == "" {
return cfg, errors.New("flux repoName, manifestsRepo, tektonCatalogRepo, branch, clusterPath, and clusterDomain are required")
}
if cfg.Talos.RepoDirName == "" || filepath.Base(cfg.Talos.RepoDirName) != cfg.Talos.RepoDirName || cfg.Talos.GeneratedDir == "" {
return cfg, errors.New("talos repoDirName and generatedDir are required for template locking")
}
for _, source := range []struct{ URL, Ref string }{
{cfg.Templates.TalosRepoURL, cfg.Templates.TalosRepoRef},
{cfg.Templates.CICDRepoURL, cfg.Templates.CICDRepoRef},
{cfg.Templates.ManifestsRepoURL, cfg.Templates.ManifestsRepoRef},
{cfg.Templates.TektonCatalogRepoURL, cfg.Templates.TektonCatalogRepoRef},
} {
if source.URL == "" || source.Ref == "" || RedactURL(source.URL) != source.URL {
return cfg, errors.New("template repository URLs and refs must be explicit and credential-free")
}
if err := validateRepositoryURL(source.URL); err != nil {
return cfg, err
}
}
if enableDelivery {
var err error
cfg, err = ResolveDelivery(cfg)
if err != nil {
return cfg, err
}
}
return cfg, nil
}
// ResolveAppOnboarding validates only the source-owned delivery contract.
func ResolveAppOnboarding(cfg Config) (Config, error) {
if cfg.Git.Owner == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.Branch == "" {
return cfg, errors.New("git owner and flux manifestsRepo and branch are required for app onboarding")
}
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
return cfg, err
}
return ResolveDelivery(cfg)
}
func validRepositoryPart(value string) bool {
return value != "" && !strings.Contains(value, "..") && regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`).MatchString(value)
}
func isChildPath(parent, child string) bool {
relative, err := filepath.Rel(filepath.Clean(parent), filepath.Clean(child))
return err == nil && relative != "." && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
}
func validateForgejoOrigin(value string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") != "" {
return errors.New("git baseUrl must be a credential-free HTTPS origin")
}
return nil
}
func validateRepositoryURL(value string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") == "" {
return errors.New("template repository URL must be a credential-free HTTPS repository URL")
}
return nil
}
func applyDefaults(cfg *Config) { func applyDefaults(cfg *Config) {
if cfg.ClusterID == "" { if cfg.ClusterID == "" {
cfg.ClusterID = cfg.Talos.Cluster.Name cfg.ClusterID = cfg.Talos.Cluster.Name

View file

@ -143,6 +143,32 @@ func TestResolveDefaultsWebhookEndpoint(t *testing.T) {
} }
} }
func TestResolveFreshBootstrapRequiresAnIsolatedExplicitWorkspace(t *testing.T) {
cfg := validConfig(t)
cfg.Git.Owner = ""
cfg.Git.CloneParent = filepath.Join(cfg.WorkspaceDir, "checkouts")
cfg.Flux.TektonCatalogRepo = "catalog"
cfg.Templates.TektonCatalogRepoURL = "https://git.example.test/templates/catalog.git"
cfg.Templates.TektonCatalogRepoRef = "main"
resolved, err := ResolveFreshBootstrap(cfg, "new-org", false)
if err != nil || resolved.Git.Owner != "new-org" {
t.Fatalf("ResolveFreshBootstrap() = (%#v, %v)", resolved.Git.Owner, err)
}
cfg.Git.CloneParent = cfg.WorkspaceDir
if _, err := ResolveFreshBootstrap(cfg, "new-org", false); err == nil || !strings.Contains(err.Error(), "cloneParent") {
t.Fatalf("ResolveFreshBootstrap() error = %v, want isolated clone parent error", err)
}
}
func TestResolveFreshBootstrapRejectsConflictingOwner(t *testing.T) {
cfg := validConfig(t)
cfg.Git.CloneParent = filepath.Join(cfg.WorkspaceDir, "checkouts")
if _, err := ResolveFreshBootstrap(cfg, "other-org", false); err == nil || !strings.Contains(err.Error(), "owner") {
t.Fatalf("ResolveFreshBootstrap() error = %v, want owner mismatch", err)
}
}
func TestLoadRejectsUnknownFields(t *testing.T) { func TestLoadRejectsUnknownFields(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml") path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("workspaceDir: test\nunknown: value\n"), 0600); err != nil { if err := os.WriteFile(path, []byte("workspaceDir: test\nunknown: value\n"), 0600); err != nil {

View file

@ -36,6 +36,10 @@ type createRepoRequest struct {
DefaultBranch string `json:"default_branch"` DefaultBranch string `json:"default_branch"`
} }
type createOrganizationRequest struct {
Username string `json:"username"`
}
type pullRequestRequest struct { type pullRequestRequest struct {
Title string `json:"title"` Title string `json:"title"`
Head string `json:"head"` Head string `json:"head"`
@ -159,6 +163,53 @@ func (rm *RepoManager) InitializeAll(createRepo func(string) error, createFlux f
return rm.ensureRepo(rm.FluxRepoName, "Flux CD cluster configurations", createFlux) return rm.ensureRepo(rm.FluxRepoName, "Flux CD cluster configurations", createFlux)
} }
// EnsureOrganization creates the configured owner only when explicitly allowed.
func (rm *RepoManager) EnsureOrganization(create bool) (bool, error) {
exists, err := rm.organizationExists()
if err != nil {
return false, err
}
if exists {
return false, nil
}
if !create {
return false, errors.New("Forgejo organization does not exist; rerun with --create-organization")
}
body, err := json.Marshal(createOrganizationRequest{Username: rm.Owner})
if err != nil {
return false, err
}
status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/orgs", rm.BaseURL), body)
if err != nil {
return false, err
}
if status == http.StatusCreated {
return true, nil
}
if status == http.StatusConflict {
exists, err = rm.organizationExists()
if err == nil && exists {
return false, nil
}
}
return false, fmt.Errorf("unexpected Forgejo organization create status %d", status)
}
func (rm *RepoManager) organizationExists() (bool, error) {
status, err := rm.apiRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/orgs/%s", rm.BaseURL, url.PathEscape(rm.Owner)), nil)
if err != nil {
return false, err
}
switch status {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("unexpected Forgejo organization lookup status %d", status)
}
}
func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error { func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error {
exists, err := rm.repoExists(name) exists, err := rm.repoExists(name)
if err != nil { if err != nil {

View file

@ -141,6 +141,40 @@ func TestRepoExistsOnlyCreatesOnNotFound(t *testing.T) {
} }
} }
func TestEnsureOrganizationCreatesOnlyWhenRequested(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
switch request.Method {
case http.MethodGet:
if request.URL.Path != "/api/v1/orgs/new-org" {
t.Fatalf("unexpected organization lookup %q", request.URL.Path)
}
writer.WriteHeader(http.StatusNotFound)
case http.MethodPost:
var body createOrganizationRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Username != "new-org" {
t.Fatalf("unexpected organization create request: %#v, %v", body, err)
}
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected Forgejo method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "new-org", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
if _, err := manager.EnsureOrganization(false); err == nil {
t.Fatal("missing organization was accepted without explicit create")
}
if requests != 1 {
t.Fatal("organization lookup performed unexpected remote actions")
}
if created, err := manager.EnsureOrganization(true); err != nil || !created {
t.Fatalf("EnsureOrganization(true) = (%t, %v)", created, err)
}
}
func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) { func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) {
requests := 0 requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {