diff --git a/README.md b/README.md index 63cc3bd..fd7a4b4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ dlv version - `cicd-tool repo init --org --flux-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 --config maidn-bootstrap.yaml` skips the wizard and uses the saved config +- `maidn bootstrap init --config --organization --create-organization --enable-delivery` locks an isolated workspace and scaffolds the Forgejo organization repositories; it does not run infrastructure or cluster actions. +- `maidn app onboard --config --from ` validates a clean configured checkout and adds its `.tekton` delivery contract. See `docs/operations.md` for the authorized operating and verification runbook. diff --git a/cmd/fresh.go b/cmd/fresh.go new file mode 100644 index 0000000..80bc083 --- /dev/null +++ b/cmd/fresh.go @@ -0,0 +1,99 @@ +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 bool + +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.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}) + 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) +} diff --git a/cmd/fresh_test.go b/cmd/fresh_test.go new file mode 100644 index 0000000..9cf53da --- /dev/null +++ b/cmd/fresh_test.go @@ -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) + } +} diff --git a/internal/bootstrap/fresh.go b/internal/bootstrap/fresh.go new file mode 100644 index 0000000..6d985e0 --- /dev/null +++ b/internal/bootstrap/fresh.go @@ -0,0 +1,109 @@ +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 +} + +type FreshOrganizationPlan struct { + Phases []string +} + +// 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) { + 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") + } + return resolved, FreshOrganizationPlan{Phases: phases}, nil +} + +// RunFreshOrganization executes only the reversible source-control setup plan. +// Infrastructure, credentials, secret material, and cluster actions remain gated. +func RunFreshOrganization(cfg config.Config, options FreshOrganizationOptions) (FreshOrganizationPlan, error) { + resolved, plan, err := PlanFreshOrganization(cfg, options) + if err != nil { + return FreshOrganizationPlan{}, err + } + if err := EnsureTemplateRevisions(resolved); err != nil { + return plan, fmt.Errorf("lock template revisions: %w", err) + } + manager := forgejo.NewRepoManager(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) + } + for _, repository := range []struct{ name, description string }{ + {resolved.Flux.ManifestsRepo, "Centralized deployment manifests for Flux CD"}, + {resolved.Flux.RepoName, "Flux CD cluster configurations"}, + } { + if _, err := manager.EnsureInitializedRepository(repository.name, repository.description); err != nil { + return plan, fmt.Errorf("ensure Forgejo repository %q: %w", repository.name, err) + } + } + if options.EnableDelivery { + if _, err := manager.EnsureRepositoryCopy(resolved.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", resolved.Templates.TektonCatalogRepoURL); err != nil { + return plan, fmt.Errorf("initialize Tekton catalog repository: %w", err) + } + } + return plan, nil +} + +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 +} diff --git a/internal/bootstrap/fresh_test.go b/internal/bootstrap/fresh_test.go new file mode 100644 index 0000000..2c2eb13 --- /dev/null +++ b/internal/bootstrap/fresh_test.go @@ -0,0 +1,59 @@ +package bootstrap + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" +) + +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", + } + if !reflect.DeepEqual(plan.Phases, want) { + t.Fatalf("plan phases = %#v, want %#v", plan.Phases, want) + } +} + +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") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 8a98945..2c7a400 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -106,6 +106,94 @@ func ResolveDelivery(cfg Config) (Config, error) { 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) { if cfg.ClusterID == "" { cfg.ClusterID = cfg.Talos.Cluster.Name diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6b89f06..626289b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) { path := filepath.Join(t.TempDir(), "config.yaml") if err := os.WriteFile(path, []byte("workspaceDir: test\nunknown: value\n"), 0600); err != nil { diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go index f1b4d6d..c65eb9d 100644 --- a/internal/forgejo/repo.go +++ b/internal/forgejo/repo.go @@ -36,6 +36,10 @@ type createRepoRequest struct { DefaultBranch string `json:"default_branch"` } +type createOrganizationRequest struct { + Username string `json:"username"` +} + type pullRequestRequest struct { Title string `json:"title"` 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) } +// 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 { exists, err := rm.repoExists(name) if err != nil { diff --git a/internal/forgejo/repo_test.go b/internal/forgejo/repo_test.go index 1092730..d81333b 100644 --- a/internal/forgejo/repo_test.go +++ b/internal/forgejo/repo_test.go @@ -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) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {