feat: add platform app onboarding
This commit is contained in:
parent
43695c7b29
commit
112fc5376b
|
|
@ -23,15 +23,11 @@ var bootstrapInitializeOpenBao bool
|
||||||
var bootstrapCreateForgejoRegistryToken bool
|
var bootstrapCreateForgejoRegistryToken bool
|
||||||
var bootstrapRegisterWebhook bool
|
var bootstrapRegisterWebhook bool
|
||||||
var bootstrapRotateWebhookAuthorization bool
|
var bootstrapRotateWebhookAuthorization bool
|
||||||
var bootstrapPublishAppFrom string
|
|
||||||
var bootstrapMergeBootstrapPR bool
|
var bootstrapMergeBootstrapPR bool
|
||||||
var bootstrapManageNetworkBridges bool
|
var bootstrapManageNetworkBridges bool
|
||||||
var bootstrapEnableDelivery bool
|
var bootstrapEnableDelivery bool
|
||||||
|
|
||||||
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
|
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
|
||||||
var loadPublishAppConfig = config.Load
|
|
||||||
var ensurePublishAppCheckoutClean = forgejo.EnsureCleanCheckout
|
|
||||||
|
|
||||||
var bootstrapCmd = &cobra.Command{
|
var bootstrapCmd = &cobra.Command{
|
||||||
Use: "bootstrap",
|
Use: "bootstrap",
|
||||||
Short: "Bootstrap Talos and Flux from config or an interactive wizard.",
|
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(&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(&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().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(&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(&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")
|
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
|
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 bootstrapConfigPath != "" {
|
||||||
if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery || bootstrapManageNetworkBridges {
|
if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery || bootstrapManageNetworkBridges {
|
||||||
cfg, err = config.LoadRaw(bootstrapConfigPath)
|
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) {
|
func TestSeedForgejoOperationalCredentialsUsesEncryptedUpsertBoundary(t *testing.T) {
|
||||||
original := upsertOperationalSecret
|
original := upsertOperationalSecret
|
||||||
t.Cleanup(func() { upsertOperationalSecret = original })
|
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/bootstrap"
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -17,10 +16,7 @@ var loadFreshConfig = config.Load
|
||||||
var loadAppOnboardConfig = config.LoadRaw
|
var loadAppOnboardConfig = config.LoadRaw
|
||||||
var runFreshOrganization = bootstrap.RunFreshOrganization
|
var runFreshOrganization = bootstrap.RunFreshOrganization
|
||||||
var resolveAppOnboarding = config.ResolveAppOnboarding
|
var resolveAppOnboarding = config.ResolveAppOnboarding
|
||||||
var ensureOnboardCheckoutClean = forgejo.EnsureCleanCheckout
|
var onboardApp = bootstrap.OnboardApp
|
||||||
var onboardCheckoutOrigin = forgejo.CheckoutOrigin
|
|
||||||
var onboardCheckoutBranch = forgejo.CurrentBranch
|
|
||||||
var generateAppDelivery = bootstrap.GenerateAppDelivery
|
|
||||||
|
|
||||||
var bootstrapInitCmd = &cobra.Command{
|
var bootstrapInitCmd = &cobra.Command{
|
||||||
Use: "init",
|
Use: "init",
|
||||||
|
|
@ -44,7 +40,7 @@ func init() {
|
||||||
bootstrapInitCmd.Flags().StringVar(&freshConfigPath, "config", "", "Path to private bootstrap config YAML")
|
bootstrapInitCmd.Flags().StringVar(&freshConfigPath, "config", "", "Path to private bootstrap config YAML")
|
||||||
bootstrapInitCmd.Flags().StringVar(&freshOrganization, "organization", "", "New Forgejo organization name")
|
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(&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().StringVar(&freshMode, "mode", string(bootstrap.Reconcile), "Lifecycle mode: reconcile or rebuild")
|
||||||
bootstrapInitCmd.Flags().BoolVar(&freshYes, "yes", false, "Confirm destructive rebuild")
|
bootstrapInitCmd.Flags().BoolVar(&freshYes, "yes", false, "Confirm destructive rebuild")
|
||||||
_ = bootstrapInitCmd.MarkFlagRequired("config")
|
_ = bootstrapInitCmd.MarkFlagRequired("config")
|
||||||
|
|
@ -82,22 +78,5 @@ func runAppOnboard(_ *cobra.Command, _ []string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := ensureOnboardCheckoutClean(onboardFrom); err != nil {
|
return onboardApp(cfg, onboardFrom)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,19 +62,17 @@ func TestBootstrapInitAppliesFluxDefaultsBeforeFreshValidation(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) {
|
func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
|
||||||
originalConfig, originalResolve, originalClean := loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
|
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
|
||||||
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
loadAppOnboardConfig = originalConfig
|
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
|
||||||
resolveAppOnboarding = originalResolve
|
|
||||||
ensureOnboardCheckoutClean = originalClean
|
|
||||||
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
||||||
})
|
})
|
||||||
loadAppOnboardConfig = func(string) (config.Config, error) { return config.Config{}, nil }
|
loadAppOnboardConfig = func(string) (config.Config, error) { return config.Config{}, nil }
|
||||||
resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") }
|
resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") }
|
||||||
ensureOnboardCheckoutClean = func(string) error {
|
onboardApp = func(config.Config, string) error {
|
||||||
t.Fatal("onboarding inspected checkout before validating config")
|
t.Fatal("onboarding reached external work before validating config")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
|
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
|
||||||
|
|
@ -83,36 +81,26 @@ func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppOnboardScaffoldsOnlyTheValidatedCheckout(t *testing.T) {
|
func TestAppOnboardPassesOnlyValidatedConfigAndCheckout(t *testing.T) {
|
||||||
originalConfig, originalResolve, originalClean := loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
|
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
|
||||||
originalOrigin, originalBranch, originalGenerate := onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery
|
|
||||||
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean = originalConfig, originalResolve, originalClean
|
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
|
||||||
onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery = originalOrigin, originalBranch, originalGenerate
|
|
||||||
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
||||||
})
|
})
|
||||||
cfg := config.Config{Delivery: config.DeliveryConfig{AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main"}}
|
cfg := config.Config{Delivery: config.DeliveryConfig{AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main"}}
|
||||||
loadAppOnboardConfig = func(string) (config.Config, error) { return cfg, nil }
|
loadAppOnboardConfig = func(string) (config.Config, error) { return cfg, nil }
|
||||||
resolveAppOnboarding = func(config.Config) (config.Config, error) { return cfg, nil }
|
resolveAppOnboarding = func(config.Config) (config.Config, error) { return cfg, nil }
|
||||||
ensureOnboardCheckoutClean = func(path string) error {
|
calls := 0
|
||||||
if path != "app-checkout" {
|
onboardApp = func(got config.Config, checkout string) error {
|
||||||
t.Fatal("onboarding checked the wrong checkout")
|
if checkout != "app-checkout" || got.Delivery.AppRepoURL != cfg.Delivery.AppRepoURL {
|
||||||
|
t.Fatal("onboarding used the wrong checkout or config")
|
||||||
}
|
}
|
||||||
return nil
|
calls++
|
||||||
}
|
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
|
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
|
||||||
if err := runAppOnboard(nil, nil); err != nil || generations != 1 {
|
if err := runAppOnboard(nil, nil); err != nil || calls != 1 {
|
||||||
t.Fatalf("runAppOnboard() = %v, generations = %d", err, generations)
|
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
|
fresh `cloneParent` when it is configured separately) and keep the prior
|
||||||
secret-bearing workspace intact for recovery.
|
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
|
## Rebuild
|
||||||
|
|
||||||
Use only when an authorized recovery requires recreating the Talos VM:
|
Use only when an authorized recovery requires recreating the Talos VM:
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,6 @@ func (r Runner) Run() error {
|
||||||
}
|
}
|
||||||
r.Config = resolvedDelivery
|
r.Config = resolvedDelivery
|
||||||
}
|
}
|
||||||
deliveryConfigured := r.Config.Delivery.Configured()
|
|
||||||
if err := preflight(r.Config); err != nil {
|
if err := preflight(r.Config); err != nil {
|
||||||
return fmt.Errorf("preflight: %w", err)
|
return fmt.Errorf("preflight: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -190,12 +189,10 @@ func (r Runner) Run() error {
|
||||||
if err := EnsureTemplateRevisions(r.Config); err != nil {
|
if err := EnsureTemplateRevisions(r.Config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !r.SkipDeliveryScaffolding {
|
|
||||||
catalogManager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, "", "", r.Config.Templates.TektonCatalogRepoRef, "")
|
catalogManager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, "", "", r.Config.Templates.TektonCatalogRepoRef, "")
|
||||||
if _, err := catalogManager.EnsureRepositoryCopy(r.Config.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", r.Config.Templates.TektonCatalogRepoURL); err != nil {
|
if _, err := catalogManager.EnsureRepositoryCopy(r.Config.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", r.Config.Templates.TektonCatalogRepoURL); err != nil {
|
||||||
return fmt.Errorf("initialize Tekton catalog repository: %w", err)
|
return fmt.Errorf("initialize Tekton catalog repository: %w", err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if err := config.WriteRedacted(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil {
|
if err := config.WriteRedacted(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -213,23 +210,16 @@ func (r Runner) Run() error {
|
||||||
},
|
},
|
||||||
func(dir string) error {
|
func(dir string) error {
|
||||||
clusterDir := filepath.Join(dir, strings.TrimPrefix(r.Config.Flux.ClusterPath, "./"))
|
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 {
|
if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else if err := copyDirExcept(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false, deliveryTemplateBaseComponents); err != nil {
|
if err := copyTemplateBaseComponents(cicdTemplateDir, dir, true); err != nil {
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := copyTemplateBaseComponents(cicdTemplateDir, dir, deliveryConfigured); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := copyClusterTemplate(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir); err != nil {
|
if err := copyClusterTemplate(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, name := range []string{"external-secrets", "cnpg", "cloudflare-tunnel", "external-dns", "monitoring", "tekton"} {
|
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"))
|
content, err := os.ReadFile(filepath.Join(cicdTemplateDir, "clusters", "template", name+"-kustomization.yaml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -248,11 +238,9 @@ func (r Runner) Run() error {
|
||||||
if err := copyAndRenderCiliumBases(cicdTemplateDir, dir, r.Config); err != nil {
|
if err := copyAndRenderCiliumBases(cicdTemplateDir, dir, r.Config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if deliveryConfigured {
|
if err := copyAndRenderPlatformDeliveryBases(cicdTemplateDir, dir, r.Config); err != nil {
|
||||||
if err := copyAndRenderDeliveryBases(cicdTemplateDir, dir, r.Config); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if err := writeDemocraticCSISecret(filepath.Join(dir, "base", "democratic-csi", "secret.sops.yaml"), r.Config.DemocraticCSI, r.Config.SOPS.AgeKeyPath); err != nil {
|
if err := writeDemocraticCSISecret(filepath.Join(dir, "base", "democratic-csi", "secret.sops.yaml"), r.Config.DemocraticCSI, r.Config.SOPS.AgeKeyPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -263,7 +251,7 @@ func (r Runner) Run() error {
|
||||||
if err := ensureOpenBaoUnsealKustomization(filepath.Join(openbaoDir, "kustomization.yaml")); err != nil {
|
if err := ensureOpenBaoUnsealKustomization(filepath.Join(openbaoDir, "kustomization.yaml")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := ensureClusterKustomizations(clusterDir, deliveryConfigured); err != nil {
|
if err := ensureClusterKustomizations(clusterDir, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return ghrepo.WriteFluxStructure(dir, r.Config.Flux.RepoName, r.Config.Flux.ClusterPath, fluxConfig)
|
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 {
|
if err := configureFluxSOPS(generatedDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return r.reconcileWebhook(generatedDir)
|
return r.completeFluxBootstrap(generatedDir)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -386,7 +374,7 @@ func (r Runner) reconcileCloudflareTunnel() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) reconcileWebhook(generatedDir string) 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("initialize OpenBao: %w", err)
|
return fmt.Errorf("initialize OpenBao: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -403,6 +391,18 @@ func (r Runner) reconcileWebhook(generatedDir string) error {
|
||||||
return nil
|
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 {
|
func renderCiliumConfig(dir string, cfg config.Config) error {
|
||||||
replacements := strings.NewReplacer(
|
replacements := strings.NewReplacer(
|
||||||
"${CILIUM_K8S_SERVICE_HOST}", cfg.Talos.KubeconfigEndpoint,
|
"${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(
|
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,
|
"${FORGEJO_BASE_URL}", cfg.Git.BaseURL,
|
||||||
"${CLUSTER_DOMAIN}", cfg.Flux.ClusterDomain,
|
"${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_URL}", forgejo.CloneURL(cfg.Git.BaseURL, cfg.Git.Owner, cfg.Flux.TektonCatalogRepo),
|
||||||
"${TEKTON_CATALOG_REPO_REF}", cfg.Templates.TektonCatalogRepoRef,
|
"${TEKTON_CATALOG_REPO_REF}", cfg.Templates.TektonCatalogRepoRef,
|
||||||
"${WEBHOOK_HOSTNAME}", cfg.Delivery.WebhookHostname,
|
"${WEBHOOK_HOSTNAME}", "tekton."+cfg.Flux.ClusterDomain,
|
||||||
"${WEBHOOK_PATH}", cfg.Delivery.WebhookPath,
|
"${WEBHOOK_PATH}", "/",
|
||||||
)
|
)
|
||||||
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil || info.IsDir() {
|
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 {
|
func copyAndRenderPlatformDeliveryBases(templateDir, repoDir string, cfg config.Config) error {
|
||||||
if err := config.ValidateDelivery(cfg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
bases := []string{"gateway", "tekton", "tekton-triggers"}
|
bases := []string{"gateway", "tekton", "tekton-triggers"}
|
||||||
for _, base := range bases {
|
for _, base := range bases {
|
||||||
baseDir := filepath.Join(repoDir, "base", base)
|
baseDir := filepath.Join(repoDir, "base", base)
|
||||||
|
|
@ -461,15 +455,11 @@ func copyAndRenderDeliveryBases(templateDir, repoDir string, cfg config.Config)
|
||||||
}
|
}
|
||||||
for _, base := range bases {
|
for _, base := range bases {
|
||||||
baseDir := filepath.Join(repoDir, "base", base)
|
baseDir := filepath.Join(repoDir, "base", base)
|
||||||
if err := renderDeliveryConfig(baseDir, cfg); err != nil {
|
if err := renderPlatformDeliveryConfig(baseDir, cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dir := filepath.Join(repoDir, "base", "tekton")
|
return writePreviewDeliveryConfig(filepath.Join(repoDir, "base", "tekton"), cfg)
|
||||||
if err := writePreviewDeliveryConfig(dir, cfg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return removeDuplicateAppDeliverySource(dir, cfg.Delivery.AppName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writePreviewDeliveryConfig(dir string, cfg config.Config) error {
|
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) {
|
if len(entries) != len(files) {
|
||||||
return errors.New("app delivery .tekton contains unmanaged files")
|
return errors.New("app delivery .tekton contains unmanaged files")
|
||||||
}
|
}
|
||||||
for name, want := range files {
|
for name, content := range files {
|
||||||
got, err := os.ReadFile(filepath.Join(target, name))
|
file := filepath.Join(target, name)
|
||||||
if err != nil || !bytes.Equal(got, want) {
|
fileInfo, err := os.Lstat(file)
|
||||||
return errors.New("app delivery .tekton differs from Maidn generated content")
|
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
|
return nil
|
||||||
|
|
@ -1031,7 +1025,7 @@ func waitForWebhookTargets(dir string, cfg config.Config, authorization string)
|
||||||
if err := waitForWebhookAuthorization(dir, authorization); err != nil {
|
if err := waitForWebhookAuthorization(dir, authorization); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
resources := []string{"deployment/el-" + cfg.Delivery.AppName, "pipeline/" + cfg.Delivery.AppName}
|
resources := []string{"pipeline/" + cfg.Delivery.AppName}
|
||||||
for _, resource := range resources {
|
for _, resource := range resources {
|
||||||
deadline := time.Now().Add(webhookTargetTimeout)
|
deadline := time.Now().Add(webhookTargetTimeout)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
|
|
@ -1078,9 +1072,6 @@ func ensureClusterKustomizations(clusterDir string, includeDelivery bool) error
|
||||||
updated := string(content)
|
updated := string(content)
|
||||||
updated = strings.ReplaceAll(updated, " - bootstrap-secrets.sops.yaml\n", "")
|
updated = strings.ReplaceAll(updated, " - bootstrap-secrets.sops.yaml\n", "")
|
||||||
for _, resource := range requiredClusterKustomizations {
|
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) {
|
if !strings.Contains(updated, resource) {
|
||||||
updated += " - " + resource + "\n"
|
updated += " - " + resource + "\n"
|
||||||
}
|
}
|
||||||
|
|
@ -1093,9 +1084,6 @@ func ensureClusterKustomizations(clusterDir string, includeDelivery bool) error
|
||||||
|
|
||||||
func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery bool) error {
|
func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery bool) error {
|
||||||
for _, component := range templateBaseComponents {
|
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 {
|
if err := copyDirExcept(filepath.Join(templateDir, "base", component), filepath.Join(repoDir, "base", component), true, generatedTemplateFiles[component]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,22 +52,22 @@ func TestCiliumChartVersion(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderDeliveryConfig(t *testing.T) {
|
func TestRenderPlatformDeliveryConfig(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "webhook.yaml")
|
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)
|
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"}}
|
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 := renderDeliveryConfig(dir, cfg); err != nil {
|
if err := renderPlatformDeliveryConfig(dir, cfg); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
content, err := os.ReadFile(path)
|
content, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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") {
|
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("delivery configuration was not rendered: %s", content)
|
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()
|
templateDir := t.TempDir()
|
||||||
repoDir := t.TempDir()
|
repoDir := t.TempDir()
|
||||||
files := []struct {
|
files := []struct {
|
||||||
base, name, template, want string
|
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", "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 {
|
for _, file := range files {
|
||||||
templatePath := filepath.Join(templateDir, "base", file.base, file.name)
|
templatePath := filepath.Join(templateDir, "base", file.base, file.name)
|
||||||
|
|
@ -191,11 +191,10 @@ func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing.
|
||||||
|
|
||||||
cfg := config.Config{
|
cfg := config.Config{
|
||||||
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"},
|
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"},
|
||||||
Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"},
|
Flux: config.FluxConfig{ClusterDomain: "example.test", 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"},
|
|
||||||
Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"},
|
Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"},
|
||||||
}
|
}
|
||||||
if err := copyAndRenderDeliveryBases(templateDir, repoDir, cfg); err != nil {
|
if err := copyAndRenderPlatformDeliveryBases(templateDir, repoDir, cfg); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,7 @@ func PlanFreshOrganization(cfg config.Config, options FreshOrganizationOptions)
|
||||||
"ensure the Forgejo organization",
|
"ensure the Forgejo organization",
|
||||||
"ensure baseline Flux and manifests repositories",
|
"ensure baseline Flux and manifests repositories",
|
||||||
}
|
}
|
||||||
if options.EnableDelivery {
|
|
||||||
phases = append(phases, "initialize the user-managed Tekton catalog repository")
|
phases = append(phases, "initialize the user-managed Tekton catalog repository")
|
||||||
}
|
|
||||||
phases = append(phases, fmt.Sprintf("%s the CI/CD cluster", mode))
|
phases = append(phases, fmt.Sprintf("%s the CI/CD cluster", mode))
|
||||||
return resolved, FreshOrganizationPlan{Phases: phases}, nil
|
return resolved, FreshOrganizationPlan{Phases: phases}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -89,8 +87,7 @@ func freshLifecycleRunner(cfg config.Config, options FreshOrganizationOptions) R
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
ConfirmRebuild: options.ConfirmRebuild,
|
ConfirmRebuild: options.ConfirmRebuild,
|
||||||
EnableDelivery: options.EnableDelivery,
|
SkipDeliveryScaffolding: true,
|
||||||
SkipDeliveryScaffolding: !options.EnableDelivery,
|
|
||||||
AutoMergeBootstrapMigration: true,
|
AutoMergeBootstrapMigration: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,10 +172,11 @@ func TestRunFreshOrganizationStopsBeforeLifecycleOnForgejoFailure(t *testing.T)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) {
|
func TestFreshLifecycleUsesSelectedModeWithoutAppDelivery(t *testing.T) {
|
||||||
originalPreflight := preflight
|
originalPreflight := preflight
|
||||||
t.Cleanup(func() { preflight = originalPreflight })
|
t.Cleanup(func() { preflight = originalPreflight })
|
||||||
cfg := runnerTestConfig(t.TempDir(), "")
|
cfg := runnerTestConfig(t.TempDir(), "")
|
||||||
|
cfg.Delivery = config.DeliveryConfig{}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
options FreshOrganizationOptions
|
options FreshOrganizationOptions
|
||||||
mode Mode
|
mode Mode
|
||||||
|
|
@ -184,12 +185,12 @@ func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) {
|
||||||
{FreshOrganizationOptions{Mode: Rebuild, ConfirmRebuild: true, EnableDelivery: true}, Rebuild},
|
{FreshOrganizationOptions{Mode: Rebuild, ConfirmRebuild: true, EnableDelivery: true}, Rebuild},
|
||||||
} {
|
} {
|
||||||
runner := freshLifecycleRunner(cfg, test.options)
|
runner := freshLifecycleRunner(cfg, test.options)
|
||||||
if runner.Mode != test.mode || runner.ConfirmRebuild != test.options.ConfirmRebuild || runner.EnableDelivery != test.options.EnableDelivery || runner.SkipDeliveryScaffolding == test.options.EnableDelivery || !runner.AutoMergeBootstrapMigration {
|
if runner.Mode != test.mode || runner.ConfirmRebuild != test.options.ConfirmRebuild || runner.EnableDelivery || !runner.SkipDeliveryScaffolding || !runner.AutoMergeBootstrapMigration {
|
||||||
t.Fatalf("fresh lifecycle runner = %#v", runner)
|
t.Fatalf("fresh lifecycle runner = %#v", runner)
|
||||||
}
|
}
|
||||||
preflight = func(got config.Config) error {
|
preflight = func(got config.Config) error {
|
||||||
if got.Delivery.Configured() != test.options.EnableDelivery {
|
if got.Delivery.Configured() {
|
||||||
t.Fatal("fresh lifecycle did not gate delivery scaffolding with --enable-delivery")
|
t.Fatal("fresh lifecycle resolved app delivery during platform initialization")
|
||||||
}
|
}
|
||||||
return errors.New("stop")
|
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) {
|
func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) {
|
||||||
cfg := freshPlanConfig(t)
|
cfg := freshPlanConfig(t)
|
||||||
prepareResumableFreshWorkspace(t, &cfg)
|
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
|
return cfg, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if enableDelivery {
|
|
||||||
var err error
|
|
||||||
cfg, err = ResolveDelivery(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return cfg, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveAppOnboarding validates only the source-owned delivery contract.
|
// ResolveAppOnboarding validates only the source-owned delivery contract.
|
||||||
func ResolveAppOnboarding(cfg Config) (Config, error) {
|
func ResolveAppOnboarding(cfg Config) (Config, error) {
|
||||||
if cfg.Git.Owner == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.Branch == "" {
|
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 and flux manifestsRepo and branch are required for app onboarding")
|
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 {
|
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
|
||||||
return cfg, err
|
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 {
|
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) {
|
func TestValidateRejectsCredentialBearingDeliveryURLs(t *testing.T) {
|
||||||
cfg := validConfig(t)
|
cfg := validConfig(t)
|
||||||
cfg.Delivery.AppRepoURL = "https://reader:token@git.example.test/test-org/web-ui.git"
|
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
|
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 {
|
func (rm *RepoManager) MergePullRequest(repo, head string) error {
|
||||||
values := url.Values{"state": {"open"}, "head": {head}}
|
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())
|
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.
|
// 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 {
|
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-*")
|
temporary, err := os.MkdirTemp("", "maidn-delivery-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(temporary)
|
defer os.RemoveAll(temporary)
|
||||||
if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil {
|
if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
cleanupAskPass, environment, err := rm.gitEnvironment()
|
cleanupAskPass, environment, err := rm.gitEnvironment()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
defer cleanupAskPass()
|
defer cleanupAskPass()
|
||||||
if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil {
|
if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
|
||||||
if err := os.RemoveAll(filepath.Join(temporary, ".tekton")); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if err := generate(temporary); err != nil {
|
if err := generate(temporary); err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
if err := runGit(temporary, environment, "add", ".tekton"); err != nil {
|
if err := runGit(temporary, environment, "add", ".tekton"); err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
changed, err := gitDiffQuiet(temporary, environment, "--cached")
|
changed, err := gitDiffQuiet(temporary, environment, "--cached")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
if changed {
|
if changed {
|
||||||
for _, args := range [][]string{{"config", "user.name", "Maidn"}, {"config", "user.email", "maidn@free-maidn.com"}, {"commit", "-m", "feat: add Maidn delivery pipeline"}} {
|
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 {
|
if err := runGit(temporary, environment, args...); err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch)
|
hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
if hasBranch {
|
if hasBranch {
|
||||||
if err := runGit(temporary, environment, "fetch", repoURL, "refs/heads/"+deliveryBranch); err != nil {
|
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")
|
different, err := gitDiffQuiet(temporary, environment, "HEAD", "FETCH_HEAD")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
if !different {
|
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) {
|
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
|
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.
|
// EnsureProtectedBranch disables direct pushes to the configured production branch.
|
||||||
func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error {
|
func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error {
|
||||||
if repo == "" || branch == "" {
|
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) {
|
func TestEnsureProtectedBranchCreatesDirectPushProtection(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
if request.URL.Path != "/api/v1/repos/owner/app/branch_protections" {
|
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) {
|
func TestRepoExistsReturnsFalseOnNotFound(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
writer.WriteHeader(http.StatusNotFound)
|
writer.WriteHeader(http.StatusNotFound)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue