feat: add platform app onboarding #30
|
|
@ -23,15 +23,11 @@ var bootstrapInitializeOpenBao bool
|
|||
var bootstrapCreateForgejoRegistryToken bool
|
||||
var bootstrapRegisterWebhook bool
|
||||
var bootstrapRotateWebhookAuthorization bool
|
||||
var bootstrapPublishAppFrom string
|
||||
var bootstrapMergeBootstrapPR bool
|
||||
var bootstrapManageNetworkBridges bool
|
||||
var bootstrapEnableDelivery bool
|
||||
|
||||
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
|
||||
var loadPublishAppConfig = config.Load
|
||||
var ensurePublishAppCheckoutClean = forgejo.EnsureCleanCheckout
|
||||
|
||||
var bootstrapCmd = &cobra.Command{
|
||||
Use: "bootstrap",
|
||||
Short: "Bootstrap Talos and Flux from config or an interactive wizard.",
|
||||
|
|
@ -52,7 +48,6 @@ func init() {
|
|||
bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao")
|
||||
bootstrapCmd.Flags().BoolVar(&bootstrapRegisterWebhook, "register-webhook", false, "Seed OpenBao secrets and register the Forgejo webhook")
|
||||
bootstrapCmd.Flags().BoolVar(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao")
|
||||
bootstrapCmd.Flags().StringVar(&bootstrapPublishAppFrom, "publish-app-from", "", "Push this app checkout's current branch and create a Forgejo delivery PR")
|
||||
bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping")
|
||||
bootstrapCmd.Flags().BoolVar(&bootstrapManageNetworkBridges, "manage-network-bridges", false, "Persist Terraform management for existing Talos network bridges")
|
||||
bootstrapCmd.Flags().BoolVar(&bootstrapEnableDelivery, "enable-delivery", false, "Resolve delivery defaults and reconcile the configured app delivery source")
|
||||
|
|
@ -128,57 +123,6 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
}
|
||||
if bootstrapPublishAppFrom != "" {
|
||||
if bootstrapConfigPath == "" {
|
||||
return fmt.Errorf("--publish-app-from requires --config")
|
||||
}
|
||||
cfg, err = loadPublishAppConfig(bootstrapConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err = config.ResolveDelivery(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensurePublishAppCheckoutClean(bootstrapPublishAppFrom); err != nil {
|
||||
return err
|
||||
}
|
||||
branch, err := forgejo.CurrentBranch(bootstrapPublishAppFrom)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deliveryBranch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
owner, repo, err := forgejo.RepositoryFromURL(cfg.Delivery.AppRepoURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, owner, cfg.Git.Username, "", "", cfg.Delivery.AppRepoRef, "")
|
||||
created, err := manager.EnsureRepository(repo, "Application source for Maidn CI/CD delivery")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created && branch != cfg.Delivery.AppRepoRef {
|
||||
return fmt.Errorf("new application repository requires the checkout branch to match delivery appRepoRef")
|
||||
}
|
||||
if err := manager.EnsureProtectedBranch(repo, cfg.Delivery.ProductionBranch); err != nil {
|
||||
return fmt.Errorf("protect Forgejo production branch: %w", err)
|
||||
}
|
||||
if err := manager.PushRef(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, branch, branch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := manager.PublishDeliveryBranch(bootstrapPublishAppFrom, branch, cfg.Delivery.AppRepoURL, deliveryBranch, func(dir string) error {
|
||||
return bootstrap.GenerateAppDelivery(dir, cfg)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return manager.CreatePullRequest(repo, "feat: migrate delivery to Tekton", deliveryBranch, cfg.Delivery.AppRepoRef)
|
||||
}
|
||||
if bootstrapConfigPath != "" {
|
||||
if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery || bootstrapManageNetworkBridges {
|
||||
cfg, err = config.LoadRaw(bootstrapConfigPath)
|
||||
|
|
|
|||
|
|
@ -21,31 +21,6 @@ func TestCreateForgejoRegistryTokenRequiresConfig(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPublishAppRequiresDeliveryConfigBeforeCheckout(t *testing.T) {
|
||||
originalConfigPath, originalPublish := bootstrapConfigPath, bootstrapPublishAppFrom
|
||||
originalLoad, originalClean := loadPublishAppConfig, ensurePublishAppCheckoutClean
|
||||
t.Cleanup(func() {
|
||||
bootstrapConfigPath = originalConfigPath
|
||||
bootstrapPublishAppFrom = originalPublish
|
||||
loadPublishAppConfig = originalLoad
|
||||
ensurePublishAppCheckoutClean = originalClean
|
||||
})
|
||||
bootstrapConfigPath = "test-config.yaml"
|
||||
bootstrapPublishAppFrom = "app-checkout"
|
||||
loadPublishAppConfig = func(string) (config.Config, error) {
|
||||
return config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}}, nil
|
||||
}
|
||||
ensurePublishAppCheckoutClean = func(string) error {
|
||||
t.Fatal("publish inspected the checkout before validating delivery config")
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runBootstrap(nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "delivery appName") {
|
||||
t.Fatalf("runBootstrap() error = %v, want incomplete delivery error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedForgejoOperationalCredentialsUsesEncryptedUpsertBoundary(t *testing.T) {
|
||||
original := upsertOperationalSecret
|
||||
t.Cleanup(func() { upsertOperationalSecret = original })
|
||||
|
|
|
|||
27
cmd/fresh.go
27
cmd/fresh.go
|
|
@ -5,7 +5,6 @@ import (
|
|||
|
||||
"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"
|
||||
)
|
||||
|
||||
|
|
@ -17,10 +16,7 @@ var loadFreshConfig = config.Load
|
|||
var loadAppOnboardConfig = 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 onboardApp = bootstrap.OnboardApp
|
||||
|
||||
var bootstrapInitCmd = &cobra.Command{
|
||||
Use: "init",
|
||||
|
|
@ -44,7 +40,7 @@ func init() {
|
|||
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().BoolVar(&freshEnableDelivery, "enable-delivery", false, "Deprecated: init always initializes the shared delivery platform")
|
||||
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")
|
||||
|
|
@ -82,22 +78,5 @@ func runAppOnboard(_ *cobra.Command, _ []string) error {
|
|||
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)
|
||||
return onboardApp(cfg, onboardFrom)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,19 +62,17 @@ func TestBootstrapInitAppliesFluxDefaultsBeforeFreshValidation(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) {
|
||||
originalConfig, originalResolve, originalClean := loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
|
||||
func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
|
||||
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
|
||||
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
||||
t.Cleanup(func() {
|
||||
loadAppOnboardConfig = originalConfig
|
||||
resolveAppOnboarding = originalResolve
|
||||
ensureOnboardCheckoutClean = originalClean
|
||||
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
|
||||
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
||||
})
|
||||
loadAppOnboardConfig = 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")
|
||||
onboardApp = func(config.Config, string) error {
|
||||
t.Fatal("onboarding reached external work before validating config")
|
||||
return nil
|
||||
}
|
||||
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
|
||||
|
|
@ -83,36 +81,26 @@ func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAppOnboardScaffoldsOnlyTheValidatedCheckout(t *testing.T) {
|
||||
originalConfig, originalResolve, originalClean := loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
|
||||
originalOrigin, originalBranch, originalGenerate := onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery
|
||||
func TestAppOnboardPassesOnlyValidatedConfigAndCheckout(t *testing.T) {
|
||||
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
|
||||
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
||||
t.Cleanup(func() {
|
||||
loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean = originalConfig, originalResolve, originalClean
|
||||
onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery = originalOrigin, originalBranch, originalGenerate
|
||||
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
|
||||
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
||||
})
|
||||
cfg := config.Config{Delivery: config.DeliveryConfig{AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main"}}
|
||||
loadAppOnboardConfig = func(string) (config.Config, error) { return cfg, nil }
|
||||
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")
|
||||
calls := 0
|
||||
onboardApp = func(got config.Config, checkout string) error {
|
||||
if checkout != "app-checkout" || got.Delivery.AppRepoURL != cfg.Delivery.AppRepoURL {
|
||||
t.Fatal("onboarding used the wrong checkout or config")
|
||||
}
|
||||
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++
|
||||
calls++
|
||||
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)
|
||||
if err := runAppOnboard(nil, nil); err != nil || calls != 1 {
|
||||
t.Fatalf("runAppOnboard() = %v, calls = %d", err, calls)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,37 @@ new template revisions intentionally, use a new empty `workspaceDir` (and a
|
|||
fresh `cloneParent` when it is configured separately) and keep the prior
|
||||
secret-bearing workspace intact for recovery.
|
||||
|
||||
## Platform Initialization And App Onboarding
|
||||
|
||||
Initialize the shared delivery platform before onboarding any application. This
|
||||
creates the Gateway, Tekton, and Tekton Triggers platform resources using
|
||||
`tekton.<cluster-domain>` and the configured Forgejo owner; it does not render
|
||||
an application Pipeline or register an application webhook:
|
||||
|
||||
```powershell
|
||||
bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization
|
||||
```
|
||||
|
||||
Import a clean checkout into the configured owner with a separate command. The
|
||||
checkout may originate in another Forgejo organization, but `delivery.appRepoUrl`
|
||||
must target `<owner>/<app>.git` and its current branch must be
|
||||
`delivery.appRepoRef`:
|
||||
|
||||
```powershell
|
||||
app onboard --config <private-bootstrap-config> --from <clean-checkout>
|
||||
```
|
||||
|
||||
Onboarding copies the current branch to the target repository, replaces only
|
||||
the two Maidn-generated `.tekton` files, protects the production branch, and
|
||||
auto-merges the app delivery and cluster registration PRs. The registration is
|
||||
stored in `base/tekton/apps/<app>.yaml` in the configured cluster repository;
|
||||
the generic EventListener dispatches by the Forgejo repository name.
|
||||
|
||||
`delivery.productionBranch` is the application production branch. A production
|
||||
delivery PR targets the configured generic
|
||||
`<owner>/<manifests-repo>` repository semantics (for example,
|
||||
`test-org-2/<manifests-repo>`), never through a source checkout's former owner.
|
||||
|
||||
## Rebuild
|
||||
|
||||
Use only when an authorized recovery requires recreating the Talos VM:
|
||||
|
|
|
|||
|
|
@ -156,7 +156,6 @@ func (r Runner) Run() error {
|
|||
}
|
||||
r.Config = resolvedDelivery
|
||||
}
|
||||
deliveryConfigured := r.Config.Delivery.Configured()
|
||||
if err := preflight(r.Config); err != nil {
|
||||
return fmt.Errorf("preflight: %w", err)
|
||||
}
|
||||
|
|
@ -190,12 +189,10 @@ func (r Runner) Run() error {
|
|||
if err := EnsureTemplateRevisions(r.Config); err != nil {
|
||||
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, "")
|
||||
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)
|
||||
}
|
||||
}
|
||||
if err := config.WriteRedacted(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -213,23 +210,16 @@ func (r Runner) Run() error {
|
|||
},
|
||||
func(dir string) error {
|
||||
clusterDir := filepath.Join(dir, strings.TrimPrefix(r.Config.Flux.ClusterPath, "./"))
|
||||
if deliveryConfigured {
|
||||
if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := copyDirExcept(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false, deliveryTemplateBaseComponents); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyTemplateBaseComponents(cicdTemplateDir, dir, deliveryConfigured); err != nil {
|
||||
if err := copyTemplateBaseComponents(cicdTemplateDir, dir, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyClusterTemplate(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range []string{"external-secrets", "cnpg", "cloudflare-tunnel", "external-dns", "monitoring", "tekton"} {
|
||||
if !deliveryConfigured && name == "tekton" {
|
||||
continue
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(cicdTemplateDir, "clusters", "template", name+"-kustomization.yaml"))
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -248,11 +238,9 @@ func (r Runner) Run() error {
|
|||
if err := copyAndRenderCiliumBases(cicdTemplateDir, dir, r.Config); err != nil {
|
||||
return err
|
||||
}
|
||||
if deliveryConfigured {
|
||||
if err := copyAndRenderDeliveryBases(cicdTemplateDir, dir, r.Config); err != nil {
|
||||
if err := copyAndRenderPlatformDeliveryBases(cicdTemplateDir, dir, r.Config); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeDemocraticCSISecret(filepath.Join(dir, "base", "democratic-csi", "secret.sops.yaml"), r.Config.DemocraticCSI, r.Config.SOPS.AgeKeyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -263,7 +251,7 @@ func (r Runner) Run() error {
|
|||
if err := ensureOpenBaoUnsealKustomization(filepath.Join(openbaoDir, "kustomization.yaml")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureClusterKustomizations(clusterDir, deliveryConfigured); err != nil {
|
||||
if err := ensureClusterKustomizations(clusterDir, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return ghrepo.WriteFluxStructure(dir, r.Config.Flux.RepoName, r.Config.Flux.ClusterPath, fluxConfig)
|
||||
|
|
@ -327,7 +315,7 @@ func (r Runner) Run() error {
|
|||
if err := configureFluxSOPS(generatedDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.reconcileWebhook(generatedDir)
|
||||
return r.completeFluxBootstrap(generatedDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -386,7 +374,7 @@ func (r Runner) reconcileCloudflareTunnel() error {
|
|||
}
|
||||
|
||||
func (r Runner) reconcileWebhook(generatedDir string) error {
|
||||
operationalSecrets, err := initializeOpenBao(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath)
|
||||
operationalSecrets, err := r.initializeOpenBaoForCluster(generatedDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize OpenBao: %w", err)
|
||||
}
|
||||
|
|
@ -403,6 +391,18 @@ func (r Runner) reconcileWebhook(generatedDir string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r Runner) initializeOpenBaoForCluster(generatedDir string) (map[string]map[string]string, error) {
|
||||
return initializeOpenBao(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath)
|
||||
}
|
||||
|
||||
// completeFluxBootstrap runs the post-Flux platform initialization only.
|
||||
func (r Runner) completeFluxBootstrap(generatedDir string) error {
|
||||
if _, err := r.initializeOpenBaoForCluster(generatedDir); err != nil {
|
||||
return fmt.Errorf("initialize OpenBao: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderCiliumConfig(dir string, cfg config.Config) error {
|
||||
replacements := strings.NewReplacer(
|
||||
"${CILIUM_K8S_SERVICE_HOST}", cfg.Talos.KubeconfigEndpoint,
|
||||
|
|
@ -422,19 +422,16 @@ func renderCiliumConfig(dir string, cfg config.Config) error {
|
|||
})
|
||||
}
|
||||
|
||||
func renderDeliveryConfig(dir string, cfg config.Config) error {
|
||||
func renderPlatformDeliveryConfig(dir string, cfg config.Config) error {
|
||||
replacements := strings.NewReplacer(
|
||||
"${APP_NAME}", cfg.Delivery.AppName,
|
||||
"${APP_REPO_URL}", cfg.Delivery.AppRepoURL,
|
||||
"${APP_REPO_REF}", cfg.Delivery.AppRepoRef,
|
||||
"${PRODUCTION_BRANCH}", cfg.Delivery.ProductionBranch,
|
||||
"${IMAGE_REPOSITORY}", cfg.Delivery.ImageRepository,
|
||||
"${FORGEJO_BASE_URL}", cfg.Git.BaseURL,
|
||||
"${CLUSTER_DOMAIN}", cfg.Flux.ClusterDomain,
|
||||
"${GIT_OWNER}", cfg.Git.Owner,
|
||||
"${FORGEJO_OWNER}", cfg.Git.Owner,
|
||||
"${TEKTON_CATALOG_REPO_URL}", forgejo.CloneURL(cfg.Git.BaseURL, cfg.Git.Owner, cfg.Flux.TektonCatalogRepo),
|
||||
"${TEKTON_CATALOG_REPO_REF}", cfg.Templates.TektonCatalogRepoRef,
|
||||
"${WEBHOOK_HOSTNAME}", cfg.Delivery.WebhookHostname,
|
||||
"${WEBHOOK_PATH}", cfg.Delivery.WebhookPath,
|
||||
"${WEBHOOK_HOSTNAME}", "tekton."+cfg.Flux.ClusterDomain,
|
||||
"${WEBHOOK_PATH}", "/",
|
||||
)
|
||||
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
|
|
@ -448,10 +445,7 @@ func renderDeliveryConfig(dir string, cfg config.Config) error {
|
|||
})
|
||||
}
|
||||
|
||||
func copyAndRenderDeliveryBases(templateDir, repoDir string, cfg config.Config) error {
|
||||
if err := config.ValidateDelivery(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
func copyAndRenderPlatformDeliveryBases(templateDir, repoDir string, cfg config.Config) error {
|
||||
bases := []string{"gateway", "tekton", "tekton-triggers"}
|
||||
for _, base := range bases {
|
||||
baseDir := filepath.Join(repoDir, "base", base)
|
||||
|
|
@ -461,15 +455,11 @@ func copyAndRenderDeliveryBases(templateDir, repoDir string, cfg config.Config)
|
|||
}
|
||||
for _, base := range bases {
|
||||
baseDir := filepath.Join(repoDir, "base", base)
|
||||
if err := renderDeliveryConfig(baseDir, cfg); err != nil {
|
||||
if err := renderPlatformDeliveryConfig(baseDir, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
dir := filepath.Join(repoDir, "base", "tekton")
|
||||
if err := writePreviewDeliveryConfig(dir, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return removeDuplicateAppDeliverySource(dir, cfg.Delivery.AppName)
|
||||
return writePreviewDeliveryConfig(filepath.Join(repoDir, "base", "tekton"), cfg)
|
||||
}
|
||||
|
||||
func writePreviewDeliveryConfig(dir string, cfg config.Config) error {
|
||||
|
|
@ -585,10 +575,14 @@ func GenerateAppDelivery(dir string, cfg config.Config) error {
|
|||
if len(entries) != len(files) {
|
||||
return errors.New("app delivery .tekton contains unmanaged files")
|
||||
}
|
||||
for name, want := range files {
|
||||
got, err := os.ReadFile(filepath.Join(target, name))
|
||||
if err != nil || !bytes.Equal(got, want) {
|
||||
return errors.New("app delivery .tekton differs from Maidn generated content")
|
||||
for name, content := range files {
|
||||
file := filepath.Join(target, name)
|
||||
fileInfo, err := os.Lstat(file)
|
||||
if err != nil || fileInfo.Mode()&os.ModeSymlink != 0 || !fileInfo.Mode().IsRegular() {
|
||||
return errors.New("app delivery .tekton contains unmanaged files")
|
||||
}
|
||||
if err := os.WriteFile(file, content, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
|
@ -1031,7 +1025,7 @@ func waitForWebhookTargets(dir string, cfg config.Config, authorization string)
|
|||
if err := waitForWebhookAuthorization(dir, authorization); err != nil {
|
||||
return err
|
||||
}
|
||||
resources := []string{"deployment/el-" + cfg.Delivery.AppName, "pipeline/" + cfg.Delivery.AppName}
|
||||
resources := []string{"pipeline/" + cfg.Delivery.AppName}
|
||||
for _, resource := range resources {
|
||||
deadline := time.Now().Add(webhookTargetTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
|
|
@ -1078,9 +1072,6 @@ func ensureClusterKustomizations(clusterDir string, includeDelivery bool) error
|
|||
updated := string(content)
|
||||
updated = strings.ReplaceAll(updated, " - bootstrap-secrets.sops.yaml\n", "")
|
||||
for _, resource := range requiredClusterKustomizations {
|
||||
if !includeDelivery && (resource == "gateway-kustomization.yaml" || resource == "tekton-kustomization.yaml" || resource == "tekton-triggers-kustomization.yaml") {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(updated, resource) {
|
||||
updated += " - " + resource + "\n"
|
||||
}
|
||||
|
|
@ -1093,9 +1084,6 @@ func ensureClusterKustomizations(clusterDir string, includeDelivery bool) error
|
|||
|
||||
func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery bool) error {
|
||||
for _, component := range templateBaseComponents {
|
||||
if !includeDelivery && deliveryTemplateBaseComponents[component] {
|
||||
continue
|
||||
}
|
||||
if err := copyDirExcept(filepath.Join(templateDir, "base", component), filepath.Join(repoDir, "base", component), true, generatedTemplateFiles[component]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,22 +52,22 @@ func TestCiliumChartVersion(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRenderDeliveryConfig(t *testing.T) {
|
||||
func TestRenderPlatformDeliveryConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "webhook.yaml")
|
||||
if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\nproduction: ${PRODUCTION_BRANCH}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil {
|
||||
if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\nowner: ${GIT_OWNER}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog"}, Delivery: config.DeliveryConfig{ProductionBranch: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}}
|
||||
if err := renderDeliveryConfig(dir, cfg); err != nil {
|
||||
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{ClusterDomain: "example.test", TektonCatalogRepo: "my-tekton-catalog"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}}
|
||||
if err := renderPlatformDeliveryConfig(dir, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(content), "${") || !strings.Contains(string(content), "/hooks/forgejo") || !strings.Contains(string(content), "production: production") || !strings.Contains(string(content), "https://git.example.test/user-org/my-tekton-catalog.git") || !strings.Contains(string(content), "ref: release") {
|
||||
t.Fatalf("delivery configuration was not rendered: %s", content)
|
||||
if strings.Contains(string(content), "${") || !strings.Contains(string(content), "host: tekton.example.test") || !strings.Contains(string(content), "owner: user-org") || !strings.Contains(string(content), "https://git.example.test/user-org/my-tekton-catalog.git") || !strings.Contains(string(content), "ref: release") {
|
||||
t.Fatalf("platform delivery configuration was not rendered: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,15 +155,15 @@ func TestWritePreviewDeliveryConfigRejectsAmbiguousKustomization(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing.T) {
|
||||
func TestCopyAndRenderPlatformDeliveryBasesOverwritesExistingMigrationOutput(t *testing.T) {
|
||||
templateDir := t.TempDir()
|
||||
repoDir := t.TempDir()
|
||||
files := []struct {
|
||||
base, name, template, want string
|
||||
}{
|
||||
{"gateway", "route.yaml", "host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\n", "host: tekton.example.test\npath: /hooks/forgejo\n"},
|
||||
{"gateway", "route.yaml", "host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\n", "host: tekton.example.test\npath: /\n"},
|
||||
{"tekton", "catalog-source.yaml", "url: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n", "url: https://git.example.test/user-org/my-tekton-catalog.git\nref: release\n"},
|
||||
{"tekton-triggers", "trigger.yaml", "app: ${APP_NAME}\nrepo: ${APP_REPO_URL}\n", "app: demo\nrepo: https://git.example.test/demo.git\n"},
|
||||
{"tekton-triggers", "trigger.yaml", "owner: ${FORGEJO_OWNER}\nhost: ${WEBHOOK_HOSTNAME}\n", "owner: user-org\nhost: tekton.example.test\n"},
|
||||
}
|
||||
for _, file := range files {
|
||||
templatePath := filepath.Join(templateDir, "base", file.base, file.name)
|
||||
|
|
@ -191,11 +191,10 @@ func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing.
|
|||
|
||||
cfg := config.Config{
|
||||
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"},
|
||||
Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"},
|
||||
Delivery: config.DeliveryConfig{AppName: "demo", AppRepoURL: "https://git.example.test/demo.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/demo", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"},
|
||||
Flux: config.FluxConfig{ClusterDomain: "example.test", TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"},
|
||||
Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"},
|
||||
}
|
||||
if err := copyAndRenderDeliveryBases(templateDir, repoDir, cfg); err != nil {
|
||||
if err := copyAndRenderPlatformDeliveryBases(templateDir, repoDir, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, file := range files {
|
||||
|
|
|
|||
|
|
@ -50,9 +50,7 @@ func PlanFreshOrganization(cfg config.Config, options FreshOrganizationOptions)
|
|||
"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
|
||||
}
|
||||
|
|
@ -89,8 +87,7 @@ func freshLifecycleRunner(cfg config.Config, options FreshOrganizationOptions) R
|
|||
Config: cfg,
|
||||
Mode: mode,
|
||||
ConfirmRebuild: options.ConfirmRebuild,
|
||||
EnableDelivery: options.EnableDelivery,
|
||||
SkipDeliveryScaffolding: !options.EnableDelivery,
|
||||
SkipDeliveryScaffolding: true,
|
||||
AutoMergeBootstrapMigration: true,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,10 +172,11 @@ func TestRunFreshOrganizationStopsBeforeLifecycleOnForgejoFailure(t *testing.T)
|
|||
}
|
||||
}
|
||||
|
||||
func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) {
|
||||
func TestFreshLifecycleUsesSelectedModeWithoutAppDelivery(t *testing.T) {
|
||||
originalPreflight := preflight
|
||||
t.Cleanup(func() { preflight = originalPreflight })
|
||||
cfg := runnerTestConfig(t.TempDir(), "")
|
||||
cfg.Delivery = config.DeliveryConfig{}
|
||||
for _, test := range []struct {
|
||||
options FreshOrganizationOptions
|
||||
mode Mode
|
||||
|
|
@ -184,12 +185,12 @@ func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) {
|
|||
{FreshOrganizationOptions{Mode: Rebuild, ConfirmRebuild: true, EnableDelivery: true}, Rebuild},
|
||||
} {
|
||||
runner := freshLifecycleRunner(cfg, test.options)
|
||||
if runner.Mode != test.mode || runner.ConfirmRebuild != test.options.ConfirmRebuild || runner.EnableDelivery != test.options.EnableDelivery || runner.SkipDeliveryScaffolding == test.options.EnableDelivery || !runner.AutoMergeBootstrapMigration {
|
||||
if runner.Mode != test.mode || runner.ConfirmRebuild != test.options.ConfirmRebuild || runner.EnableDelivery || !runner.SkipDeliveryScaffolding || !runner.AutoMergeBootstrapMigration {
|
||||
t.Fatalf("fresh lifecycle runner = %#v", runner)
|
||||
}
|
||||
preflight = func(got config.Config) error {
|
||||
if got.Delivery.Configured() != test.options.EnableDelivery {
|
||||
t.Fatal("fresh lifecycle did not gate delivery scaffolding with --enable-delivery")
|
||||
if got.Delivery.Configured() {
|
||||
t.Fatal("fresh lifecycle resolved app delivery during platform initialization")
|
||||
}
|
||||
return errors.New("stop")
|
||||
}
|
||||
|
|
@ -199,6 +200,32 @@ func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFreshPlatformRunnerInitializesOpenBaoWithoutRegisteringWebhookAfterFluxSetup(t *testing.T) {
|
||||
originalInitialize, originalWebhook := initializeOpenBao, ensureForgejoWebhook
|
||||
t.Cleanup(func() {
|
||||
initializeOpenBao, ensureForgejoWebhook = originalInitialize, originalWebhook
|
||||
})
|
||||
runner := freshLifecycleRunner(runnerTestConfig(t.TempDir(), "age-key"), FreshOrganizationOptions{})
|
||||
if !runner.SkipDeliveryScaffolding || runner.RegisterWebhook {
|
||||
t.Fatalf("fresh platform runner = %#v", runner)
|
||||
}
|
||||
initialized := false
|
||||
initializeOpenBao = func(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, operationalSecretsPath string) (map[string]map[string]string, error) {
|
||||
if kubeconfig != filepath.Join("after-flux", "kubeconfig") || ageKeyPath != "age-key" {
|
||||
t.Fatal("platform OpenBao initialization used unexpected paths")
|
||||
}
|
||||
initialized = true
|
||||
return nil, nil
|
||||
}
|
||||
ensureForgejoWebhook = func(config.Config, string, string, string) error {
|
||||
t.Fatal("platform initialization registered an application webhook")
|
||||
return nil
|
||||
}
|
||||
if err := runner.completeFluxBootstrap("after-flux"); err != nil || !initialized {
|
||||
t.Fatalf("post-Flux platform initialization = %v, initialized = %t", err, initialized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) {
|
||||
cfg := freshPlanConfig(t)
|
||||
prepareResumableFreshWorkspace(t, &cfg)
|
||||
|
|
|
|||
264
internal/bootstrap/onboard.go
Normal file
264
internal/bootstrap/onboard.go
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// OnboardApp imports one clean checkout into the configured Forgejo owner,
|
||||
// publishes its delivery branch, registers it with Flux, then adds its webhook.
|
||||
func OnboardApp(cfg config.Config, sourceDir string) error {
|
||||
resolved, err := config.ResolveAppOnboarding(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := forgejo.EnsureCleanCheckout(sourceDir); err != nil {
|
||||
return err
|
||||
}
|
||||
branch, err := forgejo.CurrentBranch(sourceDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if branch != resolved.Delivery.AppRepoRef {
|
||||
return errors.New("--from branch must match delivery appRepoRef")
|
||||
}
|
||||
owner, repository, err := forgejo.RepositoryFromURL(resolved.Delivery.AppRepoURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != resolved.Git.Owner {
|
||||
return errors.New("delivery appRepoUrl owner must match git owner for app onboarding")
|
||||
}
|
||||
deliveryBranch, err := forgejo.DeliveryBranch(resolved.Delivery.AppName, resolved.Delivery.AppRepoRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager := forgejo.NewRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Delivery.AppRepoRef, "")
|
||||
if _, err := manager.EnsureRepository(repository, "Application source for Maidn CI/CD delivery"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := manager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil {
|
||||
return fmt.Errorf("protect Forgejo production branch: %w", err)
|
||||
}
|
||||
if err := manager.PushRef(sourceDir, resolved.Delivery.AppRepoURL, branch, branch); err != nil {
|
||||
return err
|
||||
}
|
||||
changed, err := manager.PublishDeliveryBranch(sourceDir, branch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error {
|
||||
return GenerateAppDelivery(dir, resolved)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
if err := manager.EnsurePullRequest(repository, "feat: migrate delivery to Tekton", deliveryBranch, resolved.Delivery.AppRepoRef); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if open, err := manager.HasOpenPullRequest(repository, deliveryBranch); err != nil {
|
||||
return err
|
||||
} else if open {
|
||||
if err := manager.MergePullRequest(repository, deliveryBranch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
registrationBranch := "maidn/register-" + resolved.Delivery.AppName
|
||||
if _, err := manager.PublishRepositoryPullRequest(resolved.Flux.RepoName, "feat: register "+resolved.Delivery.AppName+" delivery", registrationBranch, resolved.Flux.Branch, func(dir string) error {
|
||||
return RegisterAppInCluster(dir, resolved)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("register app in cluster repository: %w", err)
|
||||
}
|
||||
secrets, err := ReadOperationalSecrets(resolved.SOPS.OperationalSecretsPath, resolved.SOPS.AgeKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read encrypted webhook authorization: %w", err)
|
||||
}
|
||||
authorization := secrets["cicd/forgejo-webhook"]["authorization"]
|
||||
if authorization == "" {
|
||||
return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization")
|
||||
}
|
||||
generatedDir := filepath.Join(resolved.Git.CloneParent, resolved.Talos.RepoDirName, resolved.Talos.GeneratedDir)
|
||||
if err := waitForWebhookTargets(generatedDir, resolved, authorization); err != nil {
|
||||
return err
|
||||
}
|
||||
webhookURL := "https://tekton." + resolved.Flux.ClusterDomain + "/"
|
||||
if err := manager.EnsureWebhook(repository, webhookURL, authorization); err != nil {
|
||||
return fmt.Errorf("register Forgejo webhook: %w", err)
|
||||
}
|
||||
if err := manager.TriggerWebhookTest(repository, webhookURL, resolved.Delivery.AppRepoRef); err != nil {
|
||||
return fmt.Errorf("trigger Forgejo webhook test: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterAppInCluster writes only the managed Flux registration for one app.
|
||||
func RegisterAppInCluster(dir string, cfg config.Config) error {
|
||||
content, err := renderAppRegistration(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tektonDir := filepath.Join(dir, "base", "tekton")
|
||||
rootPath := filepath.Join(tektonDir, "kustomization.yaml")
|
||||
root, err := readRegularFile(rootPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Tekton Kustomization: %w", err)
|
||||
}
|
||||
updatedRoot, err := addKustomizationResource(root, "apps")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Tekton Kustomization: %w", err)
|
||||
}
|
||||
appsDir := filepath.Join(tektonDir, "apps")
|
||||
if info, statErr := os.Lstat(appsDir); statErr == nil && (info.Mode()&os.ModeSymlink != 0 || !info.IsDir()) {
|
||||
return errors.New("Tekton apps path must be a directory")
|
||||
} else if statErr != nil && !os.IsNotExist(statErr) {
|
||||
return statErr
|
||||
}
|
||||
appsPath := filepath.Join(appsDir, "kustomization.yaml")
|
||||
apps, err := os.ReadFile(appsPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
if info, statErr := os.Lstat(appsPath); statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return errors.New("Tekton apps Kustomization must be a regular file")
|
||||
}
|
||||
} else {
|
||||
apps = []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n")
|
||||
}
|
||||
updatedApps, err := addKustomizationResource(apps, cfg.Delivery.AppName+".yaml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Tekton apps Kustomization: %w", err)
|
||||
}
|
||||
registrationPath := filepath.Join(appsDir, cfg.Delivery.AppName+".yaml")
|
||||
if existing, readErr := os.ReadFile(registrationPath); readErr == nil {
|
||||
info, statErr := os.Lstat(registrationPath)
|
||||
if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || !bytes.Equal(existing, content) {
|
||||
return errors.New("Tekton app registration conflicts with unmanaged content")
|
||||
}
|
||||
} else if !os.IsNotExist(readErr) {
|
||||
return readErr
|
||||
}
|
||||
if err := os.MkdirAll(appsDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(rootPath, updatedRoot, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(appsPath, updatedApps, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(registrationPath, content, 0644)
|
||||
}
|
||||
|
||||
func renderAppRegistration(cfg config.Config) ([]byte, error) {
|
||||
if err := config.ValidateDelivery(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
branch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(fmt.Sprintf(`apiVersion: source.toolkit.fluxcd.io/v1
|
||||
kind: GitRepository
|
||||
metadata:
|
||||
name: %s
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 1m
|
||||
url: %s
|
||||
secretRef:
|
||||
name: forgejo-flux-credentials
|
||||
ref:
|
||||
branch: %s
|
||||
---
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: %s
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 5m
|
||||
path: ./.tekton
|
||||
prune: true
|
||||
wait: true
|
||||
targetNamespace: tekton-pipelines
|
||||
dependsOn:
|
||||
- name: tekton-catalog
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: %s
|
||||
`, cfg.Delivery.AppName, cfg.Delivery.AppRepoURL, branch, cfg.Delivery.AppName, cfg.Delivery.AppName)), nil
|
||||
}
|
||||
|
||||
func readRegularFile(path string) ([]byte, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return nil, errors.New("must be a regular file")
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func addKustomizationResource(content []byte, resource string) ([]byte, error) {
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(content))
|
||||
var document yaml.Node
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := decoder.Decode(&yaml.Node{}); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("must contain one YAML document")
|
||||
}
|
||||
if document.Kind != yaml.DocumentNode || len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
|
||||
return nil, errors.New("must be a Kustomization mapping")
|
||||
}
|
||||
root := document.Content[0]
|
||||
apiVersion, err := yamlMappingValue(root, "apiVersion")
|
||||
if err != nil || apiVersion == nil || apiVersion.Value != "kustomize.config.k8s.io/v1beta1" {
|
||||
return nil, errors.New("must be a Kustomization")
|
||||
}
|
||||
kind, err := yamlMappingValue(root, "kind")
|
||||
if err != nil || kind == nil || kind.Value != "Kustomization" {
|
||||
return nil, errors.New("must be a Kustomization")
|
||||
}
|
||||
resources, err := yamlMappingValue(root, "resources")
|
||||
if err != nil || resources == nil {
|
||||
return nil, errors.New("must define a resources list")
|
||||
}
|
||||
if resources.Kind == yaml.ScalarNode && resources.Tag == "!!null" {
|
||||
resources.Kind, resources.Tag, resources.Value = yaml.SequenceNode, "!!seq", ""
|
||||
}
|
||||
if resources.Kind != yaml.SequenceNode {
|
||||
return nil, errors.New("must define a resources list")
|
||||
}
|
||||
for _, item := range resources.Content {
|
||||
if item.Kind != yaml.ScalarNode || item.Value == "" {
|
||||
return nil, errors.New("resources must contain non-empty scalar values")
|
||||
}
|
||||
if item.Value == resource {
|
||||
var rendered bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&rendered)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(&document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rendered.Bytes(), nil
|
||||
}
|
||||
}
|
||||
resources.Content = append(resources.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: resource})
|
||||
var rendered bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&rendered)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(&document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rendered.Bytes(), nil
|
||||
}
|
||||
78
internal/bootstrap/onboard_test.go
Normal file
78
internal/bootstrap/onboard_test.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||
)
|
||||
|
||||
func onboardingConfig() config.Config {
|
||||
return config.Config{
|
||||
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "test-org-2"},
|
||||
Flux: config.FluxConfig{Branch: "main", RepoName: "cluster", ManifestsRepo: "manifests", ClusterDomain: "example.test"},
|
||||
Delivery: config.DeliveryConfig{
|
||||
AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org-2/web-ui.git", AppRepoRef: "main", ProductionBranch: "production",
|
||||
ImageRepository: "registry.example.test/test-org-2/web-ui", BuildOutputDirectory: "dist", BuildConfiguration: "production",
|
||||
WebhookHostname: "tekton.example.test", WebhookPath: "/",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAppDeliveryReplacesOnlyKnownGeneratedFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tektonDir := filepath.Join(dir, ".tekton")
|
||||
if err := os.Mkdir(tektonDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"kustomization.yaml", "pipeline.yaml"} {
|
||||
if err := os.WriteFile(filepath.Join(tektonDir, name), []byte("old generated content\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := GenerateAppDelivery(dir, onboardingConfig()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pipeline, err := os.ReadFile(filepath.Join(tektonDir, "pipeline.yaml"))
|
||||
if err != nil || !strings.Contains(string(pipeline), "https://git.example.test/test-org-2/web-ui.git") {
|
||||
t.Fatalf("target-specific pipeline = %q, %v", pipeline, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tektonDir, "custom.yaml"), []byte("custom: true\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := GenerateAppDelivery(dir, onboardingConfig()); err == nil || !strings.Contains(err.Error(), "unmanaged") {
|
||||
t.Fatalf("custom .tekton content was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tektonDir := filepath.Join(dir, "base", "tekton")
|
||||
if err := os.MkdirAll(tektonDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tektonDir, "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RegisterAppInCluster(dir, onboardingConfig()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registration, err := os.ReadFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"))
|
||||
if err != nil || !strings.Contains(string(registration), "branch: maidn/delivery-web-ui") || !strings.Contains(string(registration), "secretRef:\n name: forgejo-flux-credentials") || !strings.Contains(string(registration), "dependsOn:\n - name: tekton-catalog") || !strings.Contains(string(registration), "path: ./.tekton") {
|
||||
t.Fatalf("registration = %q, %v", registration, err)
|
||||
}
|
||||
for path, resource := range map[string]string{filepath.Join(tektonDir, "kustomization.yaml"): "apps", filepath.Join(tektonDir, "apps", "kustomization.yaml"): "web-ui.yaml"} {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil || !strings.Contains(string(content), resource) {
|
||||
t.Fatalf("Kustomization %s does not include %s: %q, %v", path, resource, content, err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"), []byte("custom: true\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RegisterAppInCluster(dir, onboardingConfig()); err == nil || !strings.Contains(err.Error(), "conflicts") {
|
||||
t.Fatalf("unmanaged app registration was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -148,25 +148,44 @@ func ResolveFreshBootstrap(cfg Config, organization string, enableDelivery bool)
|
|||
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 cfg.Git.Owner == "" || cfg.Git.Username == "" || cfg.Git.Token == "" || cfg.Flux.RepoName == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.Branch == "" || cfg.Flux.ClusterDomain == "" {
|
||||
return cfg, errors.New("git owner, username, token, flux repoName, manifestsRepo, branch, and clusterDomain are required for app onboarding")
|
||||
}
|
||||
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
return ResolveDelivery(cfg)
|
||||
resolved, err := ResolveDelivery(cfg)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
owner, _, err := deliveryRepositoryOwner(resolved.Delivery.AppRepoURL)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if owner != resolved.Git.Owner {
|
||||
return cfg, errors.New("delivery appRepoUrl owner must match git owner for app onboarding")
|
||||
}
|
||||
if resolved.Delivery.ProductionBranch != "production" {
|
||||
return cfg, errors.New("delivery productionBranch must be literal production for app onboarding")
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func deliveryRepositoryOwner(value string) (string, string, error) {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return "", "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
|
||||
}
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
if len(parts) != 2 || !strings.HasSuffix(parts[1], ".git") || !validRepositoryPart(parts[0]) || !validRepositoryPart(strings.TrimSuffix(parts[1], ".git")) {
|
||||
return "", "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
|
||||
}
|
||||
return parts[0], strings.TrimSuffix(parts[1], ".git"), nil
|
||||
}
|
||||
|
||||
func validRepositoryPart(value string) bool {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,26 @@ func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolveAppOnboardingRequiresTargetOwner(t *testing.T) {
|
||||
cfg := validConfig(t)
|
||||
cfg.Git.Owner = "test-org-2"
|
||||
if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "owner must match") {
|
||||
t.Fatalf("ResolveAppOnboarding() accepted a source-owner target: %v", err)
|
||||
}
|
||||
cfg.Delivery.AppRepoURL = "https://git.example.test/test-org-2/web-ui.git"
|
||||
if resolved, err := ResolveAppOnboarding(cfg); err != nil || resolved.Delivery.WebhookURL() != "https://tekton.example.test/" {
|
||||
t.Fatalf("ResolveAppOnboarding() = %#v, %v", resolved.Delivery, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppOnboardingRequiresProductionBranch(t *testing.T) {
|
||||
cfg := validConfig(t)
|
||||
cfg.Delivery.ProductionBranch = "release"
|
||||
if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "literal production") {
|
||||
t.Fatalf("ResolveAppOnboarding() accepted nonstandard production branch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsCredentialBearingDeliveryURLs(t *testing.T) {
|
||||
cfg := validConfig(t)
|
||||
cfg.Delivery.AppRepoURL = "https://reader:token@git.example.test/test-org/web-ui.git"
|
||||
|
|
|
|||
|
|
@ -405,6 +405,51 @@ func (rm *RepoManager) CreatePullRequest(repo, title, head, base string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// EnsurePullRequest creates one pull request or returns the one already open
|
||||
// for the exact head branch. It refuses duplicate or otherwise ambiguous state.
|
||||
func (rm *RepoManager) EnsurePullRequest(repo, title, head, base string) error {
|
||||
if head == "" || base == "" || head == base {
|
||||
return fmt.Errorf("Forgejo pull request head and base must be different non-empty branches")
|
||||
}
|
||||
open, err := rm.HasOpenPullRequest(repo, head)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if open {
|
||||
return nil
|
||||
}
|
||||
body, err := json.Marshal(pullRequestRequest{Title: title, Head: head, Base: base})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", rm.BaseURL, rm.Owner, repo), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != http.StatusCreated {
|
||||
return fmt.Errorf("unexpected Forgejo pull request status %d", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasOpenPullRequest reports whether exactly one pull request is open for head.
|
||||
func (rm *RepoManager) HasOpenPullRequest(repo, head string) (bool, error) {
|
||||
values := url.Values{"state": {"open"}, "head": {head}}
|
||||
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode())
|
||||
var pullRequests []pullRequest
|
||||
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &pullRequests)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return false, fmt.Errorf("unexpected Forgejo pull request lookup status %d", status)
|
||||
}
|
||||
if len(pullRequests) > 1 {
|
||||
return false, fmt.Errorf("multiple open Forgejo pull requests exist for branch %q", head)
|
||||
}
|
||||
return len(pullRequests) == 1, nil
|
||||
}
|
||||
|
||||
func (rm *RepoManager) MergePullRequest(repo, head string) error {
|
||||
values := url.Values{"state": {"open"}, "head": {head}}
|
||||
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode())
|
||||
|
|
@ -453,64 +498,116 @@ func DeliveryBranch(appName, baseBranch string) (string, error) {
|
|||
}
|
||||
|
||||
// PublishDeliveryBranch generates and commits delivery content in a temporary clone.
|
||||
func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, deliveryBranch string, generate func(string) error) error {
|
||||
func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, deliveryBranch string, generate func(string) error) (bool, error) {
|
||||
if sourceDir == "" || sourceBranch == "" || deliveryBranch == "" || deliveryBranch == rm.Branch {
|
||||
return fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch")
|
||||
return false, fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch")
|
||||
}
|
||||
temporary, err := os.MkdirTemp("", "maidn-delivery-*")
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
defer os.RemoveAll(temporary)
|
||||
if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
cleanupAskPass, environment, err := rm.gitEnvironment()
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
defer cleanupAskPass()
|
||||
if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(temporary, ".tekton")); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if err := generate(temporary); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if err := runGit(temporary, environment, "add", ".tekton"); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
changed, err := gitDiffQuiet(temporary, environment, "--cached")
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if changed {
|
||||
for _, args := range [][]string{{"config", "user.name", "Maidn"}, {"config", "user.email", "maidn@free-maidn.com"}, {"commit", "-m", "feat: add Maidn delivery pipeline"}} {
|
||||
if err := runGit(temporary, environment, args...); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if hasBranch {
|
||||
if err := runGit(temporary, environment, "fetch", repoURL, "refs/heads/"+deliveryBranch); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
different, err := gitDiffQuiet(temporary, environment, "HEAD", "FETCH_HEAD")
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if !different {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
return fmt.Errorf("dedicated delivery branch %q differs from generated content; refusing to overwrite it", deliveryBranch)
|
||||
return false, fmt.Errorf("dedicated delivery branch %q differs from generated content; refusing to overwrite it", deliveryBranch)
|
||||
}
|
||||
return rm.PushBranch(temporary, repoURL, deliveryBranch)
|
||||
if err := rm.PushBranch(temporary, repoURL, deliveryBranch); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// PublishRepositoryPullRequest applies a managed change on a dedicated branch.
|
||||
// An existing branch is accepted only when it has exactly one open pull request.
|
||||
func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base string, change func(string) error) (bool, error) {
|
||||
if repo == "" || branch == "" || base == "" || branch == base {
|
||||
return false, errors.New("repository pull request requires distinct non-empty branches")
|
||||
}
|
||||
repoURL := CloneURL(rm.BaseURL, rm.Owner, repo)
|
||||
hasBranch, err := rm.HasRemoteBranch(repoURL, branch)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
temporary, err := os.MkdirTemp("", "maidn-registration-*")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer os.RemoveAll(temporary)
|
||||
cleanupAskPass, environment, err := rm.gitEnvironment()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer cleanupAskPass()
|
||||
checkout := base
|
||||
if hasBranch {
|
||||
checkout = branch
|
||||
}
|
||||
if err := runGit("", environment, "clone", "--branch", checkout, repoURL, temporary); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !hasBranch {
|
||||
if err := runGit(temporary, environment, "checkout", "-B", branch, "origin/"+base); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if err := change(temporary); err != nil {
|
||||
return false, err
|
||||
}
|
||||
changed, err := commitAndPush(temporary, repo, branch, environment)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !changed && !hasBranch {
|
||||
return false, nil
|
||||
}
|
||||
if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := rm.MergePullRequest(repo, branch); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func gitDiffQuiet(dir string, environment []string, args ...string) (bool, error) {
|
||||
|
|
@ -648,6 +745,40 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err
|
|||
return nil
|
||||
}
|
||||
|
||||
// TriggerWebhookTest asks Forgejo to deliver a test push for the managed hook.
|
||||
func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error {
|
||||
if repo == "" || webhookURL == "" || branch == "" {
|
||||
return errors.New("Forgejo repository, webhook URL, and branch are required")
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", rm.BaseURL, rm.Owner, repo)
|
||||
var hooks []hook
|
||||
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &hooks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("unexpected Forgejo webhook lookup status %d", status)
|
||||
}
|
||||
var matching []hook
|
||||
for _, candidate := range hooks {
|
||||
if candidate.URL == webhookURL {
|
||||
matching = append(matching, candidate)
|
||||
}
|
||||
}
|
||||
if len(matching) != 1 {
|
||||
return fmt.Errorf("expected one Forgejo webhook for URL %q", webhookURL)
|
||||
}
|
||||
values := url.Values{"ref": {branch}}
|
||||
status, err = rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/%d/tests?%s", endpoint, matching[0].ID, values.Encode()), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != http.StatusNoContent {
|
||||
return fmt.Errorf("unexpected Forgejo webhook test status %d", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureProtectedBranch disables direct pushes to the configured production branch.
|
||||
func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error {
|
||||
if repo == "" || branch == "" {
|
||||
|
|
|
|||
|
|
@ -238,6 +238,28 @@ func TestEnsureWebhookCreatesMissingWebhook(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTriggerWebhookTestUsesManagedHookAndBaseBranch(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/owner/app/hooks":
|
||||
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, URL: "https://tekton.example.test/"}})
|
||||
case http.MethodPost + " /api/v1/repos/owner/app/hooks/7/tests":
|
||||
if request.URL.Query().Get("ref") != "main" || request.Header.Get("Authorization") == "" {
|
||||
t.Fatal("webhook test did not use the managed hook and base branch")
|
||||
}
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
t.Fatalf("unexpected Forgejo request %s %s", request.Method, request.URL.String())
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
manager := NewRepoManager(server.URL, "test-token", "owner", "user", "", "", "main", "")
|
||||
manager.HTTPClient = server.Client()
|
||||
if err := manager.TriggerWebhookTest("app", "https://tekton.example.test/", "main"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureProtectedBranchCreatesDirectPushProtection(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/api/v1/repos/owner/app/branch_protections" {
|
||||
|
|
@ -354,6 +376,34 @@ func TestMergePullRequest(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEnsurePullRequestChecksExactOpenBranchBeforeCreating(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/repos/owner/app/pulls" || request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/delivery-app" {
|
||||
t.Fatalf("unexpected pull request lookup: %s", request.URL.String())
|
||||
}
|
||||
_, _ = writer.Write([]byte("[]"))
|
||||
case http.MethodPost:
|
||||
var body pullRequestRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Head != "maidn/delivery-app" || body.Base != "main" {
|
||||
t.Fatalf("unexpected pull request create: %#v, %v", body, err)
|
||||
}
|
||||
writer.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
t.Fatalf("unexpected request method %s", request.Method)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
manager := NewRepoManager(server.URL, "test-token", "owner", "bot", "", "", "main", "")
|
||||
manager.HTTPClient = server.Client()
|
||||
if err := manager.EnsurePullRequest("app", "delivery", "maidn/delivery-app", "main"); err != nil || requests != 2 {
|
||||
t.Fatalf("EnsurePullRequest() = %v, requests = %d", err, requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoExistsReturnsFalseOnNotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
|
|
|
|||
Loading…
Reference in a new issue