Compare commits
34 commits
feat/e2e-m
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dbaeab745 | ||
|
|
9908029629 | ||
|
|
0d7f40cc6d | ||
|
|
c0ff0de8f7 | ||
|
|
04c7b67b5d | ||
|
|
0f288f7730 | ||
|
|
8016a1063f | ||
|
|
fd1a88b1b3 | ||
|
|
719abc3702 | ||
|
|
3bcac32072 | ||
|
|
5701e511f8 | ||
|
|
7abb3ba1e4 | ||
|
|
b291f2dbe0 | ||
|
|
90c4c1088a | ||
|
|
aff2ccc3fc | ||
|
|
97e7b59910 | ||
|
|
799a51b485 | ||
|
|
c1305325f3 | ||
|
|
8684ed9427 | ||
|
|
11f9271fd0 | ||
|
|
08851c6324 | ||
|
|
67518977eb | ||
|
|
7df7d03dbe | ||
|
|
025b828958 | ||
|
|
a5e01da4e0 | ||
|
|
11b8ee99a5 | ||
|
|
04ba29f214 | ||
|
|
19ce9ae164 | ||
|
|
8e546e1e50 | ||
|
|
a5f1cbb657 | ||
|
|
10b2362218 | ||
|
|
523148a4be | ||
|
|
3ec1097fa3 | ||
|
|
dd1897d916 |
52
AGENTS.md
Normal file
52
AGENTS.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# MaidnCLI Review And Delivery Contract
|
||||||
|
|
||||||
|
## Branches And Pull Requests
|
||||||
|
|
||||||
|
- Keep each safe, reviewable change on one scoped branch.
|
||||||
|
- Do not leave completed work only in a local worktree or a pushed branch.
|
||||||
|
- Open a Forgejo pull request for every completed branch unless the user explicitly authorizes a direct merge.
|
||||||
|
- Verify the pull request exists, has the intended `head` and `base`, and return its URL.
|
||||||
|
- Do not claim a pull request is open until it is verified through the Forgejo API or UI.
|
||||||
|
- Update an existing pull request when follow-up work belongs to its scope; open another only for an independent change.
|
||||||
|
- Merge only when the user explicitly authorizes the named pull request or branch.
|
||||||
|
|
||||||
|
## E2E Ownership
|
||||||
|
|
||||||
|
- Canonical E2E fixture source repositories are `Maidn/maidn-e2e-*`.
|
||||||
|
- The testing suite, onboarding, and mutation E2E commands must target `Maidn` fixture sources.
|
||||||
|
- `test-org-2` is disposable execution state only. It may host temporary delivery branches and resources, but it is never a fixture source or test-suite owner.
|
||||||
|
|
||||||
|
## Delivery Ownership
|
||||||
|
|
||||||
|
- Application repositories are build inputs only; do not add or update active
|
||||||
|
`.tekton/` or `.maidn/` delivery resources in them.
|
||||||
|
- The cluster repository owns Pipelines, Tasks, triggers, and runtime secret
|
||||||
|
access. The manifests repository owns image tags and promotion state.
|
||||||
|
- Flux chart sources must use only the protected `maidn/platform-<app>` branch,
|
||||||
|
never an application `main` or `maidn/delivery-*` branch.
|
||||||
|
|
||||||
|
## Required Checks
|
||||||
|
|
||||||
|
- Before each commit: inspect `git status --short`, `git diff --check`, and `git log --oneline -10`.
|
||||||
|
- Before review: run the applicable focused and repository checks, then record the commands and results.
|
||||||
|
- Never commit generated workspaces, `.password`, SOPS material, kubeconfigs, Terraform state, recovery material, or token files.
|
||||||
|
|
||||||
|
## Review Handoff Format
|
||||||
|
|
||||||
|
Use this exact format whenever user review or merge is required:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Review required
|
||||||
|
PR: <URL>
|
||||||
|
Branch: <name>
|
||||||
|
Purpose: <one sentence>
|
||||||
|
Checks: <command> - PASS|FAIL|BLOCKED
|
||||||
|
Risk: <one sentence, or none>
|
||||||
|
Merge: <merge action the reviewer should take>
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Failure
|
||||||
|
|
||||||
|
- Retry with the target repository owner, not a disposable-cluster owner.
|
||||||
|
- Report the HTTP status and non-sensitive response shape only.
|
||||||
|
- A compare URL is a fallback only after PR creation has genuinely failed; it is not a substitute for an opened PR.
|
||||||
21
README.md
21
README.md
|
|
@ -1,26 +1,15 @@
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
go mod init github.com/Pingu-Studio/MaidnCLI
|
- `cicd-tool repo init` is a legacy GitHub workflow and is not used for new Forgejo/GitOps platform onboarding.
|
||||||
go get -u github.com/spf13/cobra@latest
|
|
||||||
go get golang.org/x/term
|
|
||||||
go mod tidy
|
|
||||||
go get gopkg.in/yaml.v3
|
|
||||||
|
|
||||||
in powershell run
|
|
||||||
```powershell
|
|
||||||
go install github.com/go-delve/delve/cmd/dlv@latest
|
|
||||||
dlv version
|
|
||||||
```
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
- `cicd-tool repo init --org <org> --flux-repo <repo>` creates the manifests and Flux repos
|
|
||||||
- `cicd-tool bootstrap` runs a shorter Forgejo-first wizard, asks for a Forgejo PAT, asks where local repos should be cloned, discovers Proxmox nodes/storage/networks, retries without losing entered answers when discovery fails, shows the latest Talos version, derives the standardized Talos factory URL, schematic, and required extensions automatically from the chosen version, writes `terraform.tfvars`, stages Talos images on Proxmox, and can execute Terraform, Talos bootstrap, and Flux bootstrap
|
- `cicd-tool bootstrap` runs a shorter Forgejo-first wizard, asks for a Forgejo PAT, asks where local repos should be cloned, discovers Proxmox nodes/storage/networks, retries without losing entered answers when discovery fails, shows the latest Talos version, derives the standardized Talos factory URL, schematic, and required extensions automatically from the chosen version, writes `terraform.tfvars`, stages Talos images on Proxmox, and can execute Terraform, Talos bootstrap, and Flux bootstrap
|
||||||
- `cicd-tool bootstrap --config maidn-bootstrap.yaml` skips the wizard and uses the saved config
|
- `cicd-tool bootstrap --config maidn-bootstrap.yaml` skips the wizard and uses the saved config
|
||||||
- `maidn bootstrap init --config <private-config> --organization <new-org> --create-organization --enable-delivery` locks an isolated workspace, initializes the Forgejo repositories, then runs the non-destructive bootstrap reconcile lifecycle; use `--mode=rebuild --yes` for an authorized rebuild. Delivery scaffolding requires `--enable-delivery`.
|
- `cicd-tool bootstrap init --config <private-config> --organization <new-org> --create-organization` locks an isolated workspace, initializes Forgejo repositories, then runs the non-destructive bootstrap reconcile lifecycle. Use `--mode=rebuild --yes` only for an authorized rebuild.
|
||||||
- `maidn app onboard --config <private-config> --from <app-checkout>` validates a clean configured checkout and adds its `.tekton` delivery contract.
|
- `cicd-tool app onboard --config <private-config> --from <app-checkout>` is being migrated to central delivery ownership. Do not use the source-owned implementation for new applications; see `docs/architecture/delivery-ownership.md`.
|
||||||
- `cicd-tool e2e` runs bounded, read-only Flux, ExternalSecret, PipelineRun, preview, and promotion-PR checks with JSON output. See `docs/e2e.md`.
|
- `cicd-tool e2e` runs bounded, read-only Flux, ExternalSecret, PipelineRun, preview, and promotion-PR checks with JSON output. See `docs/e2e.md`.
|
||||||
|
|
||||||
See `docs/operations.md` for the authorized operating and verification runbook.
|
See `docs/operations.md` for the authorized operating and verification runbook.
|
||||||
|
See `docs/architecture/delivery-ownership.md` for the developer and platform
|
||||||
|
ownership boundary.
|
||||||
App authors: see `docs/delivery-feedback.md` for preview feedback and the scoped Forgejo token contract.
|
App authors: see `docs/delivery-feedback.md` for preview feedback and the scoped Forgejo token contract.
|
||||||
|
|
||||||
## Forgejo setup
|
## Forgejo setup
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ var bootstrapMergeBootstrapPR bool
|
||||||
var bootstrapManageNetworkBridges bool
|
var bootstrapManageNetworkBridges bool
|
||||||
var bootstrapEnableDelivery bool
|
var bootstrapEnableDelivery bool
|
||||||
var bootstrapDestroyDemocraticCSIStorage bool
|
var bootstrapDestroyDemocraticCSIStorage bool
|
||||||
|
var bootstrapRefreshTemplateRevisions bool
|
||||||
|
|
||||||
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
|
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
|
||||||
var readOperationalSecrets = bootstrap.ReadOperationalSecrets
|
var readOperationalSecrets = bootstrap.ReadOperationalSecrets
|
||||||
|
|
@ -68,9 +69,16 @@ func init() {
|
||||||
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")
|
||||||
bootstrapCmd.Flags().BoolVar(&bootstrapDestroyDemocraticCSIStorage, "destroy-democratic-csi-storage", false, "Delete only TrueNAS datasets under this cluster's configured Democratic CSI parent during rebuild")
|
bootstrapCmd.Flags().BoolVar(&bootstrapDestroyDemocraticCSIStorage, "destroy-democratic-csi-storage", false, "Delete only TrueNAS datasets under this cluster's configured Democratic CSI parent during rebuild")
|
||||||
|
bootstrapCmd.Flags().BoolVar(&bootstrapRefreshTemplateRevisions, "refresh-template-revisions", false, "Refresh existing template revision locks from configured branch heads during reconcile")
|
||||||
}
|
}
|
||||||
|
|
||||||
func runBootstrap(cmd *cobra.Command, args []string) error {
|
func runBootstrap(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := validateTemplateRevisionRefresh(bootstrapRefreshTemplateRevisions, bootstrap.Mode(bootstrapMode), cmd != nil && cmd.Flags().Changed("mode")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if bootstrapRefreshTemplateRevisions && (bootstrapProvisionAppSecretIdentities || bootstrapCreateForgejoRegistryToken || bootstrapCreateForgejoDeliveryStatusToken || bootstrapRotateWebhookAuthorization || bootstrapInitializeOpenBao || bootstrapMergeBootstrapPR) {
|
||||||
|
return errors.New("--refresh-template-revisions is available only for the bootstrap lifecycle")
|
||||||
|
}
|
||||||
var cfg config.Config
|
var cfg config.Config
|
||||||
var err error
|
var err error
|
||||||
if bootstrapProvisionAppSecretIdentities {
|
if bootstrapProvisionAppSecretIdentities {
|
||||||
|
|
@ -139,7 +147,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
||||||
if err := bootstrap.UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo-webhook", "authorization", authorization); err != nil {
|
if err := bootstrap.UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo-webhook", "authorization", authorization); err != nil {
|
||||||
return fmt.Errorf("save Forgejo webhook authorization: %w", err)
|
return fmt.Errorf("save Forgejo webhook authorization: %w", err)
|
||||||
}
|
}
|
||||||
return bootstrap.Runner{Config: cfg, RegisterWebhook: true}.Run()
|
return bootstrap.Runner{Config: cfg, RegisterWebhook: true, RefreshWebhookSecret: true}.Run()
|
||||||
}
|
}
|
||||||
if bootstrapInitializeOpenBao {
|
if bootstrapInitializeOpenBao {
|
||||||
if bootstrapConfigPath == "" {
|
if bootstrapConfigPath == "" {
|
||||||
|
|
@ -246,10 +254,17 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes, RegisterWebhook: bootstrapRegisterWebhook, EnableDelivery: bootstrapEnableDelivery, DestroyDemocraticCSIStorage: bootstrapDestroyDemocraticCSIStorage}
|
runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes, RegisterWebhook: bootstrapRegisterWebhook, EnableDelivery: bootstrapEnableDelivery, DestroyDemocraticCSIStorage: bootstrapDestroyDemocraticCSIStorage, RefreshTemplateRevisions: bootstrapRefreshTemplateRevisions}
|
||||||
return runner.Run()
|
return runner.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateTemplateRevisionRefresh(refresh bool, mode bootstrap.Mode, modeExplicit bool) error {
|
||||||
|
if refresh && (!modeExplicit || mode != bootstrap.Reconcile) {
|
||||||
|
return errors.New("--refresh-template-revisions requires --mode=reconcile")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func seedForgejoOperationalCredentials(cfg config.Config) error {
|
func seedForgejoOperationalCredentials(cfg config.Config) error {
|
||||||
if err := upsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo", "username", cfg.Git.Username); err != nil {
|
if err := upsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo", "username", cfg.Git.Username); err != nil {
|
||||||
return fmt.Errorf("save Forgejo username for webhook registration: %w", err)
|
return fmt.Errorf("save Forgejo username for webhook registration: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,31 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestValidateTemplateRevisionRefreshRequiresExplicitReconcileMode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mode bootstrap.Mode
|
||||||
|
modeExplicit bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "reconcile", mode: bootstrap.Reconcile, modeExplicit: true},
|
||||||
|
{name: "rebuild", mode: bootstrap.Rebuild, modeExplicit: true, wantErr: true},
|
||||||
|
{name: "missing mode", mode: bootstrap.Reconcile, wantErr: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
err := validateTemplateRevisionRefresh(true, test.mode, test.modeExplicit)
|
||||||
|
if (err != nil) != test.wantErr {
|
||||||
|
t.Fatalf("validateTemplateRevisionRefresh() error = %v, wantErr %t", err, test.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateForgejoRegistryTokenRequiresConfig(t *testing.T) {
|
func TestCreateForgejoRegistryTokenRequiresConfig(t *testing.T) {
|
||||||
originalConfigPath, originalCreate := bootstrapConfigPath, bootstrapCreateForgejoRegistryToken
|
originalConfigPath, originalCreate := bootstrapConfigPath, bootstrapCreateForgejoRegistryToken
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ var e2eMutateCmd = &cobra.Command{
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(e2eMutateCmd)
|
rootCmd.AddCommand(e2eMutateCmd)
|
||||||
e2eMutateCmd.Flags().StringVar(&e2eMutateForgejoURL, "forgejo-url", "", "Credential-free Forgejo base URL")
|
e2eMutateCmd.Flags().StringVar(&e2eMutateForgejoURL, "forgejo-url", "", "Credential-free Forgejo base URL")
|
||||||
e2eMutateCmd.Flags().StringVar(&e2eMutateOwner, "owner", "", "Fixture Forgejo owner (must be test-org-2)")
|
e2eMutateCmd.Flags().StringVar(&e2eMutateOwner, "owner", "", "Fixture Forgejo owner (must be Maidn)")
|
||||||
e2eMutateCmd.Flags().StringVar(&e2eMutateRepo, "repo", "", "Fixture Forgejo repository (must start maidn-e2e-)")
|
e2eMutateCmd.Flags().StringVar(&e2eMutateRepo, "repo", "", "Fixture Forgejo repository (must start maidn-e2e-)")
|
||||||
e2eMutateCmd.Flags().StringVar(&e2eMutateBranch, "branch", "", "Fixture Forgejo branch (must start maidn-e2e-)")
|
e2eMutateCmd.Flags().StringVar(&e2eMutateBranch, "branch", "", "Fixture Forgejo branch (must start maidn-e2e-)")
|
||||||
e2eMutateCmd.Flags().StringVar(&e2eMutateSHA, "sha", "", "Full Git object ID for the fixture branch")
|
e2eMutateCmd.Flags().StringVar(&e2eMutateSHA, "sha", "", "Full Git object ID for the fixture branch")
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ func TestE2EMutateCommandWiringUsesOnlyTokenReferences(t *testing.T) {
|
||||||
}
|
}
|
||||||
fake := &commandMutationHTTP{}
|
fake := &commandMutationHTTP{}
|
||||||
e2eMutator = func() e2emutate.Mutator { return e2emutate.Mutator{HTTP: fake} }
|
e2eMutator = func() e2emutate.Mutator { return e2emutate.Mutator{HTTP: fake} }
|
||||||
e2eMutateForgejoURL, e2eMutateOwner = "https://git.example.test", "test-org-2"
|
e2eMutateForgejoURL, e2eMutateOwner = "https://git.example.test", "Maidn"
|
||||||
e2eMutateRepo, e2eMutateBranch = "maidn-e2e-repo", "maidn-e2e-branch"
|
e2eMutateRepo, e2eMutateBranch = "maidn-e2e-repo", "maidn-e2e-branch"
|
||||||
e2eMutateSHA = "0123456789abcdef0123456789abcdef01234567"
|
e2eMutateSHA = "0123456789abcdef0123456789abcdef01234567"
|
||||||
e2eMutateTokenEnv, e2eMutateTokenFile, e2eMutateOpenPR = "E2E_MUTATE_TEST_TOKEN", "", false
|
e2eMutateTokenEnv, e2eMutateTokenFile, e2eMutateOpenPR = "E2E_MUTATE_TEST_TOKEN", "", false
|
||||||
|
|
|
||||||
18
cmd/fresh.go
18
cmd/fresh.go
|
|
@ -9,7 +9,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string
|
var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string
|
||||||
var onboardAppName, onboardAppRepoURL, onboardImageRepository string
|
var onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration string
|
||||||
var freshCreateOrganization, freshEnableDelivery, freshYes bool
|
var freshCreateOrganization, freshEnableDelivery, freshYes bool
|
||||||
var freshMode string
|
var freshMode string
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ var appCmd = &cobra.Command{
|
||||||
|
|
||||||
var appOnboardCmd = &cobra.Command{
|
var appOnboardCmd = &cobra.Command{
|
||||||
Use: "onboard",
|
Use: "onboard",
|
||||||
Short: "Validate an application checkout and add its source-owned delivery contract.",
|
Short: "Validate an application checkout and add its centrally-owned delivery contract.",
|
||||||
RunE: runAppOnboard,
|
RunE: runAppOnboard,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,10 +50,13 @@ func init() {
|
||||||
rootCmd.AddCommand(appCmd)
|
rootCmd.AddCommand(appCmd)
|
||||||
appCmd.AddCommand(appOnboardCmd)
|
appCmd.AddCommand(appOnboardCmd)
|
||||||
appOnboardCmd.Flags().StringVar(&onboardConfigPath, "config", "", "Path to private bootstrap config YAML")
|
appOnboardCmd.Flags().StringVar(&onboardConfigPath, "config", "", "Path to private bootstrap config YAML")
|
||||||
appOnboardCmd.Flags().StringVar(&onboardFrom, "from", "", "Clean application checkout to scaffold")
|
appOnboardCmd.Flags().StringVar(&onboardFrom, "from", "", "Clean application checkout to validate")
|
||||||
appOnboardCmd.Flags().StringVar(&onboardAppName, "app-name", "", "Application name override")
|
appOnboardCmd.Flags().StringVar(&onboardAppName, "app-name", "", "Application name override")
|
||||||
appOnboardCmd.Flags().StringVar(&onboardAppRepoURL, "app-repo-url", "", "Application repository URL override")
|
appOnboardCmd.Flags().StringVar(&onboardAppRepoURL, "app-repo-url", "", "Application repository URL override")
|
||||||
appOnboardCmd.Flags().StringVar(&onboardImageRepository, "image-repository", "", "OCI image repository override")
|
appOnboardCmd.Flags().StringVar(&onboardImageRepository, "image-repository", "", "OCI image repository override")
|
||||||
|
appOnboardCmd.Flags().StringVar(&onboardBuildStrategy, "build-strategy", "", "Build strategy override: static or runtime")
|
||||||
|
appOnboardCmd.Flags().StringVar(&onboardBuildOutputDirectory, "build-output-directory", "", "Static build output directory override")
|
||||||
|
appOnboardCmd.Flags().StringVar(&onboardBuildConfiguration, "build-configuration", "", "Static build configuration override")
|
||||||
_ = appOnboardCmd.MarkFlagRequired("config")
|
_ = appOnboardCmd.MarkFlagRequired("config")
|
||||||
_ = appOnboardCmd.MarkFlagRequired("from")
|
_ = appOnboardCmd.MarkFlagRequired("from")
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +90,15 @@ func runAppOnboard(_ *cobra.Command, _ []string) error {
|
||||||
if onboardImageRepository != "" {
|
if onboardImageRepository != "" {
|
||||||
cfg.Delivery.ImageRepository = onboardImageRepository
|
cfg.Delivery.ImageRepository = onboardImageRepository
|
||||||
}
|
}
|
||||||
|
if onboardBuildStrategy != "" {
|
||||||
|
cfg.Delivery.BuildStrategy = onboardBuildStrategy
|
||||||
|
}
|
||||||
|
if onboardBuildOutputDirectory != "" {
|
||||||
|
cfg.Delivery.BuildOutputDirectory = onboardBuildOutputDirectory
|
||||||
|
}
|
||||||
|
if onboardBuildConfiguration != "" {
|
||||||
|
cfg.Delivery.BuildConfiguration = onboardBuildConfiguration
|
||||||
|
}
|
||||||
cfg, err = resolveAppOnboarding(cfg)
|
cfg, err = resolveAppOnboarding(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,11 @@ func TestBootstrapInitAppliesFluxDefaultsBeforeFreshValidation(t *testing.T) {
|
||||||
func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
|
func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
|
||||||
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
|
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
|
||||||
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
||||||
originalName, originalRepo, originalImage := onboardAppName, onboardAppRepoURL, onboardImageRepository
|
originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration := onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
|
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
|
||||||
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
||||||
onboardAppName, onboardAppRepoURL, onboardImageRepository = originalName, originalRepo, originalImage
|
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration
|
||||||
})
|
})
|
||||||
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") }
|
||||||
|
|
@ -78,7 +78,7 @@ func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
|
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
|
||||||
onboardAppName, onboardAppRepoURL, onboardImageRepository = "", "", ""
|
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = "", "", "", "", "", ""
|
||||||
if err := runAppOnboard(nil, nil); err == nil {
|
if err := runAppOnboard(nil, nil); err == nil {
|
||||||
t.Fatal("onboarding accepted invalid configuration")
|
t.Fatal("onboarding accepted invalid configuration")
|
||||||
}
|
}
|
||||||
|
|
@ -87,25 +87,25 @@ func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
|
||||||
func TestAppOnboardPassesOnlyValidatedConfigAndCheckout(t *testing.T) {
|
func TestAppOnboardPassesOnlyValidatedConfigAndCheckout(t *testing.T) {
|
||||||
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
|
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
|
||||||
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
||||||
originalName, originalRepo, originalImage := onboardAppName, onboardAppRepoURL, onboardImageRepository
|
originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration := onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
|
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
|
||||||
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
||||||
onboardAppName, onboardAppRepoURL, onboardImageRepository = originalName, originalRepo, originalImage
|
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration
|
||||||
})
|
})
|
||||||
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(got config.Config) (config.Config, error) { return got, nil }
|
resolveAppOnboarding = func(got config.Config) (config.Config, error) { return got, nil }
|
||||||
calls := 0
|
calls := 0
|
||||||
onboardApp = func(got config.Config, checkout string) error {
|
onboardApp = func(got config.Config, checkout string) error {
|
||||||
if checkout != "app-checkout" || got.Delivery.AppName != "fixture" || got.Delivery.AppRepoURL != "https://git.example.test/new-org/fixture.git" || got.Delivery.ImageRepository != "registry.example.test/new-org/fixture" {
|
if checkout != "app-checkout" || got.Delivery.AppName != "fixture" || got.Delivery.AppRepoURL != "https://git.example.test/new-org/fixture.git" || got.Delivery.ImageRepository != "registry.example.test/new-org/fixture" || got.Delivery.BuildStrategy != "runtime" || got.Delivery.BuildOutputDirectory != "dist/fixture" || got.Delivery.BuildConfiguration != "ci" {
|
||||||
t.Fatal("onboarding used the wrong checkout or config")
|
t.Fatal("onboarding used the wrong checkout or config")
|
||||||
}
|
}
|
||||||
calls++
|
calls++
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
|
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
|
||||||
onboardAppName, onboardAppRepoURL, onboardImageRepository = "fixture", "https://git.example.test/new-org/fixture.git", "registry.example.test/new-org/fixture"
|
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = "fixture", "https://git.example.test/new-org/fixture.git", "registry.example.test/new-org/fixture", "runtime", "dist/fixture", "ci"
|
||||||
if err := runAppOnboard(nil, nil); err != nil || calls != 1 {
|
if err := runAppOnboard(nil, nil); err != nil || calls != 1 {
|
||||||
t.Fatalf("runAppOnboard() = %v, calls = %d", err, calls)
|
t.Fatalf("runAppOnboard() = %v, calls = %d", err, calls)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
107
docs/architecture/delivery-ownership.md
Normal file
107
docs/architecture/delivery-ownership.md
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# Delivery Ownership
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
This is the approved target architecture. Central onboarding is available for
|
||||||
|
new applications; existing source-owned registrations remain migration work.
|
||||||
|
|
||||||
|
## Trust Boundary
|
||||||
|
|
||||||
|
Application repositories are build inputs, not GitOps inputs. A developer can
|
||||||
|
change application code on `main`, but cannot change an active Pipeline, Task,
|
||||||
|
runtime secret reference, Helm chart, environment value, or promotion policy.
|
||||||
|
|
||||||
|
| Concern | Owner | Location |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Application code, tests, Dockerfile | Developers | `Maidn/<app>` `main` |
|
||||||
|
| Approved charts and environment values | Platform | `Maidn/<app>` `maidn/platform-<app>` |
|
||||||
|
| Pipeline, Tasks, triggers, runtime access | Platform | Cluster repository |
|
||||||
|
| Image tags, preview ownership, promotion PRs | Platform | Deployment manifests repository |
|
||||||
|
| Secret values and policies | Platform | Private config, SOPS operational state, OpenBao |
|
||||||
|
|
||||||
|
The protected platform branch may be readable by developers, but only platform
|
||||||
|
operators and approved automation may push or merge into it. Flux must track
|
||||||
|
only that branch for chart content. Flux must never track an application `main`
|
||||||
|
branch or `maidn/delivery-*` branch.
|
||||||
|
|
||||||
|
Onboarding requires an existing `maidn/platform-<app>` branch and verifies its
|
||||||
|
no-direct-push protection before it opens the central registration PR. It never
|
||||||
|
seeds a platform branch from developer-controlled `main`.
|
||||||
|
|
||||||
|
## Resource Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
App[Application main branch\ncode only]
|
||||||
|
Platform[Protected platform branch\nchart and values]
|
||||||
|
Cluster[Cluster repository\nPipeline, Tasks, secret access]
|
||||||
|
Manifests[Manifests repository\nimage tags and promotion]
|
||||||
|
Flux[Flux]
|
||||||
|
Workload[Preview, staging, production]
|
||||||
|
|
||||||
|
App -->|exact source SHA| Cluster
|
||||||
|
Cluster --> Manifests
|
||||||
|
Platform --> Flux
|
||||||
|
Manifests --> Flux
|
||||||
|
Flux --> Workload
|
||||||
|
```
|
||||||
|
|
||||||
|
The central Pipeline clones the application repository at the event SHA only to
|
||||||
|
build an image. It obtains chart content from the protected platform branch and
|
||||||
|
writes only image-tag and promotion state to the manifests repository.
|
||||||
|
|
||||||
|
## Prohibited Application Content
|
||||||
|
|
||||||
|
Application repositories must not contain active delivery control-plane
|
||||||
|
resources:
|
||||||
|
|
||||||
|
- `.tekton/` Pipeline or Task resources
|
||||||
|
- `.maidn/` Flux, RBAC, SecretStore, or ExternalSecret resources
|
||||||
|
- Flux Kustomizations or GitRepositories
|
||||||
|
- Kubernetes Secret values, SOPS identities, OpenBao tokens, or kubeconfigs
|
||||||
|
|
||||||
|
An application `main` branch may retain a chart as a developer proposal, but it
|
||||||
|
has no delivery effect. A platform operator explicitly reviews and copies an
|
||||||
|
approved chart and environment values to `maidn/platform-<app>`.
|
||||||
|
|
||||||
|
## Approval Sequence
|
||||||
|
|
||||||
|
1. A platform operator creates or updates the protected platform branch through
|
||||||
|
a reviewed platform PR.
|
||||||
|
2. A platform operator reviews and merges the central cluster registration that
|
||||||
|
renders the Pipeline, Tasks, protected chart source, and runtime references.
|
||||||
|
3. Flux applies only the central cluster resources and protected chart branch.
|
||||||
|
4. Developers use Forgejo pull requests and pushes to trigger builds; they do
|
||||||
|
not edit deployment control-plane resources.
|
||||||
|
5. Production promotion remains a reviewed PR in the manifests repository.
|
||||||
|
|
||||||
|
## Migration Rules
|
||||||
|
|
||||||
|
Existing source-owned registrations are migrated one application at a time.
|
||||||
|
First establish the protected platform package, then merge the central cluster
|
||||||
|
registration, then verify Flux and delivery. Remove the legacy source
|
||||||
|
GitRepository, Kustomizations, and delivery branch only after the replacement
|
||||||
|
is Ready.
|
||||||
|
|
||||||
|
The canonical fixtures are migration pilots. `Maidn/maidn-e2e-secret` remains
|
||||||
|
disabled until its central replacement is reviewed. Angular and web legacy
|
||||||
|
registrations must follow the same migration path.
|
||||||
|
|
||||||
|
## Command Status
|
||||||
|
|
||||||
|
`cicd-tool bootstrap`, `cicd-tool app`, and `cicd-tool e2e` are the current
|
||||||
|
Forgejo and GitOps command families. `cicd-tool repo init` and `cicd-tool vault
|
||||||
|
create-*` are legacy GitHub/direct-apply paths and are not part of new platform
|
||||||
|
onboarding. They will be deprecated or removed after the central delivery
|
||||||
|
migration.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
The architecture is accepted only after a canonical fixture proves all of the
|
||||||
|
following with a real Forgejo event:
|
||||||
|
|
||||||
|
- pull request preview build, route, and delivery feedback
|
||||||
|
- main-to-staging deployment at an immutable image SHA
|
||||||
|
- reviewed production promotion PR
|
||||||
|
- runtime secret and shared database projection without preview credentials
|
||||||
|
- closed-preview cleanup and orphan-cleaner recovery
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
# Delivery feedback
|
# Delivery feedback
|
||||||
|
|
||||||
`maidn app onboard` adds source-owned Tekton tasks that update one marked
|
The central cluster registration owns the Tekton tasks that update one marked
|
||||||
Forgejo pull-request comment. The comment contains only the verified preview
|
Forgejo pull-request comment. Application repositories do not carry active
|
||||||
URL, a redacted task-status summary, and the PipelineRun name. Set the optional
|
delivery Tasks. The comment contains only the verified preview URL, a redacted
|
||||||
`delivery.tektonDashboardUrl` to a credential-free HTTPS Tekton Dashboard
|
task-status summary, and the PipelineRun name. Set the optional
|
||||||
origin to add a PipelineRun link.
|
`delivery.tektonDashboardUrl` to a credential-free HTTPS Tekton Dashboard origin
|
||||||
|
to add a PipelineRun link.
|
||||||
|
|
||||||
Before enabling delivery feedback, create the separate Forgejo token with
|
Before enabling delivery feedback, create the separate Forgejo token with
|
||||||
`bootstrap --config <private-bootstrap-config> --create-forgejo-delivery-status-token`.
|
`bootstrap --config <private-bootstrap-config> --create-forgejo-delivery-status-token`.
|
||||||
|
|
@ -17,5 +18,6 @@ Forgejo API responses.
|
||||||
Preview and staging feedback waits up to ten minutes for the app Deployment
|
Preview and staging feedback waits up to ten minutes for the app Deployment
|
||||||
and HTTPRoute, then performs a bounded HTTPS check. A production event reports
|
and HTTPRoute, then performs a bounded HTTPS check. A production event reports
|
||||||
the manifest-repository promotion PR; it does not claim a production deploy.
|
the manifest-repository promotion PR; it does not claim a production deploy.
|
||||||
The chart must name both resources after `delivery.appName`; the HTTPRoute's
|
The protected `maidn/platform-<app>` chart must name both resources after
|
||||||
first hostname must be the public HTTPS preview/staging URL.
|
`delivery.appName`; the HTTPRoute's first hostname must be the public HTTPS
|
||||||
|
preview/staging URL.
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,10 @@ Build the portable OCI runner with `docker build -t maidn-e2e-runner .`.
|
||||||
Mount the kubeconfig and optional token file read-only; ensure they are readable
|
Mount the kubeconfig and optional token file read-only; ensure they are readable
|
||||||
by the image's non-root user. The build context excludes known secret-bearing
|
by the image's non-root user. The build context excludes known secret-bearing
|
||||||
bootstrap inputs.
|
bootstrap inputs.
|
||||||
|
|
||||||
|
## Fixture Boundary
|
||||||
|
|
||||||
|
Use `cicd-tool e2e-mutate` only with canonical `Maidn/maidn-e2e-*` fixtures.
|
||||||
|
The test runner must prove preview, staging, production promotion, runtime
|
||||||
|
secret/database access, preview cleanup, and orphan cleanup against central
|
||||||
|
delivery resources. It must not mutate `test-org-2` as a fixture source.
|
||||||
|
|
|
||||||
|
|
@ -23,31 +23,80 @@ 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
|
## Standard Delivery Workflow
|
||||||
|
|
||||||
Initialize the shared delivery platform before onboarding any application. This
|
Open and merge reviewed platform-source PRs before reconciling. When a merged
|
||||||
|
template source must replace an existing revision lock, run the refresh by
|
||||||
|
itself. It creates migration PRs; review and merge them, then rerun normal
|
||||||
|
reconciliation:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go run . bootstrap --config <private-bootstrap-config> --mode=reconcile --refresh-template-revisions
|
||||||
|
go run . bootstrap --config <private-bootstrap-config> --mode=reconcile
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialize the shared delivery platform before onboarding an application. This
|
||||||
creates the Gateway, Tekton, and Tekton Triggers platform resources using
|
creates the Gateway, Tekton, and Tekton Triggers platform resources using
|
||||||
`tekton.<cluster-domain>` and the configured Forgejo owner; it does not render
|
`tekton.<cluster-domain>` and the configured Forgejo owner:
|
||||||
an application Pipeline or register an application webhook:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization
|
go run . bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization
|
||||||
```
|
```
|
||||||
|
|
||||||
Import a clean checkout into the configured owner with a separate command. The
|
The source-owned onboarding implementation is retired. Central onboarding
|
||||||
checkout may originate in another Forgejo organization, but `delivery.appRepoUrl`
|
creates a reviewed cluster registration only; it never writes `.tekton` or
|
||||||
must target `<owner>/<app>.git` and its current branch must be
|
`.maidn` resources to an application repository. Before merging that
|
||||||
`delivery.appRepoRef`:
|
registration, a platform operator must create the corresponding
|
||||||
|
`maidn/platform-<app>` package through a reviewed platform PR; onboarding
|
||||||
|
verifies its existence and enforces its protection. See [Delivery
|
||||||
|
Ownership](architecture/delivery-ownership.md) for the approved architecture
|
||||||
|
and migration rules.
|
||||||
|
|
||||||
|
The onboarding command uses a clean checkout on `delivery.appRepoRef`.
|
||||||
|
The app repository URL must be the canonical source owner, such as
|
||||||
|
`Maidn/<app>.git`; `test-org-2` is execution state only. Per-app static build
|
||||||
|
values remain command-line overrides rather than private defaults:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
app onboard --config <private-bootstrap-config> --from <clean-checkout>
|
go run . app onboard --config <private-bootstrap-config> --from <clean-checkout> `
|
||||||
|
--app-name <app> --app-repo-url https://<forgejo>/Maidn/<app>.git `
|
||||||
|
--image-repository <registry>/<owner>/<app> --build-strategy static `
|
||||||
|
--build-output-directory <output-directory> --build-configuration <configuration>
|
||||||
```
|
```
|
||||||
|
|
||||||
Onboarding copies the current branch to the target repository, replaces only
|
For a runtime build, set `--build-strategy runtime`; static-only output options
|
||||||
the two Maidn-generated `.tekton` files, protects the production branch, and
|
remain harmless. Central onboarding creates the reviewed cluster-registration
|
||||||
auto-merges the app delivery and cluster registration PRs. The registration is
|
change after the platform package exists. The cluster repository owns
|
||||||
stored in `base/tekton/apps/<app>.yaml` in the configured cluster repository;
|
`base/tekton/apps/<app>.yaml`; the application repository remains a build input
|
||||||
the generic EventListener dispatches by the Forgejo repository name.
|
only. The generic EventListener dispatches by Forgejo repository name.
|
||||||
|
|
||||||
|
Existing source-owned registrations are migrated in separate reviewed cluster
|
||||||
|
repository PRs. Never overwrite an unmanaged registration.
|
||||||
|
|
||||||
|
After Flux reports the app Kustomization Ready, use Forgejo's hook test endpoint
|
||||||
|
against a non-`main` ref and inspect the resulting PipelineRun. The command and
|
||||||
|
read-only checks are in [Webhook Smoke Test](#webhook-smoke-test).
|
||||||
|
|
||||||
|
### Shared Services And Add-ons
|
||||||
|
|
||||||
|
Environment databases are platform-owned shared services. Staging workloads use
|
||||||
|
the CNPG-generated `staging-postgres-app` Secret and production workloads use
|
||||||
|
`production-postgres-app`; applications must not declare their own CNPG Cluster
|
||||||
|
by default. Shared credentials are appropriate only for the shared environment
|
||||||
|
database. Use a dedicated service only when isolation, lifecycle, or storage
|
||||||
|
requirements demand it.
|
||||||
|
|
||||||
|
The cluster repository owns reviewed dedicated resources and runtime secret
|
||||||
|
access. Application repositories must not carry active `.maidn` resources.
|
||||||
|
Runtime references never contain credential values. Preview namespaces do not
|
||||||
|
receive staging or production runtime credentials; preview-safe configuration is
|
||||||
|
defined in the protected platform chart branch.
|
||||||
|
|
||||||
|
Declare runtime secret access in the private configuration and run normal
|
||||||
|
bootstrap reconciliation to create its policy and role. Set values only with
|
||||||
|
`app secret set` using stdin, `--file`, or `--generate`; provision the scoped
|
||||||
|
E2E identity with `bootstrap --provision-app-secret-identities --e2e-app <app>`
|
||||||
|
only when a probe needs it. See [secret-grants.md](secret-grants.md).
|
||||||
|
|
||||||
`delivery.productionBranch` is the application production branch. A production
|
`delivery.productionBranch` is the application production branch. A production
|
||||||
delivery PR targets the configured generic
|
delivery PR targets the configured generic
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,6 @@ spec:
|
||||||
- name: environment
|
- name: environment
|
||||||
- name: pr-number
|
- name: pr-number
|
||||||
default: ""
|
default: ""
|
||||||
- name: app-url
|
|
||||||
default: ""
|
|
||||||
- name: app-revision
|
|
||||||
default: ""
|
|
||||||
- name: forgejo-base-url
|
- name: forgejo-base-url
|
||||||
default: ""
|
default: ""
|
||||||
- name: forgejo-owner
|
- name: forgejo-owner
|
||||||
|
|
@ -57,10 +53,6 @@ spec:
|
||||||
value: $(params.environment)
|
value: $(params.environment)
|
||||||
- name: PR_NUMBER
|
- name: PR_NUMBER
|
||||||
value: $(params.pr-number)
|
value: $(params.pr-number)
|
||||||
- name: APP_URL
|
|
||||||
value: $(params.app-url)
|
|
||||||
- name: APP_REVISION
|
|
||||||
value: $(params.app-revision)
|
|
||||||
- name: FORGEJO_BASE_URL
|
- name: FORGEJO_BASE_URL
|
||||||
value: $(params.forgejo-base-url)
|
value: $(params.forgejo-base-url)
|
||||||
- name: FORGEJO_OWNER
|
- name: FORGEJO_OWNER
|
||||||
|
|
@ -102,8 +94,6 @@ spec:
|
||||||
cd /tmp/manifests
|
cd /tmp/manifests
|
||||||
if [ "$ENVIRONMENT" = preview ]; then
|
if [ "$ENVIRONMENT" = preview ]; then
|
||||||
valid_pr_number "$PR_NUMBER"
|
valid_pr_number "$PR_NUMBER"
|
||||||
valid_url "$APP_URL"
|
|
||||||
valid_revision "$APP_REVISION"
|
|
||||||
app_dir="apps/previews/$APP_NAME-pr-$PR_NUMBER"
|
app_dir="apps/previews/$APP_NAME-pr-$PR_NUMBER"
|
||||||
marker="$app_dir/ownership.yaml"
|
marker="$app_dir/ownership.yaml"
|
||||||
if [ -e "$app_dir" ]; then
|
if [ -e "$app_dir" ]; then
|
||||||
|
|
@ -129,12 +119,7 @@ spec:
|
||||||
EOF
|
EOF
|
||||||
cmp -s "$expected_marker" "$marker" || fail
|
cmp -s "$expected_marker" "$marker" || fail
|
||||||
fi
|
fi
|
||||||
git clone "$APP_URL" /tmp/app
|
|
||||||
git -C /tmp/app checkout "$APP_REVISION"
|
|
||||||
[ -f /tmp/app/preview/values.yaml ] || fail
|
|
||||||
mkdir -p "$app_dir"
|
mkdir -p "$app_dir"
|
||||||
cp /tmp/app/preview/values.yaml "$app_dir/values.yaml"
|
|
||||||
sed -i "s/PLACEHOLDER_PR/$PR_NUMBER/g" "$app_dir/values.yaml"
|
|
||||||
cat > "$marker" <<EOF
|
cat > "$marker" <<EOF
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
|
|
@ -169,14 +154,20 @@ spec:
|
||||||
chart:
|
chart:
|
||||||
spec:
|
spec:
|
||||||
chart: ./charts/$APP_NAME
|
chart: ./charts/$APP_NAME
|
||||||
|
reconcileStrategy: Revision
|
||||||
sourceRef:
|
sourceRef:
|
||||||
kind: GitRepository
|
kind: GitRepository
|
||||||
name: $APP_NAME
|
name: $APP_NAME
|
||||||
namespace: flux-system
|
namespace: flux-system
|
||||||
|
valuesFiles:
|
||||||
|
- ./charts/$APP_NAME/values.yaml
|
||||||
|
- ./preview/values.yaml
|
||||||
values:
|
values:
|
||||||
image:
|
image:
|
||||||
repository: $IMAGE
|
repository: $IMAGE
|
||||||
tag: $TAG
|
tag: $TAG
|
||||||
|
gateway:
|
||||||
|
hostname: $APP_NAME-pr-$PR_NUMBER.{{ .ClusterDomain }}
|
||||||
EOF
|
EOF
|
||||||
cat > "$app_dir/kustomization.yaml" <<EOF
|
cat > "$app_dir/kustomization.yaml" <<EOF
|
||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
|
@ -212,10 +203,14 @@ spec:
|
||||||
chart:
|
chart:
|
||||||
spec:
|
spec:
|
||||||
chart: ./charts/$APP_NAME
|
chart: ./charts/$APP_NAME
|
||||||
|
reconcileStrategy: Revision
|
||||||
sourceRef:
|
sourceRef:
|
||||||
kind: GitRepository
|
kind: GitRepository
|
||||||
name: $APP_NAME
|
name: $APP_NAME
|
||||||
namespace: flux-system
|
namespace: flux-system
|
||||||
|
valuesFiles:
|
||||||
|
- ./charts/$APP_NAME/values.yaml
|
||||||
|
- ./staging/values.yaml
|
||||||
values:
|
values:
|
||||||
image:
|
image:
|
||||||
repository: $IMAGE
|
repository: $IMAGE
|
||||||
|
|
@ -259,10 +254,14 @@ spec:
|
||||||
chart:
|
chart:
|
||||||
spec:
|
spec:
|
||||||
chart: ./charts/$APP_NAME
|
chart: ./charts/$APP_NAME
|
||||||
|
reconcileStrategy: Revision
|
||||||
sourceRef:
|
sourceRef:
|
||||||
kind: GitRepository
|
kind: GitRepository
|
||||||
name: $APP_NAME
|
name: $APP_NAME
|
||||||
namespace: flux-system
|
namespace: flux-system
|
||||||
|
valuesFiles:
|
||||||
|
- ./charts/$APP_NAME/values.yaml
|
||||||
|
- ./production/values.yaml
|
||||||
values:
|
values:
|
||||||
image:
|
image:
|
||||||
repository: $IMAGE
|
repository: $IMAGE
|
||||||
|
|
@ -635,7 +634,7 @@ spec:
|
||||||
operator: notin
|
operator: notin
|
||||||
values: [closed]
|
values: [closed]
|
||||||
taskRef:
|
taskRef:
|
||||||
name: maidn-node-static-image
|
name: {{ if eq .BuildStrategy "static" }}maidn-node-static-image{{ else }}maidn-node-runtime-image{{ end }}
|
||||||
params:
|
params:
|
||||||
- name: url
|
- name: url
|
||||||
value: {{ quote .AppRepoURL }}
|
value: {{ quote .AppRepoURL }}
|
||||||
|
|
@ -643,10 +642,12 @@ spec:
|
||||||
value: $(params.git-revision)
|
value: $(params.git-revision)
|
||||||
- name: image
|
- name: image
|
||||||
value: $(params.image)
|
value: $(params.image)
|
||||||
|
{{ if eq .BuildStrategy "static" }}
|
||||||
- name: output-directory
|
- name: output-directory
|
||||||
value: {{ quote .BuildOutputDirectory }}
|
value: {{ quote .BuildOutputDirectory }}
|
||||||
- name: build-configuration
|
- name: build-configuration
|
||||||
value: {{ quote .BuildConfiguration }}
|
value: {{ quote .BuildConfiguration }}
|
||||||
|
{{ end }}
|
||||||
- name: update-preview
|
- name: update-preview
|
||||||
runAfter: [build-layer]
|
runAfter: [build-layer]
|
||||||
when:
|
when:
|
||||||
|
|
@ -675,10 +676,6 @@ spec:
|
||||||
value: preview
|
value: preview
|
||||||
- name: pr-number
|
- name: pr-number
|
||||||
value: $(params.pr-number)
|
value: $(params.pr-number)
|
||||||
- name: app-url
|
|
||||||
value: {{ quote .AppRepoURL }}
|
|
||||||
- name: app-revision
|
|
||||||
value: $(params.git-revision)
|
|
||||||
- name: update-staging
|
- name: update-staging
|
||||||
runAfter: [build-layer]
|
runAfter: [build-layer]
|
||||||
when:
|
when:
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,12 @@ type Runner struct {
|
||||||
Mode Mode
|
Mode Mode
|
||||||
ConfirmRebuild bool
|
ConfirmRebuild bool
|
||||||
RegisterWebhook bool
|
RegisterWebhook bool
|
||||||
|
RefreshWebhookSecret bool
|
||||||
EnableDelivery bool
|
EnableDelivery bool
|
||||||
SkipDeliveryScaffolding bool
|
SkipDeliveryScaffolding bool
|
||||||
AutoMergeBootstrapMigration bool
|
AutoMergeBootstrapMigration bool
|
||||||
DestroyDemocraticCSIStorage bool
|
DestroyDemocraticCSIStorage bool
|
||||||
|
RefreshTemplateRevisions bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type operationalSecrets struct {
|
type operationalSecrets struct {
|
||||||
|
|
@ -73,6 +75,8 @@ var ensureForgejoWebhook = func(cfg config.Config, repo, webhookURL, authorizati
|
||||||
return manager.EnsureWebhook(repo, webhookURL, authorization)
|
return manager.EnsureWebhook(repo, webhookURL, authorization)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var readOperationalSecrets = ReadOperationalSecrets
|
||||||
|
|
||||||
var runWebhookCommand = utils.RunCommandQuietOutputInDir
|
var runWebhookCommand = utils.RunCommandQuietOutputInDir
|
||||||
|
|
||||||
var preflight = config.Preflight
|
var preflight = config.Preflight
|
||||||
|
|
@ -170,6 +174,15 @@ func (r Runner) Run() error {
|
||||||
}
|
}
|
||||||
r.Config = resolvedDelivery
|
r.Config = resolvedDelivery
|
||||||
}
|
}
|
||||||
|
if r.Mode, err = resolveLifecycleMode(r.Mode, r.ConfirmRebuild); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.RefreshTemplateRevisions && r.Mode != Reconcile {
|
||||||
|
return errors.New("--refresh-template-revisions requires --mode=reconcile")
|
||||||
|
}
|
||||||
|
if r.DestroyDemocraticCSIStorage && r.Mode != Rebuild {
|
||||||
|
return errors.New("--destroy-democratic-csi-storage requires --mode=rebuild --yes")
|
||||||
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
@ -184,13 +197,12 @@ func (r Runner) Run() error {
|
||||||
return errors.New("SOPS recoveryRecipient is required")
|
return errors.New("SOPS recoveryRecipient is required")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r.Mode, err = resolveLifecycleMode(r.Mode, r.ConfirmRebuild); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if r.DestroyDemocraticCSIStorage && r.Mode != Rebuild {
|
|
||||||
return errors.New("--destroy-democratic-csi-storage requires --mode=rebuild --yes")
|
|
||||||
}
|
|
||||||
if r.RegisterWebhook {
|
if r.RegisterWebhook {
|
||||||
|
if r.RefreshTemplateRevisions {
|
||||||
|
if err := RefreshTemplateRevisions(r.Config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return r.reconcileWebhook(filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName, r.Config.Talos.GeneratedDir))
|
return r.reconcileWebhook(filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName, r.Config.Talos.GeneratedDir))
|
||||||
}
|
}
|
||||||
if r.Config.SOPS.RecoveryIdentityPath == "" || r.Config.SOPS.RecoveryBundlePath == "" {
|
if r.Config.SOPS.RecoveryIdentityPath == "" || r.Config.SOPS.RecoveryBundlePath == "" {
|
||||||
|
|
@ -203,8 +215,14 @@ func (r Runner) Run() error {
|
||||||
return fmt.Errorf("preflight OpenBao recovery bundle: %w", err)
|
return fmt.Errorf("preflight OpenBao recovery bundle: %w", err)
|
||||||
}
|
}
|
||||||
workspace := r.Config.WorkspaceDir
|
workspace := r.Config.WorkspaceDir
|
||||||
if err := EnsureTemplateRevisions(r.Config); err != nil {
|
if r.RefreshTemplateRevisions {
|
||||||
return err
|
if err := RefreshTemplateRevisions(r.Config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := EnsureTemplateRevisions(r.Config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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 {
|
||||||
|
|
@ -398,15 +416,26 @@ func (r Runner) reconcileCloudflareTunnel() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) reconcileWebhook(generatedDir string) error {
|
func (r Runner) reconcileWebhook(generatedDir string) error {
|
||||||
operationalSecrets, err := r.initializeOpenBaoForCluster(generatedDir)
|
var (
|
||||||
if err != nil {
|
operationalSecrets map[string]map[string]string
|
||||||
return fmt.Errorf("initialize OpenBao: %w", err)
|
err error
|
||||||
|
)
|
||||||
|
if r.RefreshWebhookSecret {
|
||||||
|
operationalSecrets, err = r.initializeOpenBaoForCluster(generatedDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("initialize OpenBao: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
operationalSecrets, err = readOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read encrypted webhook authorization: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
authorization := operationalSecrets["cicd/forgejo-webhook"]["authorization"]
|
authorization := operationalSecrets["cicd/forgejo-webhook"]["authorization"]
|
||||||
if authorization == "" {
|
if authorization == "" {
|
||||||
return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization")
|
return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization")
|
||||||
}
|
}
|
||||||
if err := waitForWebhookTargets(generatedDir, r.Config, authorization); err != nil {
|
if err := waitForWebhookTargets(generatedDir, r.Config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := ensureForgejoWebhook(r.Config, r.Config.Delivery.AppName, r.Config.Delivery.WebhookURL(), authorization); err != nil {
|
if err := ensureForgejoWebhook(r.Config, r.Config.Delivery.AppName, r.Config.Delivery.WebhookURL(), authorization); err != nil {
|
||||||
|
|
@ -595,6 +624,7 @@ type appDeliveryTemplateConfig struct {
|
||||||
AppRepoRef string
|
AppRepoRef string
|
||||||
ProductionBranch string
|
ProductionBranch string
|
||||||
ImageRepository string
|
ImageRepository string
|
||||||
|
BuildStrategy string
|
||||||
BuildOutputDirectory string
|
BuildOutputDirectory string
|
||||||
BuildConfiguration string
|
BuildConfiguration string
|
||||||
ForgejoBaseURL string
|
ForgejoBaseURL string
|
||||||
|
|
@ -602,68 +632,18 @@ type appDeliveryTemplateConfig struct {
|
||||||
ManifestsURL string
|
ManifestsURL string
|
||||||
ManifestsRepo string
|
ManifestsRepo string
|
||||||
ManifestsBranch string
|
ManifestsBranch string
|
||||||
|
ClusterDomain string
|
||||||
}
|
}
|
||||||
|
|
||||||
var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`)
|
var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`)
|
||||||
|
|
||||||
// GenerateAppDelivery writes the source-owned Tekton delivery contract for an app checkout.
|
|
||||||
func GenerateAppDelivery(dir string, cfg config.Config) error {
|
|
||||||
if err := config.ValidateDelivery(cfg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
content, err := renderAppDelivery(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
files := map[string][]byte{
|
|
||||||
"kustomization.yaml": []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - pipeline.yaml\n"),
|
|
||||||
"pipeline.yaml": content,
|
|
||||||
}
|
|
||||||
target := filepath.Join(dir, ".tekton")
|
|
||||||
info, err := os.Lstat(target)
|
|
||||||
if err == nil {
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
||||||
return errors.New("app delivery .tekton path must be a directory")
|
|
||||||
}
|
|
||||||
entries, err := os.ReadDir(target)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(entries) != len(files) {
|
|
||||||
return errors.New("app delivery .tekton contains unmanaged files")
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
if !os.IsNotExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
temporary, err := os.MkdirTemp(dir, ".maidn-tekton-")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(temporary)
|
|
||||||
for name, content := range files {
|
|
||||||
if err := os.WriteFile(filepath.Join(temporary, name), content, 0644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return os.Rename(temporary, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderAppDelivery(cfg config.Config) ([]byte, error) {
|
func renderAppDelivery(cfg config.Config) ([]byte, error) {
|
||||||
if !deliveryAppName.MatchString(cfg.Delivery.AppName) {
|
if !deliveryAppName.MatchString(cfg.Delivery.AppName) {
|
||||||
return nil, errors.New("delivery appName must be a lowercase DNS label")
|
return nil, errors.New("delivery appName must be a lowercase DNS label")
|
||||||
}
|
}
|
||||||
|
if cfg.Delivery.BuildStrategy != "static" && cfg.Delivery.BuildStrategy != "runtime" {
|
||||||
|
return nil, errors.New("delivery buildStrategy must be static or runtime")
|
||||||
|
}
|
||||||
appRepository, err := deliveryRepository(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL)
|
appRepository, err := deliveryRepository(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -675,10 +655,10 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) {
|
||||||
values := appDeliveryTemplateConfig{
|
values := appDeliveryTemplateConfig{
|
||||||
AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL,
|
AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL,
|
||||||
AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository,
|
AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository,
|
||||||
BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration,
|
BuildStrategy: cfg.Delivery.BuildStrategy, BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration,
|
||||||
ForgejoBaseURL: origin, ForgejoOwner: cfg.Git.Owner, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsRepo: cfg.Flux.ManifestsRepo, ManifestsBranch: cfg.Flux.Branch,
|
ForgejoBaseURL: origin, ForgejoOwner: cfg.Git.Owner, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsRepo: cfg.Flux.ManifestsRepo, ManifestsBranch: cfg.Flux.Branch, ClusterDomain: cfg.Flux.ClusterDomain,
|
||||||
}
|
}
|
||||||
for name, value := range map[string]string{"appRepository": values.AppRepository, "appRepoUrl": values.AppRepoURL, "appRepoRef": values.AppRepoRef, "productionBranch": values.ProductionBranch, "imageRepository": values.ImageRepository, "buildOutputDirectory": values.BuildOutputDirectory, "buildConfiguration": values.BuildConfiguration, "forgejoBaseUrl": values.ForgejoBaseURL, "forgejoOwner": values.ForgejoOwner, "manifestsUrl": values.ManifestsURL, "manifestsRepo": values.ManifestsRepo, "manifestsBranch": values.ManifestsBranch} {
|
for name, value := range map[string]string{"appRepository": values.AppRepository, "appRepoUrl": values.AppRepoURL, "appRepoRef": values.AppRepoRef, "productionBranch": values.ProductionBranch, "imageRepository": values.ImageRepository, "buildOutputDirectory": values.BuildOutputDirectory, "buildConfiguration": values.BuildConfiguration, "forgejoBaseUrl": values.ForgejoBaseURL, "forgejoOwner": values.ForgejoOwner, "manifestsUrl": values.ManifestsURL, "manifestsRepo": values.ManifestsRepo, "manifestsBranch": values.ManifestsBranch, "clusterDomain": values.ClusterDomain} {
|
||||||
if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value {
|
if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value {
|
||||||
return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name)
|
return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name)
|
||||||
}
|
}
|
||||||
|
|
@ -1098,38 +1078,22 @@ func renderDemocraticCSISecret(csi config.DemocraticCSIConfig) ([]byte, error) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForWebhookTargets(dir string, cfg config.Config, authorization string) error {
|
func waitForWebhookTargets(dir string, _ config.Config) error {
|
||||||
if err := waitForWebhookAuthorization(dir, authorization); err != nil {
|
if err := waitForWebhookExternalSecret(dir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
resources := []string{"pipeline/" + cfg.Delivery.AppName}
|
|
||||||
for _, resource := range resources {
|
|
||||||
deadline := time.Now().Add(webhookTargetTimeout)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if _, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", resource); err == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(webhookTargetPollInterval)
|
|
||||||
}
|
|
||||||
if _, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", resource); err != nil {
|
|
||||||
return fmt.Errorf("wait for %s before registering Forgejo webhook", resource)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForWebhookAuthorization(dir, authorization string) error {
|
func waitForWebhookExternalSecret(dir string) error {
|
||||||
deadline := time.Now().Add(webhookTargetTimeout)
|
deadline := time.Now().Add(webhookTargetTimeout)
|
||||||
for {
|
for {
|
||||||
output, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", "secret/forgejo-webhook", "-o=jsonpath={.data.authorization}")
|
output, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", "externalsecret/forgejo-webhook", "-o=jsonpath={.status.conditions[0].status}")
|
||||||
if err == nil {
|
if err == nil && strings.TrimSpace(string(output)) == "True" {
|
||||||
observed, decodeErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(output)))
|
return nil
|
||||||
if decodeErr == nil && string(observed) == authorization {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if !time.Now().Before(deadline) {
|
if !time.Now().Before(deadline) {
|
||||||
return errors.New("ExternalSecret target Secret forgejo-webhook did not refresh within the timeout; Forgejo webhook was not updated. Wait for External Secrets to recover, then safely rerun cicd-tool bootstrap --config <config> --register-webhook")
|
return errors.New("ExternalSecret forgejo-webhook did not become ready within the timeout; Forgejo webhook was not updated. Wait for External Secrets to recover, then safely rerun cicd-tool bootstrap --config <config> --register-webhook")
|
||||||
}
|
}
|
||||||
time.Sleep(webhookTargetPollInterval)
|
time.Sleep(webhookTargetPollInterval)
|
||||||
}
|
}
|
||||||
|
|
@ -1721,6 +1685,42 @@ func EnsureTemplateRevisions(cfg config.Config) error {
|
||||||
return ensureTemplateRevisions(cfg.WorkspaceDir, cfg)
|
return ensureTemplateRevisions(cfg.WorkspaceDir, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefreshTemplateRevisions replaces an existing template lock with configured ref heads.
|
||||||
|
func RefreshTemplateRevisions(cfg config.Config) error {
|
||||||
|
return refreshTemplateRevisions(cfg.WorkspaceDir, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshTemplateRevisions(workspace string, cfg config.Config) error {
|
||||||
|
lockPath := filepath.Join(workspace, "maidn-template-revisions.yaml")
|
||||||
|
lock, err := readTemplateRevisionLock(lockPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return errors.New("template revision lock does not exist; run bootstrap without --refresh-template-revisions first")
|
||||||
|
}
|
||||||
|
return errors.New("template revision lock is invalid")
|
||||||
|
}
|
||||||
|
checkouts := []templateCheckout{
|
||||||
|
{Dir: filepath.Join(workspace, "maidn-cicd-cluster-template"), Repository: cfg.Templates.CICDRepoURL, Ref: cfg.Templates.CICDRepoRef},
|
||||||
|
{Dir: filepath.Join(workspace, "cicd-deployment-manifests-template"), Repository: cfg.Templates.ManifestsRepoURL, Ref: cfg.Templates.ManifestsRepoRef},
|
||||||
|
{Dir: filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName), Repository: cfg.Templates.TalosRepoURL, Ref: cfg.Templates.TalosRepoRef, GeneratedDir: cfg.Talos.GeneratedDir},
|
||||||
|
}
|
||||||
|
revisions := []templateRevision{lock.CICD, lock.Manifests, lock.Talos}
|
||||||
|
for index, checkout := range checkouts {
|
||||||
|
if !sameTemplateSource(revisions[index], checkout) {
|
||||||
|
return errors.New("configured template source or ref differs from its workspace revision lock")
|
||||||
|
}
|
||||||
|
commit, err := checkoutTemplateRevision(cfg, checkout, "")
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("configured template revision cannot be resolved; correct the template source or ref, then rerun bootstrap")
|
||||||
|
}
|
||||||
|
revisions[index].Commit = commit
|
||||||
|
}
|
||||||
|
if err := replaceTemplateRevisionLock(lockPath, templateRevisionLock{Version: 1, CICD: revisions[0], Manifests: revisions[1], Talos: revisions[2]}); err != nil {
|
||||||
|
return errors.New("cannot update template revision lock")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func readTemplateRevisionLock(path string) (templateRevisionLock, error) {
|
func readTemplateRevisionLock(path string) (templateRevisionLock, error) {
|
||||||
var lock templateRevisionLock
|
var lock templateRevisionLock
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
|
|
@ -1757,6 +1757,27 @@ func writeTemplateRevisionLock(path string, lock templateRevisionLock) error {
|
||||||
return file.Close()
|
return file.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func replaceTemplateRevisionLock(path string, lock templateRevisionLock) error {
|
||||||
|
data, err := yaml.Marshal(lock)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file, err := os.CreateTemp(filepath.Dir(path), ".maidn-template-revisions-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
temporaryPath := file.Name()
|
||||||
|
defer os.Remove(temporaryPath)
|
||||||
|
if _, err := file.Write(data); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(temporaryPath, path)
|
||||||
|
}
|
||||||
|
|
||||||
func sameTemplateSource(revision templateRevision, checkout templateCheckout) bool {
|
func sameTemplateSource(revision templateRevision, checkout templateCheckout) bool {
|
||||||
return validTemplateRevision(revision) && revision.Repository == config.RedactURL(checkout.Repository) && revision.Ref == checkout.Ref
|
return validTemplateRevision(revision) && revision.Repository == config.RedactURL(checkout.Repository) && revision.Ref == checkout.Ref
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,30 +111,57 @@ func TestRemoveDuplicateAppDeliverySource(t *testing.T) {
|
||||||
func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(t *testing.T) {
|
func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(t *testing.T) {
|
||||||
cfg := config.Config{
|
cfg := config.Config{
|
||||||
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
|
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
|
||||||
Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"},
|
Flux: config.FluxConfig{Branch: "main", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
|
||||||
Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"},
|
Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"},
|
||||||
}
|
}
|
||||||
content, err := renderAppDelivery(cfg)
|
content, err := renderAppDelivery(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, expected := range []string{"name: build-layer", "maidn-node-static-image", "name: url\n value: \"https://git.example.test/apps/web-ui.git\"", "name: revision\n value: $(params.git-revision)", "name: image\n value: $(params.image)", "runAfter: [build-layer]", "maidn-preview-orphan-reconciler", "valid_pr_number()", "valid_commit()", "values: [promotion]", "values: [\"production\"]", "cmp -s \"$expected_marker\" \"$marker\"", "values: [closed]"} {
|
for _, expected := range []string{"name: build-layer", "maidn-node-static-image", "name: url\n value: \"https://git.example.test/apps/web-ui.git\"", "name: revision\n value: $(params.git-revision)", "name: image\n value: $(params.image)", "name: output-directory\n value: \"dist/web-ui\"", "name: build-configuration\n value: \"production\"", "runAfter: [build-layer]", "maidn-preview-orphan-reconciler", "valid_pr_number()", "valid_commit()", "values: [promotion]", "values: [\"production\"]", "cmp -s \"$expected_marker\" \"$marker\"", "values: [closed]"} {
|
||||||
if !strings.Contains(string(content), expected) {
|
if !strings.Contains(string(content), expected) {
|
||||||
t.Fatalf("generated delivery does not contain %q", expected)
|
t.Fatalf("generated delivery does not contain %q", expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, unexpected := range []string{"maidn-git-clone", "maidn-node-static-build", "maidn-node-static-push", "runAfter: [clone]", "name: source", "workspace: source", "workspace: artifact", "easycsr", "test-org", "git rm -r"} {
|
for _, expected := range []string{"reconcileStrategy: Revision", "valuesFiles:\n - ./charts/$APP_NAME/values.yaml\n - ./preview/values.yaml", "valuesFiles:\n - ./charts/$APP_NAME/values.yaml\n - ./staging/values.yaml", "valuesFiles:\n - ./charts/$APP_NAME/values.yaml\n - ./production/values.yaml", "hostname: $APP_NAME-pr-$PR_NUMBER.example.test"} {
|
||||||
|
if !strings.Contains(string(content), expected) {
|
||||||
|
t.Fatalf("generated delivery does not contain protected platform values %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unexpected := range []string{"maidn-git-clone", "maidn-node-static-build", "maidn-node-static-push", "runAfter: [clone]", "name: source", "workspace: source", "workspace: artifact", "easycsr", "test-org", "git rm -r", "$APP_URL", "$APP_REVISION", "platform-url", "platform-revision", "/tmp/platform", "/tmp/app/preview/values.yaml"} {
|
||||||
if strings.Contains(string(content), unexpected) {
|
if strings.Contains(string(content), unexpected) {
|
||||||
t.Fatalf("generated delivery contains unexpected %q", unexpected)
|
t.Fatalf("generated delivery contains unexpected %q", unexpected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGeneratedDeliveryUsesRuntimeImageWithoutStaticParameters(t *testing.T) {
|
||||||
|
cfg := config.Config{
|
||||||
|
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
|
||||||
|
Flux: config.FluxConfig{Branch: "main", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
|
||||||
|
Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "runtime", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"},
|
||||||
|
}
|
||||||
|
content, err := renderAppDelivery(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"maidn-node-runtime-image", "name: url\n value: \"https://git.example.test/apps/web-ui.git\"", "name: revision\n value: $(params.git-revision)", "name: image\n value: $(params.image)"} {
|
||||||
|
if !strings.Contains(string(content), expected) {
|
||||||
|
t.Fatalf("generated runtime delivery does not contain %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unexpected := range []string{"maidn-node-static-image", "name: output-directory", "name: build-configuration"} {
|
||||||
|
if strings.Contains(string(content), unexpected) {
|
||||||
|
t.Fatalf("generated runtime delivery contains static-only %q", unexpected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.T) {
|
func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.T) {
|
||||||
cfg := config.Config{
|
cfg := config.Config{
|
||||||
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
|
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
|
||||||
Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"},
|
Flux: config.FluxConfig{Branch: "main", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
|
||||||
Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildOutputDirectory: "dist", BuildConfiguration: "production"},
|
Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production"},
|
||||||
}
|
}
|
||||||
content, err := renderAppDelivery(cfg)
|
content, err := renderAppDelivery(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -163,14 +190,10 @@ func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateAppDeliveryRequiresCompleteConfig(t *testing.T) {
|
func TestRenderAppDeliveryRequiresCompleteConfig(t *testing.T) {
|
||||||
dir := t.TempDir()
|
_, err := renderAppDelivery(config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}})
|
||||||
err := GenerateAppDelivery(dir, config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}})
|
if err == nil || !strings.Contains(err.Error(), "delivery buildStrategy") {
|
||||||
if err == nil || !strings.Contains(err.Error(), "delivery appName") {
|
t.Fatalf("renderAppDelivery() error = %v, want incomplete delivery error", err)
|
||||||
t.Fatalf("GenerateAppDelivery() error = %v, want incomplete delivery error", err)
|
|
||||||
}
|
|
||||||
if _, statErr := os.Stat(filepath.Join(dir, ".tekton")); !os.IsNotExist(statErr) {
|
|
||||||
t.Fatal("GenerateAppDelivery() wrote delivery files before rejecting incomplete config")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -982,12 +1005,14 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
|
||||||
originalPreflight := preflight
|
originalPreflight := preflight
|
||||||
originalGit := runGit
|
originalGit := runGit
|
||||||
originalInitialize := initializeOpenBao
|
originalInitialize := initializeOpenBao
|
||||||
|
originalRead := readOperationalSecrets
|
||||||
originalCommand := runWebhookCommand
|
originalCommand := runWebhookCommand
|
||||||
originalWebhook := ensureForgejoWebhook
|
originalWebhook := ensureForgejoWebhook
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
preflight = originalPreflight
|
preflight = originalPreflight
|
||||||
runGit = originalGit
|
runGit = originalGit
|
||||||
initializeOpenBao = originalInitialize
|
initializeOpenBao = originalInitialize
|
||||||
|
readOperationalSecrets = originalRead
|
||||||
runWebhookCommand = originalCommand
|
runWebhookCommand = originalCommand
|
||||||
ensureForgejoWebhook = originalWebhook
|
ensureForgejoWebhook = originalWebhook
|
||||||
})
|
})
|
||||||
|
|
@ -1006,9 +1031,12 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
|
||||||
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
|
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
|
||||||
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
|
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
|
||||||
}
|
}
|
||||||
|
readOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
|
||||||
|
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
|
||||||
|
}
|
||||||
runWebhookCommand = func(_ string, _ string, args ...string) ([]byte, error) {
|
runWebhookCommand = func(_ string, _ string, args ...string) ([]byte, error) {
|
||||||
if strings.Contains(strings.Join(args, " "), "secret/forgejo-webhook") {
|
if strings.Contains(strings.Join(args, " "), "externalsecret/forgejo-webhook") {
|
||||||
return []byte(base64.StdEncoding.EncodeToString([]byte(authorization))), nil
|
return []byte("True"), nil
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -1038,14 +1066,16 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) {
|
func TestReconcileWebhookWaitsForReadyExternalSecret(t *testing.T) {
|
||||||
originalInitialize := initializeOpenBao
|
originalInitialize := initializeOpenBao
|
||||||
|
originalRead := readOperationalSecrets
|
||||||
originalCommand := runWebhookCommand
|
originalCommand := runWebhookCommand
|
||||||
originalWebhook := ensureForgejoWebhook
|
originalWebhook := ensureForgejoWebhook
|
||||||
originalTimeout := webhookTargetTimeout
|
originalTimeout := webhookTargetTimeout
|
||||||
originalInterval := webhookTargetPollInterval
|
originalInterval := webhookTargetPollInterval
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
initializeOpenBao = originalInitialize
|
initializeOpenBao = originalInitialize
|
||||||
|
readOperationalSecrets = originalRead
|
||||||
runWebhookCommand = originalCommand
|
runWebhookCommand = originalCommand
|
||||||
ensureForgejoWebhook = originalWebhook
|
ensureForgejoWebhook = originalWebhook
|
||||||
webhookTargetTimeout = originalTimeout
|
webhookTargetTimeout = originalTimeout
|
||||||
|
|
@ -1053,21 +1083,19 @@ func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
authorization := "Bearer test-webhook-authorization"
|
authorization := "Bearer test-webhook-authorization"
|
||||||
staleTarget := base64.StdEncoding.EncodeToString([]byte("Bearer stale-webhook-authorization"))
|
|
||||||
refreshedTarget := base64.StdEncoding.EncodeToString([]byte(authorization))
|
|
||||||
targetChecks := 0
|
targetChecks := 0
|
||||||
refreshedObserved := false
|
refreshedObserved := false
|
||||||
runWebhookCommand = func(_ string, name string, args ...string) ([]byte, error) {
|
runWebhookCommand = func(_ string, name string, args ...string) ([]byte, error) {
|
||||||
if name != "kubectl" || strings.Contains(strings.Join(args, " "), authorization) {
|
if name != "kubectl" || strings.Contains(strings.Join(args, " "), authorization) {
|
||||||
t.Fatal("webhook target probe used an unexpected command")
|
t.Fatal("webhook target probe used an unexpected command")
|
||||||
}
|
}
|
||||||
if strings.Contains(strings.Join(args, " "), "secret/forgejo-webhook") {
|
if strings.Contains(strings.Join(args, " "), "externalsecret/forgejo-webhook") {
|
||||||
targetChecks++
|
targetChecks++
|
||||||
if targetChecks == 1 {
|
if targetChecks == 1 {
|
||||||
return []byte(staleTarget), nil
|
return []byte("False"), nil
|
||||||
}
|
}
|
||||||
refreshedObserved = true
|
refreshedObserved = true
|
||||||
return []byte(refreshedTarget), nil
|
return []byte("True"), nil
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -1076,7 +1104,7 @@ func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) {
|
||||||
patches := 0
|
patches := 0
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
if !refreshedObserved {
|
if !refreshedObserved {
|
||||||
t.Error("Forgejo was called before the refreshed webhook Secret was observed")
|
t.Error("Forgejo was called before the refreshed webhook ExternalSecret was ready")
|
||||||
writer.WriteHeader(http.StatusInternalServerError)
|
writer.WriteHeader(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1108,24 +1136,29 @@ func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) {
|
||||||
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
|
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
|
||||||
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
|
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
|
||||||
}
|
}
|
||||||
|
readOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
|
||||||
|
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
cfg := config.Config{Delivery: config.DeliveryConfig{AppName: "app", WebhookHostname: "tekton.example.test", WebhookPath: "/"}}
|
cfg := config.Config{Delivery: config.DeliveryConfig{AppName: "app", WebhookHostname: "tekton.example.test", WebhookPath: "/"}}
|
||||||
if err := (Runner{Config: cfg}).reconcileWebhook(t.TempDir()); err != nil {
|
if err := (Runner{Config: cfg}).reconcileWebhook(t.TempDir()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if targetChecks != 2 || patches != 1 {
|
if targetChecks != 2 || patches != 1 {
|
||||||
t.Fatal("Forgejo webhook was not updated after the target Secret refreshed")
|
t.Fatal("Forgejo webhook was not updated after the ExternalSecret became ready")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) {
|
func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) {
|
||||||
originalInitialize := initializeOpenBao
|
originalInitialize := initializeOpenBao
|
||||||
|
originalRead := readOperationalSecrets
|
||||||
originalCommand := runWebhookCommand
|
originalCommand := runWebhookCommand
|
||||||
originalWebhook := ensureForgejoWebhook
|
originalWebhook := ensureForgejoWebhook
|
||||||
originalTimeout := webhookTargetTimeout
|
originalTimeout := webhookTargetTimeout
|
||||||
originalInterval := webhookTargetPollInterval
|
originalInterval := webhookTargetPollInterval
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
initializeOpenBao = originalInitialize
|
initializeOpenBao = originalInitialize
|
||||||
|
readOperationalSecrets = originalRead
|
||||||
runWebhookCommand = originalCommand
|
runWebhookCommand = originalCommand
|
||||||
ensureForgejoWebhook = originalWebhook
|
ensureForgejoWebhook = originalWebhook
|
||||||
webhookTargetTimeout = originalTimeout
|
webhookTargetTimeout = originalTimeout
|
||||||
|
|
@ -1136,8 +1169,11 @@ func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) {
|
||||||
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
|
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
|
||||||
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
|
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
|
||||||
}
|
}
|
||||||
|
readOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
|
||||||
|
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
|
||||||
|
}
|
||||||
runWebhookCommand = func(_ string, _ string, _ ...string) ([]byte, error) {
|
runWebhookCommand = func(_ string, _ string, _ ...string) ([]byte, error) {
|
||||||
return []byte(base64.StdEncoding.EncodeToString([]byte("Bearer stale-webhook-authorization"))), nil
|
return []byte("False"), nil
|
||||||
}
|
}
|
||||||
webhookTargetTimeout = -time.Nanosecond
|
webhookTargetTimeout = -time.Nanosecond
|
||||||
webhookTargetPollInterval = 0
|
webhookTargetPollInterval = 0
|
||||||
|
|
@ -1152,7 +1188,7 @@ func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) {
|
||||||
t.Fatal("webhook refresh timeout did not return a safe rerun error")
|
t.Fatal("webhook refresh timeout did not return a safe rerun error")
|
||||||
}
|
}
|
||||||
if webhookUpdated {
|
if webhookUpdated {
|
||||||
t.Fatal("Forgejo webhook update was attempted before the target Secret refreshed")
|
t.Fatal("Forgejo webhook update was attempted before the ExternalSecret became ready")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,27 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OnboardApp imports one clean checkout into the configured Forgejo owner,
|
type onboardingRepoManager interface {
|
||||||
// publishes its delivery branch, registers it with Flux, then adds its webhook.
|
EnsureRepository(string, string) (bool, error)
|
||||||
|
RemoteBranchRevision(string, string) (string, error)
|
||||||
|
PushRef(string, string, string, string) error
|
||||||
|
EnsureProtectedBranch(string, string) error
|
||||||
|
PublishRepositoryPullRequest(string, string, string, string, func(string) error) (bool, error)
|
||||||
|
EnsureWebhook(string, string, string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var newOnboardingRepoManager = func(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch string) onboardingRepoManager {
|
||||||
|
return forgejo.NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnboardApp opens a reviewed central cluster-registration pull request.
|
||||||
func OnboardApp(cfg config.Config, sourceDir string) error {
|
func OnboardApp(cfg config.Config, sourceDir string) error {
|
||||||
resolved, err := config.ResolveAppOnboarding(cfg)
|
resolved, err := config.ResolveAppOnboarding(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -30,56 +43,35 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
|
||||||
if sourceBranch != resolved.Delivery.AppRepoRef {
|
if sourceBranch != resolved.Delivery.AppRepoRef {
|
||||||
return errors.New("--from branch must match delivery appRepoRef")
|
return errors.New("--from branch must match delivery appRepoRef")
|
||||||
}
|
}
|
||||||
|
sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
owner, repository, err := forgejo.RepositoryFromURL(resolved.Delivery.AppRepoURL)
|
owner, repository, err := forgejo.RepositoryFromURL(resolved.Delivery.AppRepoURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if owner != resolved.Git.Owner {
|
sourceManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, owner, resolved.Git.Username, "", "", resolved.Delivery.AppRepoRef, "")
|
||||||
return errors.New("delivery appRepoUrl owner must match git owner for app onboarding")
|
if _, err := sourceManager.EnsureRepository(repository, "Application build input for Maidn CI/CD"); err != nil {
|
||||||
}
|
|
||||||
deliveryBranch, err := forgejo.DeliveryBranch(resolved.Delivery.AppName, resolved.Delivery.AppRepoRef)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
manager := forgejo.NewRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Delivery.AppRepoRef, "")
|
if err := publishInitialAppBranches(sourceManager, sourceDir, resolved.Delivery.AppRepoURL, sourceRevision, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil {
|
||||||
if _, err := manager.EnsureRepository(repository, "Application source for Maidn CI/CD delivery"); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := publishInitialAppBranches(manager, sourceDir, resolved.Delivery.AppRepoURL, sourceBranch, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil {
|
if err := sourceManager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil {
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := manager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil {
|
|
||||||
return fmt.Errorf("protect Forgejo production branch: %w", err)
|
return fmt.Errorf("protect Forgejo production branch: %w", err)
|
||||||
}
|
}
|
||||||
changed, err := manager.PublishDeliveryBranch(sourceDir, sourceBranch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error {
|
if err := ensurePlatformBranch(sourceManager, repository, resolved.Delivery.AppRepoURL, resolved.Delivery.AppName); err != nil {
|
||||||
if err := GenerateAppDelivery(dir, resolved); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return GenerateAppSecretAccess(dir, resolved)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if changed {
|
registrationBranch := registrationBranch(resolved.Delivery.AppName, sourceRevision)
|
||||||
if err := manager.EnsurePullRequest(repository, "feat: migrate delivery to Tekton", deliveryBranch, resolved.Delivery.AppRepoRef); err != nil {
|
clusterManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "")
|
||||||
return err
|
if _, err := clusterManager.PublishRepositoryPullRequest(resolved.Flux.RepoName, "feat: register "+resolved.Delivery.AppName+" delivery", registrationBranch, resolved.Flux.Branch, func(dir string) error {
|
||||||
}
|
return registerAppInCluster(dir, resolved)
|
||||||
}
|
|
||||||
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 {
|
}); err != nil {
|
||||||
return fmt.Errorf("register app in cluster repository: %w", err)
|
return fmt.Errorf("register app in cluster repository: %w", err)
|
||||||
}
|
}
|
||||||
secrets, err := ReadOperationalSecrets(resolved.SOPS.OperationalSecretsPath, resolved.SOPS.AgeKeyPath)
|
secrets, err := readOperationalSecrets(resolved.SOPS.OperationalSecretsPath, resolved.SOPS.AgeKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read encrypted webhook authorization: %w", err)
|
return fmt.Errorf("read encrypted webhook authorization: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -88,25 +80,41 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
|
||||||
return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization")
|
return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization")
|
||||||
}
|
}
|
||||||
generatedDir := filepath.Join(resolved.Git.CloneParent, resolved.Talos.RepoDirName, resolved.Talos.GeneratedDir)
|
generatedDir := filepath.Join(resolved.Git.CloneParent, resolved.Talos.RepoDirName, resolved.Talos.GeneratedDir)
|
||||||
if err := waitForWebhookTargets(generatedDir, resolved, authorization); err != nil {
|
if err := waitForWebhookTargets(generatedDir, resolved); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
webhookURL := "https://tekton." + resolved.Flux.ClusterDomain + "/"
|
webhookURL := "https://tekton." + resolved.Flux.ClusterDomain + "/"
|
||||||
if err := manager.EnsureWebhook(repository, webhookURL, authorization); err != nil {
|
if err := sourceManager.EnsureWebhook(repository, webhookURL, authorization); err != nil {
|
||||||
return fmt.Errorf("register Forgejo webhook: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishInitialAppBranches establishes the immutable source baseline before delivery setup.
|
func registrationBranch(appName, revision string) string {
|
||||||
func publishInitialAppBranches(manager *forgejo.RepoManager, sourceDir, targetURL, sourceBranch, targetBranch, productionBranch string) error {
|
return "maidn/register-" + appName + "-" + revision[:12]
|
||||||
sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch)
|
}
|
||||||
|
|
||||||
|
func platformBranch(appName string) string {
|
||||||
|
return "maidn/platform-" + appName
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensurePlatformBranch(manager onboardingRepoManager, repository, repositoryURL, appName string) error {
|
||||||
|
branch := platformBranch(appName)
|
||||||
|
revision, err := manager.RemoteBranchRevision(repositoryURL, branch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("read Forgejo platform branch: %w", err)
|
||||||
}
|
}
|
||||||
|
if revision == "" {
|
||||||
|
return fmt.Errorf("approved Forgejo platform branch %q must exist before central registration", branch)
|
||||||
|
}
|
||||||
|
if err := manager.EnsureProtectedBranch(repository, branch); err != nil {
|
||||||
|
return fmt.Errorf("protect Forgejo platform branch: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishInitialAppBranches establishes the immutable source baseline before central registration.
|
||||||
|
func publishInitialAppBranches(manager onboardingRepoManager, sourceDir, targetURL, sourceRevision, targetBranch, productionBranch string) error {
|
||||||
mainRevision, err := manager.RemoteBranchRevision(targetURL, targetBranch)
|
mainRevision, err := manager.RemoteBranchRevision(targetURL, targetBranch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read target base branch: %w", err)
|
return fmt.Errorf("read target base branch: %w", err)
|
||||||
|
|
@ -143,6 +151,10 @@ func publishInitialAppBranches(manager *forgejo.RepoManager, sourceDir, targetUR
|
||||||
|
|
||||||
// RegisterAppInCluster writes only the managed Flux registration for one app.
|
// RegisterAppInCluster writes only the managed Flux registration for one app.
|
||||||
func RegisterAppInCluster(dir string, cfg config.Config) error {
|
func RegisterAppInCluster(dir string, cfg config.Config) error {
|
||||||
|
return registerAppInCluster(dir, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAppInCluster(dir string, cfg config.Config) error {
|
||||||
content, err := renderAppRegistration(cfg)
|
content, err := renderAppRegistration(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -204,7 +216,11 @@ func renderAppRegistration(cfg config.Config) ([]byte, error) {
|
||||||
if err := config.ValidateDelivery(cfg); err != nil {
|
if err := config.ValidateDelivery(cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
branch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef)
|
secretAccess, err := renderAppSecretAccess(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
delivery, err := renderAppDelivery(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -220,67 +236,25 @@ spec:
|
||||||
name: forgejo-flux-credentials
|
name: forgejo-flux-credentials
|
||||||
ref:
|
ref:
|
||||||
branch: %s
|
branch: %s
|
||||||
---
|
`, cfg.Delivery.AppName, cfg.Delivery.AppRepoURL, platformBranch(cfg.Delivery.AppName))
|
||||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
if len(secretAccess) != 0 {
|
||||||
kind: Kustomization
|
content += "---\n" + string(secretAccess)
|
||||||
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)
|
|
||||||
if hasRuntimeSecretGrant(cfg) {
|
|
||||||
content += fmt.Sprintf(`---
|
|
||||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
|
||||||
kind: Kustomization
|
|
||||||
metadata:
|
|
||||||
name: %s-secrets
|
|
||||||
namespace: flux-system
|
|
||||||
spec:
|
|
||||||
interval: 5m
|
|
||||||
path: ./.maidn
|
|
||||||
prune: true
|
|
||||||
wait: true
|
|
||||||
dependsOn:
|
|
||||||
- name: external-secrets-config
|
|
||||||
sourceRef:
|
|
||||||
kind: GitRepository
|
|
||||||
name: %s
|
|
||||||
`, cfg.Delivery.AppName, cfg.Delivery.AppName)
|
|
||||||
}
|
}
|
||||||
return []byte(content), nil
|
return append([]byte(content+"---\n"), delivery...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasRuntimeSecretGrant(cfg config.Config) bool {
|
// renderAppSecretAccess renders only central OpenBao references, never secret values.
|
||||||
for _, grant := range cfg.SecretGrants {
|
func renderAppSecretAccess(cfg config.Config) ([]byte, error) {
|
||||||
if grant.Application == cfg.Delivery.AppName && grant.Consumer == "runtime" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateAppSecretAccess renders only OpenBao references, never secret values.
|
|
||||||
func GenerateAppSecretAccess(dir string, cfg config.Config) error {
|
|
||||||
if err := config.ValidateSecretGrants(cfg.SecretGrants); err != nil {
|
if err := config.ValidateSecretGrants(cfg.SecretGrants); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
var manifests string
|
var manifests []string
|
||||||
for _, grant := range cfg.SecretGrants {
|
for _, grant := range cfg.SecretGrants {
|
||||||
if grant.Application != cfg.Delivery.AppName || grant.Consumer != "runtime" {
|
if grant.Application != cfg.Delivery.AppName || grant.Consumer != "runtime" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := cfg.Delivery.AppName + "-runtime-" + grant.Environment
|
name := cfg.Delivery.AppName + "-runtime-" + grant.Environment
|
||||||
manifests += fmt.Sprintf(`apiVersion: v1
|
manifest := fmt.Sprintf(`apiVersion: v1
|
||||||
kind: ServiceAccount
|
kind: ServiceAccount
|
||||||
metadata:
|
metadata:
|
||||||
name: maidn-%s
|
name: maidn-%s
|
||||||
|
|
@ -320,21 +294,11 @@ spec:
|
||||||
data:
|
data:
|
||||||
`, name, grant.Environment, name, grant.Environment, name, name, name, grant.Environment, name, name)
|
`, name, grant.Environment, name, grant.Environment, name, name, name, grant.Environment, name, name)
|
||||||
for _, secret := range grant.Secrets {
|
for _, secret := range grant.Secrets {
|
||||||
manifests += fmt.Sprintf(" - secretKey: %s\n remoteRef:\n key: apps/%s/%s\n property: value\n", secret, grant.Application, secret)
|
manifest += fmt.Sprintf(" - secretKey: %s\n remoteRef:\n key: apps/%s/%s\n property: value\n", secret, grant.Application, secret)
|
||||||
}
|
}
|
||||||
manifests += "---\n"
|
manifests = append(manifests, manifest)
|
||||||
}
|
}
|
||||||
if manifests == "" {
|
return []byte(strings.Join(manifests, "---\n")), nil
|
||||||
return nil
|
|
||||||
}
|
|
||||||
maidnDir := filepath.Join(dir, ".maidn")
|
|
||||||
if err := os.MkdirAll(maidnDir, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(maidnDir, "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - secret-access.yaml\n"), 0644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(filepath.Join(maidnDir, "secret-access.yaml"), []byte(manifests), 0644)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readRegularFile(path string) ([]byte, error) {
|
func readRegularFile(path string) ([]byte, error) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package bootstrap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
@ -31,51 +32,13 @@ func onboardingConfig() config.Config {
|
||||||
Flux: config.FluxConfig{Branch: "main", RepoName: "cluster", ManifestsRepo: "manifests", ClusterDomain: "example.test"},
|
Flux: config.FluxConfig{Branch: "main", RepoName: "cluster", ManifestsRepo: "manifests", ClusterDomain: "example.test"},
|
||||||
Delivery: config.DeliveryConfig{
|
Delivery: config.DeliveryConfig{
|
||||||
AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org-2/web-ui.git", AppRepoRef: "main", ProductionBranch: "production",
|
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",
|
ImageRepository: "registry.example.test/test-org-2/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production",
|
||||||
WebhookHostname: "tekton.example.test", WebhookPath: "/",
|
WebhookHostname: "tekton.example.test", WebhookPath: "/",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateAppDeliveryReplacesOnlyKnownGeneratedFiles(t *testing.T) {
|
func TestRegisterAppInClusterRendersCentralDeliveryResources(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"))
|
|
||||||
normalized := strings.ReplaceAll(string(pipeline), "\r\n", "\n")
|
|
||||||
if err != nil || !strings.Contains(normalized, "https://git.example.test/test-org-2/web-ui.git") || !strings.Contains(normalized, "name: HOME\n value: /tekton/home") || !strings.Contains(normalized, "grep -qxF \" namespace: staging\"") || !strings.Contains(normalized, "namespace: production") || !strings.Contains(normalized, "name: web-ui-wait-delivery") || !strings.Contains(normalized, "name: web-ui-report-delivery") || !strings.Contains(normalized, "forgejo-delivery-status") || !strings.Contains(normalized, "$(context.pipelineRun.name)") || !strings.Contains(normalized, "Promotion PR opened or updated") || strings.Contains(normalized, "taskRunTemplate:") {
|
|
||||||
t.Fatalf("target-specific pipeline = %q, %v", pipeline, err)
|
|
||||||
}
|
|
||||||
decoder := yaml.NewDecoder(bytes.NewReader(pipeline))
|
|
||||||
for {
|
|
||||||
var document yaml.Node
|
|
||||||
err := decoder.Decode(&document)
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generated pipeline YAML: %v", 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()
|
dir := t.TempDir()
|
||||||
tektonDir := filepath.Join(dir, "base", "tekton")
|
tektonDir := filepath.Join(dir, "base", "tekton")
|
||||||
if err := os.MkdirAll(tektonDir, 0755); err != nil {
|
if err := os.MkdirAll(tektonDir, 0755); err != nil {
|
||||||
|
|
@ -88,7 +51,7 @@ func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
registration, err := os.ReadFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"))
|
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") {
|
if err != nil || !strings.Contains(string(registration), "branch: maidn/platform-web-ui") || !strings.Contains(string(registration), "secretRef:\n name: forgejo-flux-credentials") || !strings.Contains(string(registration), "kind: Task") || !strings.Contains(string(registration), "name: web-ui-update-manifest") || !strings.Contains(string(registration), "kind: Pipeline") || !strings.Contains(string(registration), "name: web-ui\n") || strings.Contains(string(registration), "maidn/delivery-") || strings.Contains(string(registration), "path: ./.tekton") || strings.Contains(string(registration), "apiVersion: kustomize.toolkit.fluxcd.io") {
|
||||||
t.Fatalf("registration = %q, %v", registration, err)
|
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"} {
|
for path, resource := range map[string]string{filepath.Join(tektonDir, "kustomization.yaml"): "apps", filepath.Join(tektonDir, "apps", "kustomization.yaml"): "web-ui.yaml"} {
|
||||||
|
|
@ -97,6 +60,17 @@ func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) {
|
||||||
t.Fatalf("Kustomization %s does not include %s: %q, %v", path, resource, content, err)
|
t.Fatalf("Kustomization %s does not include %s: %q, %v", path, resource, content, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
decoder := yaml.NewDecoder(bytes.NewReader(registration))
|
||||||
|
for {
|
||||||
|
var document yaml.Node
|
||||||
|
err := decoder.Decode(&document)
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("registration YAML: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := os.WriteFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"), bytes.ReplaceAll(registration, []byte("\n"), []byte("\r\n")), 0644); err != nil {
|
if err := os.WriteFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"), bytes.ReplaceAll(registration, []byte("\n"), []byte("\r\n")), 0644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -111,19 +85,11 @@ func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateAppSecretAccessRendersOnlyDeclaredRuntimeSecrets(t *testing.T) {
|
func TestRenderAppRegistrationIncludesOnlyDeclaredRuntimeSecretAccess(t *testing.T) {
|
||||||
dir := t.TempDir()
|
|
||||||
cfg := onboardingConfig()
|
cfg := onboardingConfig()
|
||||||
cfg.SecretGrants = []config.SecretGrant{{Application: "web-ui", Consumer: "runtime", Environment: "staging", Secrets: []string{"api-key"}, Shared: []string{"payments"}}}
|
cfg.SecretGrants = []config.SecretGrant{{Application: "web-ui", Consumer: "runtime", Environment: "staging", Secrets: []string{"api-key"}, Shared: []string{"payments"}}}
|
||||||
if err := GenerateAppSecretAccess(dir, cfg); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
access, err := os.ReadFile(filepath.Join(dir, ".maidn", "secret-access.yaml"))
|
|
||||||
if err != nil || !strings.Contains(string(access), "namespace: staging") || !strings.Contains(string(access), "key: apps/web-ui/api-key") || strings.Contains(string(access), "shared/payments") {
|
|
||||||
t.Fatalf("secret access = %q, %v", access, err)
|
|
||||||
}
|
|
||||||
registration, err := renderAppRegistration(cfg)
|
registration, err := renderAppRegistration(cfg)
|
||||||
if err != nil || !strings.Contains(string(registration), "name: web-ui-secrets") || !strings.Contains(string(registration), "path: ./.maidn") {
|
if err != nil || !strings.Contains(string(registration), "namespace: staging") || !strings.Contains(string(registration), "key: apps/web-ui/api-key") || strings.Contains(string(registration), "shared/payments") || strings.Contains(string(registration), ".maidn") {
|
||||||
t.Fatalf("secret registration = %q, %v", registration, err)
|
t.Fatalf("secret registration = %q, %v", registration, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -146,7 +112,7 @@ func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
|
||||||
onboardingGit(t, "", "init", "--bare", target)
|
onboardingGit(t, "", "init", "--bare", target)
|
||||||
|
|
||||||
manager := forgejo.NewRepoManager("https://git.example.test", "", "owner", "", "", "", "main", "")
|
manager := forgejo.NewRepoManager("https://git.example.test", "", "owner", "", "", "", "main", "")
|
||||||
if err := publishInitialAppBranches(manager, source, target, "source", "main", "production"); err != nil {
|
if err := publishInitialAppBranches(manager, source, target, sourceRevision, "main", "production"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, branch := range []string{"main", "production"} {
|
for _, branch := range []string{"main", "production"} {
|
||||||
|
|
@ -173,7 +139,7 @@ func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
|
||||||
existingProduction := onboardingGit(t, production, "rev-parse", "production")
|
existingProduction := onboardingGit(t, production, "rev-parse", "production")
|
||||||
onboardingGit(t, production, "push", preservedTarget, "production:production")
|
onboardingGit(t, production, "push", preservedTarget, "production:production")
|
||||||
|
|
||||||
if err := publishInitialAppBranches(manager, source, preservedTarget, "source", "main", "production"); err != nil {
|
if err := publishInitialAppBranches(manager, source, preservedTarget, sourceRevision, "main", "production"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/production"); got != existingProduction {
|
if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/production"); got != existingProduction {
|
||||||
|
|
@ -183,10 +149,139 @@ func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
onboardingGit(t, source, "commit", "-am", "updated source")
|
onboardingGit(t, source, "commit", "-am", "updated source")
|
||||||
if err := publishInitialAppBranches(manager, source, preservedTarget, "source", "main", "production"); err != nil {
|
if err := publishInitialAppBranches(manager, source, preservedTarget, sourceRevision, "main", "production"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/main"); got != sourceRevision {
|
if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/main"); got != sourceRevision {
|
||||||
t.Fatalf("main = %s, want existing %s", got, sourceRevision)
|
t.Fatalf("main = %s, want existing %s", got, sourceRevision)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type onboardingManagerFake struct {
|
||||||
|
owner string
|
||||||
|
calls []string
|
||||||
|
remoteRevisions map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *onboardingManagerFake) EnsureRepository(repo, _ string) (bool, error) {
|
||||||
|
m.calls = append(m.calls, "ensure "+m.owner+"/"+repo)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *onboardingManagerFake) RemoteBranchRevision(targetURL, branch string) (string, error) {
|
||||||
|
m.calls = append(m.calls, "remote "+targetURL+":"+branch)
|
||||||
|
if m.remoteRevisions != nil {
|
||||||
|
return m.remoteRevisions[branch], nil
|
||||||
|
}
|
||||||
|
return "existing", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *onboardingManagerFake) PushRef(_, targetURL, _, targetBranch string) error {
|
||||||
|
m.calls = append(m.calls, "push "+targetURL+":"+targetBranch)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *onboardingManagerFake) EnsureProtectedBranch(repo, branch string) error {
|
||||||
|
m.calls = append(m.calls, "protect "+m.owner+"/"+repo+":"+branch)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *onboardingManagerFake) HasOpenPullRequest(repo, branch string) (bool, error) {
|
||||||
|
m.calls = append(m.calls, "open-pr "+m.owner+"/"+repo+":"+branch)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *onboardingManagerFake) MergePullRequest(repo, branch string) error {
|
||||||
|
m.calls = append(m.calls, "merge-pr "+m.owner+"/"+repo+":"+branch)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *onboardingManagerFake) PublishRepositoryPullRequest(repo, _, branch, _ string, _ func(string) error) (bool, error) {
|
||||||
|
m.calls = append(m.calls, "register "+m.owner+"/"+repo+":"+branch)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *onboardingManagerFake) EnsureWebhook(repo, _, _ string) error {
|
||||||
|
m.calls = append(m.calls, "webhook "+m.owner+"/"+repo)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *onboardingManagerFake) TriggerWebhookTest(repo, _, branch string) error {
|
||||||
|
m.calls = append(m.calls, "webhook-test "+m.owner+"/"+repo+":"+branch)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnboardAppUsesCanonicalSourceAndCentralClusterManagers(t *testing.T) {
|
||||||
|
source := filepath.Join(t.TempDir(), "source")
|
||||||
|
if err := os.Mkdir(source, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
onboardingGit(t, source, "init", "-b", "main")
|
||||||
|
onboardingGit(t, source, "config", "user.name", "Test")
|
||||||
|
onboardingGit(t, source, "config", "user.email", "test@example.test")
|
||||||
|
if err := os.WriteFile(filepath.Join(source, "README.md"), []byte("source\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
onboardingGit(t, source, "add", "README.md")
|
||||||
|
onboardingGit(t, source, "commit", "-m", "source")
|
||||||
|
|
||||||
|
cfg := onboardingConfig()
|
||||||
|
cfg.Git.Username, cfg.Git.Token = "bot", "test-token"
|
||||||
|
cfg.Delivery.AppRepoURL = "https://git.example.test/Maidn/maidn-e2e-web.git"
|
||||||
|
sourceManager := &onboardingManagerFake{owner: "Maidn"}
|
||||||
|
clusterManager := &onboardingManagerFake{owner: cfg.Git.Owner}
|
||||||
|
originalManager, originalSecrets := newOnboardingRepoManager, readOperationalSecrets
|
||||||
|
t.Cleanup(func() {
|
||||||
|
newOnboardingRepoManager, readOperationalSecrets = originalManager, originalSecrets
|
||||||
|
})
|
||||||
|
newOnboardingRepoManager = func(_, _, owner, _, _, _, _, _ string) onboardingRepoManager {
|
||||||
|
switch owner {
|
||||||
|
case "Maidn":
|
||||||
|
return sourceManager
|
||||||
|
case cfg.Git.Owner:
|
||||||
|
return clusterManager
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected onboarding manager owner %q", owner)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
|
||||||
|
return nil, errors.New("stop after registration")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := OnboardApp(cfg, source)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "read encrypted webhook authorization") {
|
||||||
|
t.Fatalf("OnboardApp() = %v", err)
|
||||||
|
}
|
||||||
|
sourceCalls := strings.Join(sourceManager.calls, "\n")
|
||||||
|
for _, want := range []string{
|
||||||
|
"ensure Maidn/maidn-e2e-web",
|
||||||
|
"remote https://git.example.test/Maidn/maidn-e2e-web.git:main",
|
||||||
|
"remote https://git.example.test/Maidn/maidn-e2e-web.git:production",
|
||||||
|
"protect Maidn/maidn-e2e-web:production",
|
||||||
|
"remote https://git.example.test/Maidn/maidn-e2e-web.git:maidn/platform-web-ui",
|
||||||
|
"protect Maidn/maidn-e2e-web:maidn/platform-web-ui",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(sourceCalls, want) {
|
||||||
|
t.Fatalf("source manager calls = %q, missing %q", sourceCalls, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(sourceCalls, "delivery") || strings.Contains(sourceCalls, "ensure-pr") || strings.Contains(sourceCalls, "merge-pr") {
|
||||||
|
t.Fatalf("source manager published a delivery change: %q", sourceCalls)
|
||||||
|
}
|
||||||
|
sourceRevision := onboardingGit(t, source, "rev-parse", "main")
|
||||||
|
if got := strings.Join(clusterManager.calls, "\n"); got != "register test-org-2/cluster:maidn/register-web-ui-"+sourceRevision[:12] {
|
||||||
|
t.Fatalf("cluster manager calls = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePlatformBranchRequiresExistingBranch(t *testing.T) {
|
||||||
|
manager := &onboardingManagerFake{owner: "Maidn", remoteRevisions: map[string]string{}}
|
||||||
|
err := ensurePlatformBranch(manager, "maidn-e2e-web", "https://git.example.test/Maidn/maidn-e2e-web.git", "web-ui")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "maidn/platform-web-ui") {
|
||||||
|
t.Fatalf("ensurePlatformBranch() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.Join(manager.calls, "\n"), "protect") {
|
||||||
|
t.Fatalf("missing platform branch was protected: %q", manager.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,20 +40,61 @@ func TestEnsureTemplateRevisionsReusesLockedCommitAfterBranchDrift(t *testing.T)
|
||||||
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
|
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
locked := git.commits["cicd"]
|
locked := map[string]string{}
|
||||||
git.commits["cicd"] = strings.Repeat("d", 40)
|
for name, commit := range git.commits {
|
||||||
|
locked[name] = commit
|
||||||
|
}
|
||||||
|
git.commits = map[string]string{"cicd": strings.Repeat("d", 40), "manifests": strings.Repeat("e", 40), "talos": strings.Repeat("f", 40)}
|
||||||
git.resetCalls()
|
git.resetCalls()
|
||||||
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
|
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if git.fetched["cicd"] != locked || git.checkedOut["cicd"] != locked {
|
lock, err := readTemplateRevisionLock(filepath.Join(workspace, "maidn-template-revisions.yaml"))
|
||||||
t.Fatalf("branch drift changed locked CICD revision: fetched %q, checked out %q", git.fetched["cicd"], git.checkedOut["cicd"])
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, commit := range locked {
|
||||||
|
if git.fetched[name] != commit || git.checkedOut[name] != commit {
|
||||||
|
t.Fatalf("branch drift changed locked %s revision: fetched %q, checked out %q", name, git.fetched[name], git.checkedOut[name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lock.CICD.Commit != locked["cicd"] || lock.Manifests.Commit != locked["manifests"] || lock.Talos.Commit != locked["talos"] {
|
||||||
|
t.Fatalf("normal bootstrap rewrote template lock: %#v", lock)
|
||||||
}
|
}
|
||||||
if strings.Contains(git.commands(), "fetch origin main") {
|
if strings.Contains(git.commands(), "fetch origin main") {
|
||||||
t.Fatal("later run fetched a mutable branch instead of the lock commit")
|
t.Fatal("later run fetched a mutable branch instead of the lock commit")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRefreshTemplateRevisionsUpdatesExistingLockToConfiguredHeads(t *testing.T) {
|
||||||
|
workspace, cfg, git := templateRevisionTestConfig(t)
|
||||||
|
useTemplateRevisionGit(t, git)
|
||||||
|
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
git.commits = map[string]string{"cicd": strings.Repeat("d", 40), "manifests": strings.Repeat("e", 40), "talos": strings.Repeat("f", 40)}
|
||||||
|
git.resetCalls()
|
||||||
|
|
||||||
|
if err := refreshTemplateRevisions(workspace, cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lock, err := readTemplateRevisionLock(filepath.Join(workspace, "maidn-template-revisions.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, commit := range git.commits {
|
||||||
|
if git.fetched[name] != commit || git.checkedOut[name] != commit {
|
||||||
|
t.Fatalf("refresh did not use configured %s branch head: fetched %q, checked out %q", name, git.fetched[name], git.checkedOut[name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lock.CICD.Commit != git.commits["cicd"] || lock.Manifests.Commit != git.commits["manifests"] || lock.Talos.Commit != git.commits["talos"] {
|
||||||
|
t.Fatalf("refresh did not update template lock: %#v", lock)
|
||||||
|
}
|
||||||
|
if !strings.Contains(git.commands(), "fetch origin main") || strings.Contains(git.commands(), "template-password") {
|
||||||
|
t.Fatal("refresh did not safely fetch configured branch heads")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureTemplateRevisionsRejectsChangedRefWithoutGit(t *testing.T) {
|
func TestEnsureTemplateRevisionsRejectsChangedRefWithoutGit(t *testing.T) {
|
||||||
workspace, cfg, git := templateRevisionTestConfig(t)
|
workspace, cfg, git := templateRevisionTestConfig(t)
|
||||||
useTemplateRevisionGit(t, git)
|
useTemplateRevisionGit(t, git)
|
||||||
|
|
|
||||||
|
|
@ -164,12 +164,12 @@ func ResolveAppOnboarding(cfg Config) (Config, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg, err
|
return cfg, err
|
||||||
}
|
}
|
||||||
owner, _, err := deliveryRepositoryOwner(resolved.Delivery.AppRepoURL)
|
owner, repository, err := deliveryRepositoryOwner(resolved.Delivery.AppRepoURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg, err
|
return cfg, err
|
||||||
}
|
}
|
||||||
if owner != resolved.Git.Owner {
|
if owner != resolved.Git.Owner && (owner != "Maidn" || !strings.HasPrefix(repository, "maidn-e2e-")) {
|
||||||
return cfg, errors.New("delivery appRepoUrl owner must match git owner for app onboarding")
|
return cfg, errors.New("delivery appRepoUrl owner must match git owner or identify a canonical Maidn E2E fixture")
|
||||||
}
|
}
|
||||||
if resolved.Delivery.ProductionBranch != "production" {
|
if resolved.Delivery.ProductionBranch != "production" {
|
||||||
return cfg, errors.New("delivery productionBranch must be literal production for app onboarding")
|
return cfg, errors.New("delivery productionBranch must be literal production for app onboarding")
|
||||||
|
|
@ -215,6 +215,9 @@ func validateRepositoryURL(value string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyDefaults(cfg *Config) {
|
func applyDefaults(cfg *Config) {
|
||||||
|
if cfg.Delivery.BuildStrategy == "" {
|
||||||
|
cfg.Delivery.BuildStrategy = "static"
|
||||||
|
}
|
||||||
if cfg.ClusterID == "" {
|
if cfg.ClusterID == "" {
|
||||||
cfg.ClusterID = cfg.Talos.Cluster.Name
|
cfg.ClusterID = cfg.Talos.Cluster.Name
|
||||||
}
|
}
|
||||||
|
|
@ -317,6 +320,9 @@ func applyDefaults(cfg *Config) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyDeliveryDefaults(cfg *Config) {
|
func applyDeliveryDefaults(cfg *Config) {
|
||||||
|
if cfg.Delivery.BuildStrategy == "" {
|
||||||
|
cfg.Delivery.BuildStrategy = "static"
|
||||||
|
}
|
||||||
if cfg.Delivery.AppRepoRef == "" {
|
if cfg.Delivery.AppRepoRef == "" {
|
||||||
cfg.Delivery.AppRepoRef = cfg.Flux.Branch
|
cfg.Delivery.AppRepoRef = cfg.Flux.Branch
|
||||||
}
|
}
|
||||||
|
|
@ -542,6 +548,9 @@ func ValidateDelivery(cfg Config) error {
|
||||||
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`).MatchString(cfg.Delivery.AppName) {
|
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`).MatchString(cfg.Delivery.AppName) {
|
||||||
return errors.New("delivery appName must be a lowercase DNS label of at most 47 characters")
|
return errors.New("delivery appName must be a lowercase DNS label of at most 47 characters")
|
||||||
}
|
}
|
||||||
|
if cfg.Delivery.BuildStrategy != "static" && cfg.Delivery.BuildStrategy != "runtime" {
|
||||||
|
return errors.New("delivery buildStrategy must be static or runtime")
|
||||||
|
}
|
||||||
if cfg.Delivery.ProductionBranch == cfg.Delivery.AppRepoRef || !validDeliveryBranch(cfg.Delivery.ProductionBranch) {
|
if cfg.Delivery.ProductionBranch == cfg.Delivery.AppRepoRef || !validDeliveryBranch(cfg.Delivery.ProductionBranch) {
|
||||||
return errors.New("delivery productionBranch must be a valid branch distinct from appRepoRef")
|
return errors.New("delivery productionBranch must be a valid branch distinct from appRepoRef")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ func validConfig(t *testing.T) Config {
|
||||||
Templates: TemplateConfig{TalosRepoURL: "https://git.example.test/talos.git", TalosRepoRef: "main", CICDRepoURL: "https://git.example.test/template.git", CICDRepoRef: "main", ManifestsRepoURL: "https://git.example.test/manifests.git", ManifestsRepoRef: "main"},
|
Templates: TemplateConfig{TalosRepoURL: "https://git.example.test/talos.git", TalosRepoRef: "main", CICDRepoURL: "https://git.example.test/template.git", CICDRepoRef: "main", ManifestsRepoURL: "https://git.example.test/manifests.git", ManifestsRepoRef: "main"},
|
||||||
Cilium: CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"},
|
Cilium: CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"},
|
||||||
DemocraticCSI: DemocraticCSIConfig{TrueNASAPIKey: "api-key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"},
|
DemocraticCSI: DemocraticCSIConfig{TrueNASAPIKey: "api-key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"},
|
||||||
Delivery: DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/web-ui", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"},
|
Delivery: DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"},
|
||||||
Talos: TalosConfig{
|
Talos: TalosConfig{
|
||||||
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
|
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
|
||||||
Proxmox: TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
|
Proxmox: TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
|
||||||
|
|
@ -64,6 +64,7 @@ func TestResolveDeliveryAppliesDefaultsOnlyForExplicitAppOperations(t *testing.T
|
||||||
cfg.Delivery.AppRepoRef = ""
|
cfg.Delivery.AppRepoRef = ""
|
||||||
cfg.Delivery.BuildOutputDirectory = ""
|
cfg.Delivery.BuildOutputDirectory = ""
|
||||||
cfg.Delivery.BuildConfiguration = ""
|
cfg.Delivery.BuildConfiguration = ""
|
||||||
|
cfg.Delivery.BuildStrategy = ""
|
||||||
cfg.Delivery.WebhookHostname = ""
|
cfg.Delivery.WebhookHostname = ""
|
||||||
cfg.Delivery.WebhookPath = ""
|
cfg.Delivery.WebhookPath = ""
|
||||||
|
|
||||||
|
|
@ -78,11 +79,25 @@ func TestResolveDeliveryAppliesDefaultsOnlyForExplicitAppOperations(t *testing.T
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !delivery.Delivery.Configured() || delivery.Delivery.AppRepoRef != "main" || delivery.Delivery.BuildOutputDirectory != "dist" || delivery.Delivery.BuildConfiguration != "production" || delivery.Delivery.WebhookURL() != "https://tekton.example.test/" {
|
if !delivery.Delivery.Configured() || delivery.Delivery.BuildStrategy != "static" || delivery.Delivery.AppRepoRef != "main" || delivery.Delivery.BuildOutputDirectory != "dist" || delivery.Delivery.BuildConfiguration != "production" || delivery.Delivery.WebhookURL() != "https://tekton.example.test/" {
|
||||||
t.Fatalf("ResolveDelivery() did not apply the complete delivery contract: %#v", delivery.Delivery)
|
t.Fatalf("ResolveDelivery() did not apply the complete delivery contract: %#v", delivery.Delivery)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateDeliveryBuildStrategy(t *testing.T) {
|
||||||
|
cfg := validConfig(t)
|
||||||
|
for _, strategy := range []string{"static", "runtime"} {
|
||||||
|
cfg.Delivery.BuildStrategy = strategy
|
||||||
|
if err := ValidateDelivery(cfg); err != nil {
|
||||||
|
t.Fatalf("ValidateDelivery() rejected %q: %v", strategy, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.Delivery.BuildStrategy = "container"
|
||||||
|
if err := ValidateDelivery(cfg); err == nil || !strings.Contains(err.Error(), "buildStrategy") {
|
||||||
|
t.Fatalf("ValidateDelivery() accepted invalid build strategy: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) {
|
func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) {
|
||||||
cfg := validConfig(t)
|
cfg := validConfig(t)
|
||||||
cfg.Delivery.ImageRepository = ""
|
cfg.Delivery.ImageRepository = ""
|
||||||
|
|
@ -99,16 +114,26 @@ func TestValidateDeliveryRejectsUnsafeTektonDashboardURL(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveAppOnboardingRequiresTargetOwner(t *testing.T) {
|
func TestResolveAppOnboardingAllowsOnlyCanonicalCrossOwnerSource(t *testing.T) {
|
||||||
cfg := validConfig(t)
|
cfg := validConfig(t)
|
||||||
cfg.Git.Owner = "test-org-2"
|
cfg.Git.Owner = "test-org-2"
|
||||||
if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "owner must match") {
|
cfg.Delivery.AppRepoURL = "https://git.example.test/Maidn/maidn-e2e-web.git"
|
||||||
t.Fatalf("ResolveAppOnboarding() accepted a source-owner target: %v", err)
|
if _, err := ResolveAppOnboarding(cfg); err != nil {
|
||||||
|
t.Fatalf("ResolveAppOnboarding() rejected canonical source: %v", err)
|
||||||
}
|
}
|
||||||
cfg.Delivery.AppRepoURL = "https://git.example.test/test-org-2/web-ui.git"
|
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/" {
|
if resolved, err := ResolveAppOnboarding(cfg); err != nil || resolved.Delivery.WebhookURL() != "https://tekton.example.test/" {
|
||||||
t.Fatalf("ResolveAppOnboarding() = %#v, %v", resolved.Delivery, err)
|
t.Fatalf("ResolveAppOnboarding() = %#v, %v", resolved.Delivery, err)
|
||||||
}
|
}
|
||||||
|
for _, appRepoURL := range []string{
|
||||||
|
"https://git.example.test/Maidn/web-ui.git",
|
||||||
|
"https://git.example.test/other-org/maidn-e2e-web.git",
|
||||||
|
} {
|
||||||
|
cfg.Delivery.AppRepoURL = appRepoURL
|
||||||
|
if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "canonical Maidn E2E fixture") {
|
||||||
|
t.Fatalf("ResolveAppOnboarding() accepted noncanonical cross-owner source %q: %v", appRepoURL, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveAppOnboardingRequiresProductionBranch(t *testing.T) {
|
func TestResolveAppOnboardingRequiresProductionBranch(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ type DeliveryConfig struct {
|
||||||
AppRepoRef string `yaml:"appRepoRef"`
|
AppRepoRef string `yaml:"appRepoRef"`
|
||||||
ProductionBranch string `yaml:"productionBranch"`
|
ProductionBranch string `yaml:"productionBranch"`
|
||||||
ImageRepository string `yaml:"imageRepository"`
|
ImageRepository string `yaml:"imageRepository"`
|
||||||
|
BuildStrategy string `yaml:"buildStrategy"`
|
||||||
BuildOutputDirectory string `yaml:"buildOutputDirectory"`
|
BuildOutputDirectory string `yaml:"buildOutputDirectory"`
|
||||||
BuildConfiguration string `yaml:"buildConfiguration"`
|
BuildConfiguration string `yaml:"buildConfiguration"`
|
||||||
TektonDashboardURL string `yaml:"tektonDashboardUrl,omitempty"`
|
TektonDashboardURL string `yaml:"tektonDashboardUrl,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
fixtureOwner = "test-org-2"
|
fixtureOwner = "Maidn"
|
||||||
fixturePrefix = "maidn-e2e-"
|
fixturePrefix = "maidn-e2e-"
|
||||||
maxBodyBytes = 1 << 20
|
maxBodyBytes = 1 << 20
|
||||||
)
|
)
|
||||||
|
|
@ -78,7 +78,7 @@ func (o Options) Validate() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if o.Owner != fixtureOwner {
|
if o.Owner != fixtureOwner {
|
||||||
return errors.New("Forgejo mutation owner must be test-org-2")
|
return errors.New("Forgejo mutation owner must be Maidn")
|
||||||
}
|
}
|
||||||
for _, value := range []string{o.Repo, o.Branch} {
|
for _, value := range []string{o.Repo, o.Branch} {
|
||||||
if !fixtureName(value) {
|
if !fixtureName(value) {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ func mutationResponse(status int, body string) *http.Response {
|
||||||
func testOptions() Options {
|
func testOptions() Options {
|
||||||
return Options{
|
return Options{
|
||||||
ForgejoURL: "https://git.example.test",
|
ForgejoURL: "https://git.example.test",
|
||||||
Owner: "test-org-2",
|
Owner: "Maidn",
|
||||||
Repo: "maidn-e2e-repo",
|
Repo: "maidn-e2e-repo",
|
||||||
Branch: "maidn-e2e-branch",
|
Branch: "maidn-e2e-branch",
|
||||||
SHA: "0123456789abcdef0123456789abcdef01234567",
|
SHA: "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
|
@ -60,7 +60,7 @@ func TestMutatorUpdatesFixtureRefAndEnsuresOnePR(t *testing.T) {
|
||||||
t.Fatal("mutation request did not authenticate at the API boundary")
|
t.Fatal("mutation request did not authenticate at the API boundary")
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case request.Method == http.MethodPatch && request.URL.Path == "/api/v1/repos/test-org-2/maidn-e2e-repo/git/refs/heads/maidn-e2e-branch":
|
case request.Method == http.MethodPatch && request.URL.Path == "/api/v1/repos/Maidn/maidn-e2e-repo/git/refs/heads/maidn-e2e-branch":
|
||||||
var body struct {
|
var body struct {
|
||||||
SHA string `json:"sha"`
|
SHA string `json:"sha"`
|
||||||
Force bool `json:"force"`
|
Force bool `json:"force"`
|
||||||
|
|
@ -69,12 +69,12 @@ func TestMutatorUpdatesFixtureRefAndEnsuresOnePR(t *testing.T) {
|
||||||
t.Fatalf("unexpected branch update: %#v, %v", body, err)
|
t.Fatalf("unexpected branch update: %#v, %v", body, err)
|
||||||
}
|
}
|
||||||
return mutationResponse(http.StatusOK, ""), nil
|
return mutationResponse(http.StatusOK, ""), nil
|
||||||
case request.Method == http.MethodGet && request.URL.Path == "/api/v1/repos/test-org-2/maidn-e2e-repo/pulls":
|
case request.Method == http.MethodGet && request.URL.Path == "/api/v1/repos/Maidn/maidn-e2e-repo/pulls":
|
||||||
if request.URL.Query().Get("head") != "test-org-2:maidn-e2e-branch" || request.URL.Query().Get("state") != "open" {
|
if request.URL.Query().Get("head") != "Maidn:maidn-e2e-branch" || request.URL.Query().Get("state") != "open" {
|
||||||
t.Fatal("pull request lookup did not target the fixture branch")
|
t.Fatal("pull request lookup did not target the fixture branch")
|
||||||
}
|
}
|
||||||
return mutationResponse(http.StatusOK, "[]"), nil
|
return mutationResponse(http.StatusOK, "[]"), nil
|
||||||
case request.Method == http.MethodPost && request.URL.Path == "/api/v1/repos/test-org-2/maidn-e2e-repo/pulls":
|
case request.Method == http.MethodPost && request.URL.Path == "/api/v1/repos/Maidn/maidn-e2e-repo/pulls":
|
||||||
var body struct {
|
var body struct {
|
||||||
Head string `json:"head"`
|
Head string `json:"head"`
|
||||||
Base string `json:"base"`
|
Base string `json:"base"`
|
||||||
|
|
@ -101,7 +101,7 @@ func TestMutatorCreatesFixtureRefWhenAbsent(t *testing.T) {
|
||||||
case http.MethodPatch:
|
case http.MethodPatch:
|
||||||
return mutationResponse(http.StatusNotFound, ""), nil
|
return mutationResponse(http.StatusNotFound, ""), nil
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
if request.URL.Path != "/api/v1/repos/test-org-2/maidn-e2e-repo/git/refs" {
|
if request.URL.Path != "/api/v1/repos/Maidn/maidn-e2e-repo/git/refs" {
|
||||||
t.Fatalf("branch creation targeted %q", request.URL.Path)
|
t.Fatalf("branch creation targeted %q", request.URL.Path)
|
||||||
}
|
}
|
||||||
return mutationResponse(http.StatusCreated, ""), nil
|
return mutationResponse(http.StatusCreated, ""), nil
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,9 @@ type mergePullRequestRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type hook struct {
|
type hook struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
Config map[string]string `json:"config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type hookRequest struct {
|
type hookRequest struct {
|
||||||
|
|
@ -96,6 +97,7 @@ type accessToken struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
var copyGit = runGit
|
var copyGit = runGit
|
||||||
|
var hasRemoteBranch = (*RepoManager).HasRemoteBranch
|
||||||
|
|
||||||
func (e *APIError) Error() string {
|
func (e *APIError) Error() string {
|
||||||
return fmt.Sprintf("forgejo returned %s", e.Status)
|
return fmt.Sprintf("forgejo returned %s", e.Status)
|
||||||
|
|
@ -418,7 +420,7 @@ func (rm *RepoManager) CreatePullRequest(repo, title, head, base string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if status != http.StatusCreated && status != http.StatusUnprocessableEntity {
|
if status != http.StatusCreated && status != http.StatusUnprocessableEntity && status != http.StatusConflict {
|
||||||
return fmt.Errorf("unexpected Forgejo pull request status %d", status)
|
return fmt.Errorf("unexpected Forgejo pull request status %d", status)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -507,70 +509,6 @@ func (rm *RepoManager) PushRef(dir, repoURL, sourceRef, targetBranch string) err
|
||||||
return runGit(dir, environment, "push", repoURL, sourceRef+":refs/heads/"+targetBranch)
|
return runGit(dir, environment, "push", repoURL, sourceRef+":refs/heads/"+targetBranch)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeliveryBranch returns the dedicated branch that carries generated delivery content.
|
|
||||||
func DeliveryBranch(appName, baseBranch string) (string, error) {
|
|
||||||
branch := "maidn/delivery-" + appName
|
|
||||||
if appName == "" || branch == baseBranch {
|
|
||||||
return "", fmt.Errorf("delivery branch and configured base branch must differ")
|
|
||||||
}
|
|
||||||
return branch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PublishDeliveryBranch generates and commits delivery content in a temporary clone.
|
|
||||||
func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, deliveryBranch string, generate func(string) error) (bool, error) {
|
|
||||||
if sourceDir == "" || sourceBranch == "" || deliveryBranch == "" || deliveryBranch == rm.Branch {
|
|
||||||
return false, fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch")
|
|
||||||
}
|
|
||||||
hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
temporary, err := os.MkdirTemp("", "maidn-delivery-*")
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(temporary)
|
|
||||||
cleanupAskPass, environment, err := rm.gitEnvironment()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
defer cleanupAskPass()
|
|
||||||
if hasBranch {
|
|
||||||
if err := runGit("", environment, "clone", "--branch", deliveryBranch, repoURL, temporary); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
} else if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := generate(temporary); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := runGit(temporary, environment, "add", ".tekton"); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
changed, err := gitDiffQuiet(temporary, environment, "--cached")
|
|
||||||
if err != nil {
|
|
||||||
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 false, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !changed {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := rm.PushBranch(temporary, repoURL, deliveryBranch); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PublishRepositoryPullRequest applies a managed change on a dedicated branch.
|
// PublishRepositoryPullRequest applies a managed change on a dedicated branch.
|
||||||
// An existing branch is accepted only when it has exactly one open pull request.
|
// 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) {
|
func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base string, change func(string) error) (bool, error) {
|
||||||
|
|
@ -578,10 +516,19 @@ func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base st
|
||||||
return false, errors.New("repository pull request requires distinct non-empty branches")
|
return false, errors.New("repository pull request requires distinct non-empty branches")
|
||||||
}
|
}
|
||||||
repoURL := CloneURL(rm.BaseURL, rm.Owner, repo)
|
repoURL := CloneURL(rm.BaseURL, rm.Owner, repo)
|
||||||
hasBranch, err := rm.HasRemoteBranch(repoURL, branch)
|
hasBranch, err := hasRemoteBranch(rm, repoURL, branch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if hasBranch {
|
||||||
|
open, err := rm.HasOpenPullRequest(repo, branch)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !open {
|
||||||
|
return false, fmt.Errorf("Forgejo branch %q exists without exactly one open pull request; refusing to reuse it", branch)
|
||||||
|
}
|
||||||
|
}
|
||||||
temporary, err := os.MkdirTemp("", "maidn-registration-*")
|
temporary, err := os.MkdirTemp("", "maidn-registration-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|
@ -622,14 +569,11 @@ func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base st
|
||||||
if !open {
|
if !open {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return true, rm.MergePullRequest(repo, branch)
|
return true, nil
|
||||||
}
|
}
|
||||||
if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil {
|
if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if err := rm.MergePullRequest(repo, branch); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -775,7 +719,7 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err
|
||||||
Events: []string{"push", "pull_request"},
|
Events: []string{"push", "pull_request"},
|
||||||
}
|
}
|
||||||
for _, existing := range hooks {
|
for _, existing := range hooks {
|
||||||
if existing.URL != webhookURL {
|
if hookURL(existing) != webhookURL {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
request, err := json.Marshal(hookRequest{Active: createRequest.Active, AuthorizationHeader: createRequest.AuthorizationHeader, Config: createRequest.Config, Events: createRequest.Events})
|
request, err := json.Marshal(hookRequest{Active: createRequest.Active, AuthorizationHeader: createRequest.AuthorizationHeader, Config: createRequest.Config, Events: createRequest.Events})
|
||||||
|
|
@ -805,6 +749,13 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hookURL(existing hook) string {
|
||||||
|
if existing.URL != "" {
|
||||||
|
return existing.URL
|
||||||
|
}
|
||||||
|
return existing.Config["url"]
|
||||||
|
}
|
||||||
|
|
||||||
// TriggerWebhookTest asks Forgejo to deliver a test push for the managed hook.
|
// TriggerWebhookTest asks Forgejo to deliver a test push for the managed hook.
|
||||||
func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error {
|
func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error {
|
||||||
if repo == "" || webhookURL == "" || branch == "" {
|
if repo == "" || webhookURL == "" || branch == "" {
|
||||||
|
|
@ -821,7 +772,7 @@ func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error
|
||||||
}
|
}
|
||||||
var matching []hook
|
var matching []hook
|
||||||
for _, candidate := range hooks {
|
for _, candidate := range hooks {
|
||||||
if candidate.URL == webhookURL {
|
if hookURL(candidate) == webhookURL {
|
||||||
matching = append(matching, candidate)
|
matching = append(matching, candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -839,10 +790,10 @@ func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureProtectedBranch disables direct pushes to the configured production branch.
|
// EnsureProtectedBranch disables direct pushes to a managed branch.
|
||||||
func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error {
|
func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error {
|
||||||
if repo == "" || branch == "" {
|
if repo == "" || branch == "" {
|
||||||
return errors.New("Forgejo repository and production branch are required")
|
return errors.New("Forgejo repository and branch are required")
|
||||||
}
|
}
|
||||||
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/branch_protections", rm.BaseURL, rm.Owner, repo)
|
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/branch_protections", rm.BaseURL, rm.Owner, repo)
|
||||||
var protections []branchProtection
|
var protections []branchProtection
|
||||||
|
|
@ -860,11 +811,11 @@ func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(matching) > 1 {
|
if len(matching) > 1 {
|
||||||
return fmt.Errorf("multiple Forgejo branch protections match production branch %q", branch)
|
return fmt.Errorf("multiple Forgejo branch protections match branch %q", branch)
|
||||||
}
|
}
|
||||||
if len(matching) == 1 {
|
if len(matching) == 1 {
|
||||||
if matching[0].EnablePush || matching[0].EnablePushWhitelist {
|
if matching[0].EnablePush || matching[0].EnablePushWhitelist {
|
||||||
return fmt.Errorf("Forgejo production branch %q permits direct pushes", branch)
|
return fmt.Errorf("Forgejo branch %q permits direct pushes", branch)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,7 @@ func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) {
|
||||||
if request.URL.Path != "/api/v1/repos/owner/app/hooks" {
|
if request.URL.Path != "/api/v1/repos/owner/app/hooks" {
|
||||||
t.Fatalf("unexpected lookup path %q", request.URL.Path)
|
t.Fatalf("unexpected lookup path %q", request.URL.Path)
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, URL: "https://tekton.example.test/"}})
|
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, Config: map[string]string{"url": "https://tekton.example.test/"}}})
|
||||||
case http.MethodPatch:
|
case http.MethodPatch:
|
||||||
if request.URL.Path != "/api/v1/repos/owner/app/hooks/7" {
|
if request.URL.Path != "/api/v1/repos/owner/app/hooks/7" {
|
||||||
t.Fatalf("unexpected update path %q", request.URL.Path)
|
t.Fatalf("unexpected update path %q", request.URL.Path)
|
||||||
|
|
@ -317,6 +317,28 @@ func TestEnsureProtectedBranchCreatesDirectPushProtection(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnsureProtectedBranchUsesCanonicalSourceOwnerPath(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.URL.Path != "/api/v1/repos/Maidn/maidn-e2e-web/branch_protections" {
|
||||||
|
t.Fatalf("canonical source mutation targeted %q", request.URL.Path)
|
||||||
|
}
|
||||||
|
switch request.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
_ = json.NewEncoder(writer).Encode([]branchProtection{})
|
||||||
|
case http.MethodPost:
|
||||||
|
writer.WriteHeader(http.StatusCreated)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", request.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
manager := NewRepoManager(server.URL, "token", "Maidn", "user", "", "", "main", "")
|
||||||
|
manager.HTTPClient = server.Client()
|
||||||
|
if err := manager.EnsureProtectedBranch("maidn-e2e-web", "production"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureProtectedBranchRejectsExistingDirectPushRule(t *testing.T) {
|
func TestEnsureProtectedBranchRejectsExistingDirectPushRule(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.Method != http.MethodGet {
|
if request.Method != http.MethodGet {
|
||||||
|
|
@ -378,6 +400,34 @@ func TestHasRemoteBranchReturnsFalseForMissingBranch(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPublishRepositoryPullRequestRejectsExistingBranchWithoutOpenPullRequest(t *testing.T) {
|
||||||
|
original := hasRemoteBranch
|
||||||
|
t.Cleanup(func() { hasRemoteBranch = original })
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.Method != http.MethodGet || request.URL.Path != "/api/v1/repos/owner/cluster/pulls" || request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/register-web-ui-deadbeefcafe" {
|
||||||
|
t.Fatalf("unexpected pull request lookup: %s %s", request.Method, request.URL.String())
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(writer).Encode([]pullRequest{})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
manager := NewRepoManager(server.URL, "test-token", "owner", "user", "", "", "main", "")
|
||||||
|
manager.HTTPClient = server.Client()
|
||||||
|
hasRemoteBranch = func(got *RepoManager, repoURL, branch string) (bool, error) {
|
||||||
|
if got != manager || repoURL != CloneURL(server.URL, "owner", "cluster") || branch != "maidn/register-web-ui-deadbeefcafe" {
|
||||||
|
t.Fatalf("unexpected remote branch lookup: %q %q", repoURL, branch)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := manager.PublishRepositoryPullRequest("cluster", "register web-ui", "maidn/register-web-ui-deadbeefcafe", "main", func(string) error {
|
||||||
|
t.Fatal("change ran for a stale registration branch")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "without exactly one open pull request") {
|
||||||
|
t.Fatalf("PublishRepositoryPullRequest() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMergePullRequest(t *testing.T) {
|
func TestMergePullRequest(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) {
|
||||||
switch request.Method {
|
switch request.Method {
|
||||||
|
|
@ -404,6 +454,21 @@ func TestMergePullRequest(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreatePullRequestAcceptsExistingConflict(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.Method != http.MethodPost || request.URL.Path != "/api/v1/repos/owner/cluster/pulls" {
|
||||||
|
t.Fatalf("unexpected pull request request: %s %s", request.Method, request.URL.Path)
|
||||||
|
}
|
||||||
|
writer.WriteHeader(http.StatusConflict)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
|
||||||
|
manager.HTTPClient = server.Client()
|
||||||
|
if err := manager.CreatePullRequest("cluster", "title", "maidn/bootstrap-test", "main"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsurePullRequestChecksExactOpenBranchBeforeCreating(t *testing.T) {
|
func TestEnsurePullRequestChecksExactOpenBranchBeforeCreating(t *testing.T) {
|
||||||
requests := 0
|
requests := 0
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
|
|
||||||
|
|
@ -498,33 +498,19 @@ func redactOpenBaoDiagnostic(diagnostic string, sensitive ...string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshExternalSecrets(kubeconfig string) error {
|
func refreshExternalSecrets(kubeconfig string) error {
|
||||||
available, err := kubectlOutput(kubeconfig, "-n", "external-secrets", "get", "deployment/external-secrets", "-o=jsonpath={.status.conditions[?(@.type==\"Available\")].status}")
|
available, err := kubectlOutput(kubeconfig, "--request-timeout=30s", "-n", "external-secrets", "get", "deployment/external-secrets", "-o=jsonpath={.status.conditions[?(@.type==\"Available\")].status}")
|
||||||
if err != nil || strings.TrimSpace(string(available)) != "True" {
|
if err != nil || strings.TrimSpace(string(available)) != "True" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
timestamp := time.Now().UnixNano()
|
timestamp := time.Now().UnixNano()
|
||||||
_, err = kubectlOutput(kubeconfig, "annotate", "clustersecretstore", "openbao", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite")
|
fmt.Fprintln(os.Stderr, "OpenBao: refresh OpenBao secret store")
|
||||||
|
_, err = kubectlOutput(kubeconfig, "--request-timeout=30s", "annotate", "clustersecretstore", "openbao", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("refresh OpenBao secret store after seed: %w", err)
|
return fmt.Errorf("refresh OpenBao secret store after seed: %w", err)
|
||||||
}
|
}
|
||||||
webhook, err := kubectlOutput(kubeconfig, "get", "externalsecret", "forgejo-webhook", "-n", "tekton-pipelines", "--ignore-not-found", "-o=name")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("check Forgejo webhook ExternalSecret after OpenBao seed: %w", err)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(string(webhook)) == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err = kubectlOutput(kubeconfig, externalSecretRefreshArgs(timestamp)...)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("refresh ExternalSecrets after OpenBao seed: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func externalSecretRefreshArgs(timestamp int64) []string {
|
|
||||||
return []string{"annotate", "externalsecret", "forgejo-webhook", "-n", "tekton-pipelines", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func encryptRecovery(recipient, bundlePath string, plaintext []byte) error {
|
func encryptRecovery(recipient, bundlePath string, plaintext []byte) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -100,32 +100,13 @@ func TestRefreshExternalSecretsIsReadyGatedAndScoped(t *testing.T) {
|
||||||
if len(calls) == 1 {
|
if len(calls) == 1 {
|
||||||
return []byte("True"), nil
|
return []byte("True"), nil
|
||||||
}
|
}
|
||||||
if len(calls) == 3 {
|
|
||||||
return []byte("externalsecret.external-secrets.io/forgejo-webhook"), nil
|
|
||||||
}
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 4 || !strings.Contains(calls[0], "get deployment/external-secrets") || !strings.Contains(calls[1], "annotate clustersecretstore openbao") || !strings.Contains(calls[2], "get externalsecret forgejo-webhook") || !strings.Contains(calls[3], "annotate externalsecret forgejo-webhook") || strings.Contains(calls[1], "--all") || strings.Contains(calls[3], "--all") {
|
if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 2 || !strings.Contains(calls[0], "get deployment/external-secrets") || !strings.Contains(calls[1], "annotate clustersecretstore openbao") || strings.Contains(calls[1], "--all") {
|
||||||
t.Fatalf("ExternalSecret refresh was not readiness-gated and scoped: %q, %v", calls, err)
|
t.Fatalf("ExternalSecret refresh was not readiness-gated and scoped: %q, %v", calls, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefreshExternalSecretsSkipsWebhookBeforeTekton(t *testing.T) {
|
|
||||||
original := kubectlOutput
|
|
||||||
t.Cleanup(func() { kubectlOutput = original })
|
|
||||||
var calls []string
|
|
||||||
kubectlOutput = func(_ string, args ...string) ([]byte, error) {
|
|
||||||
calls = append(calls, strings.Join(args, " "))
|
|
||||||
if len(calls) == 1 {
|
|
||||||
return []byte("True"), nil
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 3 || !strings.Contains(calls[2], "--ignore-not-found") {
|
|
||||||
t.Fatalf("missing webhook ExternalSecret was not safely skipped: %q, %v", calls, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) {
|
func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) {
|
||||||
originalDecrypt, originalExec := decryptRecovery, execInPodMutation
|
originalDecrypt, originalExec := decryptRecovery, execInPodMutation
|
||||||
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
|
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue