Compare commits
123 commits
fix/platfo
...
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 | ||
|
|
732270221d | ||
|
|
a5f1cbb657 | ||
|
|
10b2362218 | ||
|
|
523148a4be | ||
|
|
3ec1097fa3 | ||
|
|
dd1897d916 | ||
|
|
d4f6b74bef | ||
|
|
9109b2b367 | ||
|
|
2b545f662f | ||
|
|
beca1b5d36 | ||
|
|
bc166e4973 | ||
|
|
a867c209c3 | ||
|
|
7b1de17e5d | ||
|
|
66d42b6ca4 | ||
|
|
9f1c05a4d4 | ||
|
|
217f03d4be | ||
|
|
1bca082c77 | ||
|
|
20381ebddf | ||
|
|
d97bc041f9 | ||
|
|
45f16ab9bc | ||
|
|
7d1cf8ae6d | ||
|
|
4281e63750 | ||
|
|
7e4d50b776 | ||
|
|
29c7180259 | ||
|
|
c9a8e38914 | ||
|
|
a62799d531 | ||
|
|
d6333d9775 | ||
|
|
e86732d219 | ||
|
|
7789c80f25 | ||
|
|
b26c2d3165 | ||
|
|
eba3df7bcd | ||
|
|
5954a8e99b | ||
|
|
31751d7eb7 | ||
|
|
99eb19fba4 | ||
|
|
026b191c84 | ||
|
|
b7b9068f9c | ||
|
|
0cf8fa7557 | ||
|
|
3ea55e34e0 | ||
|
|
32734dafff | ||
|
|
def841532b | ||
|
|
00cb4ead63 | ||
|
|
83ddc5f241 | ||
|
|
e8cef9b66e | ||
|
|
f5015c67c0 | ||
|
|
b55602ce23 | ||
|
|
888f4aa88b | ||
|
|
461e4a02ab | ||
|
|
ac215f40ab | ||
|
|
8cf30c0429 | ||
|
|
7038197757 | ||
|
|
d3f204c873 | ||
|
|
044bd865c0 | ||
|
|
c431156498 | ||
|
|
112fc5376b | ||
|
|
43695c7b29 | ||
|
|
50989db2fe | ||
|
|
8e4672010b | ||
|
|
214b00720a | ||
|
|
62540c69a2 | ||
|
|
54a2ae2838 | ||
|
|
055b658567 | ||
|
|
eb840a5468 | ||
|
|
d0df184af4 | ||
|
|
769028c587 | ||
|
|
0b8147dd4f | ||
|
|
c80b860fcb | ||
|
|
f09cdf4ee9 | ||
|
|
9d24194637 | ||
|
|
fe3ce96511 | ||
|
|
651c2938d5 | ||
|
|
c659bd3c1f | ||
|
|
0bef3b5f03 | ||
|
|
2478f08e5c | ||
|
|
ee517273b2 | ||
|
|
4ec361ec1d | ||
|
|
1777cfce20 | ||
|
|
5e98336cdf | ||
|
|
3f903afd13 | ||
|
|
8e8f80e06f | ||
|
|
d48547229a | ||
|
|
2d3840653a | ||
|
|
e5bfb3b2b7 | ||
|
|
8dd95824c0 | ||
|
|
8b26e1422b | ||
|
|
1ae08491b6 | ||
|
|
634f415716 | ||
|
|
476259bc18 | ||
|
|
e8bcebf65f | ||
|
|
2852569aa3 | ||
|
|
8b4fef00a8 | ||
|
|
433305e0aa | ||
|
|
ce4194e5b2 | ||
|
|
5c7bbf6e21 | ||
|
|
e0bc2a34b0 |
16
.dockerignore
Normal file
16
.dockerignore
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
.git
|
||||||
|
.age
|
||||||
|
.recovery
|
||||||
|
maidn-bootstrap*.yaml
|
||||||
|
maidn-workspace
|
||||||
|
*.sops.yaml
|
||||||
|
terraform.tfvars
|
||||||
|
*.tfvars
|
||||||
|
*.tfstate*
|
||||||
|
kubeconfig
|
||||||
|
*.kubeconfig
|
||||||
|
*.kube
|
||||||
|
.kube
|
||||||
|
clusterconfig
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
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.
|
||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
FROM golang:1.24.0-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/cicd-tool .
|
||||||
|
|
||||||
|
FROM alpine:3.22
|
||||||
|
ARG KUBECTL_VERSION=v1.33.4
|
||||||
|
RUN apk add --no-cache ca-certificates curl \
|
||||||
|
&& curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" -o /usr/local/bin/kubectl \
|
||||||
|
&& chmod 0755 /usr/local/bin/kubectl
|
||||||
|
COPY --from=build /out/cicd-tool /usr/local/bin/cicd-tool
|
||||||
|
USER 65532:65532
|
||||||
|
ENTRYPOINT ["cicd-tool"]
|
||||||
13
How2Auth.md
13
How2Auth.md
|
|
@ -1,2 +1,11 @@
|
||||||
edingrech
|
# Credential onboarding
|
||||||
dckr_pat_bfKKDH4g3qUxchs9UMLxFx2oTiU
|
|
||||||
|
Do not put credential values in this file, Git, generated configuration, command arguments, terminal history, or logs.
|
||||||
|
|
||||||
|
1. Obtain written authorization for `<credential-purpose>`, `<consumer-inventory>`, `<approved-scope>`, and `<rotation-window>`.
|
||||||
|
2. Have the authorized operator enter `<credential-value>` only through the approved secure prompt or standard input boundary.
|
||||||
|
3. Store it only in the configured SOPS-encrypted operational-secrets file, then reseed and verify OpenBao before changing consumers.
|
||||||
|
4. Keep the previous credential active only for the approved overlap window; revoke it only after every consumer check succeeds.
|
||||||
|
5. Record `<credential-identifier>`, `<timestamp>`, `<operator>`, and `<status>` without recording any credential value.
|
||||||
|
|
||||||
|
See [docs/secrets.md](docs/secrets.md) and [docs/runbooks/credential-rotation.md](docs/runbooks/credential-rotation.md).
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -1,23 +1,16 @@
|
||||||
## 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
|
||||||
|
- `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.
|
||||||
|
- `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`.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
## Forgejo setup
|
## Forgejo setup
|
||||||
|
|
||||||
|
|
|
||||||
295
cmd/app_secret.go
Normal file
295
cmd/app_secret.go
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/openbao"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretIdentity, appSecretGrantEnvironment string
|
||||||
|
var appSecretShared, appSecretDeleteYes, appSecretGenerate bool
|
||||||
|
var appSecretGrantShared, appSecretGrantSecrets []string
|
||||||
|
|
||||||
|
var loadAppSecretConfig = config.Load
|
||||||
|
var saveAppSecretConfig = config.Save
|
||||||
|
var storeAppSecret = openbao.StoreManagedSecret
|
||||||
|
var listAppSecrets = openbao.ListManagedSecrets
|
||||||
|
var appSecretStatus = openbao.ManagedSecretStatus
|
||||||
|
var deleteAppSecret = openbao.DeleteManagedSecret
|
||||||
|
|
||||||
|
var appSecretCmd = &cobra.Command{
|
||||||
|
Use: "secret",
|
||||||
|
Short: "Manage application and shared OpenBao secret values.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var appSecretSetCmd = &cobra.Command{
|
||||||
|
Use: "set <app-or-group> <secret>",
|
||||||
|
Short: "Store a value from stdin or --file.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: runAppSecretSet,
|
||||||
|
}
|
||||||
|
|
||||||
|
var appSecretListCmd = &cobra.Command{
|
||||||
|
Use: "list <app-or-group>",
|
||||||
|
Short: "List secret names without values.",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runAppSecretList,
|
||||||
|
}
|
||||||
|
|
||||||
|
var appSecretDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <app-or-group> <secret>",
|
||||||
|
Short: "Permanently delete a secret after explicit confirmation.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: runAppSecretDelete,
|
||||||
|
}
|
||||||
|
|
||||||
|
var appSecretGrantCmd = &cobra.Command{
|
||||||
|
Use: "grant <app> <build|publish|runtime>",
|
||||||
|
Short: "Add a declarative SecretGrant to the private bootstrap config.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: runAppSecretGrant,
|
||||||
|
}
|
||||||
|
|
||||||
|
var appSecretStatusCmd = &cobra.Command{
|
||||||
|
Use: "status <app-or-group> <secret>",
|
||||||
|
Short: "Report whether a secret exists without reading its value.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: runAppSecretStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
appCmd.AddCommand(appSecretCmd)
|
||||||
|
appSecretCmd.AddCommand(appSecretSetCmd, appSecretListCmd, appSecretDeleteCmd, appSecretGrantCmd, appSecretStatusCmd)
|
||||||
|
appSecretCmd.PersistentFlags().StringVar(&appSecretConfigPath, "config", "", "Path to private bootstrap config YAML")
|
||||||
|
_ = appSecretCmd.MarkPersistentFlagRequired("config")
|
||||||
|
appSecretCmd.PersistentFlags().StringVar(&appSecretTokenFile, "token-file", "", "Path to restricted OpenBao token file for secret CRUD")
|
||||||
|
appSecretCmd.PersistentFlags().StringVar(&appSecretIdentity, "identity", "", "Encrypted operational identity: admin or e2e:<app>")
|
||||||
|
|
||||||
|
appSecretSetCmd.Flags().StringVar(&appSecretFile, "file", "", "Read the secret value from this file instead of stdin")
|
||||||
|
appSecretSetCmd.Flags().BoolVar(&appSecretGenerate, "generate", false, "Generate a random secret value without printing it")
|
||||||
|
for _, command := range []*cobra.Command{appSecretSetCmd, appSecretListCmd, appSecretDeleteCmd, appSecretStatusCmd} {
|
||||||
|
command.Flags().BoolVar(&appSecretShared, "shared", false, "Use shared/<group>/<secret> instead of apps/<app>/<secret>")
|
||||||
|
}
|
||||||
|
appSecretDeleteCmd.Flags().BoolVar(&appSecretDeleteYes, "yes", false, "Confirm permanent deletion")
|
||||||
|
appSecretGrantCmd.Flags().StringVar(&appSecretGrantEnvironment, "environment", "", "Runtime environment: staging or production")
|
||||||
|
appSecretGrantCmd.Flags().StringSliceVar(&appSecretGrantSecrets, "secret", nil, "Application secret name granted to this consumer (repeat for each)")
|
||||||
|
appSecretGrantCmd.Flags().StringSliceVar(&appSecretGrantShared, "shared", nil, "Shared secret group allowed by this grant")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAppSecretSet(cmd *cobra.Command, args []string) error {
|
||||||
|
path, kubeconfig, err := appSecretTarget(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
value, err := readAppSecretValue(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tokenFile, cleanup, err := appSecretTokenPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
if err := storeAppSecret(kubeconfig, tokenFile, path, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "stored %s\n", path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAppSecretList(cmd *cobra.Command, args []string) error {
|
||||||
|
kubeconfig, err := appSecretKubeconfigFromConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tokenFile, cleanup, err := appSecretTokenPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
values, err := listAppSecrets(kubeconfig, tokenFile, appSecretShared, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, value := range values {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAppSecretDelete(cmd *cobra.Command, args []string) error {
|
||||||
|
if !appSecretDeleteYes {
|
||||||
|
return errors.New("delete requires --yes")
|
||||||
|
}
|
||||||
|
path, kubeconfig, err := appSecretTarget(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tokenFile, cleanup, err := appSecretTokenPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
if err := deleteAppSecret(kubeconfig, tokenFile, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "deleted %s\n", path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAppSecretGrant(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := loadRequiredAppSecretConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
grant := config.SecretGrant{Application: args[0], Consumer: args[1], Environment: appSecretGrantEnvironment, Secrets: appSecretGrantSecrets, Shared: appSecretGrantShared}
|
||||||
|
if err := config.ValidateSecretGrants([]config.SecretGrant{grant}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, existing := range cfg.SecretGrants {
|
||||||
|
if existing.Application == grant.Application && existing.Consumer == grant.Consumer && existing.Environment == grant.Environment {
|
||||||
|
if reflect.DeepEqual(existing, grant) {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), "grant already declared")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("secret grant already exists with a different definition")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.SecretGrants = append(cfg.SecretGrants, grant)
|
||||||
|
if err := config.ValidateSecretGrants(cfg.SecretGrants); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := saveAppSecretConfig(appSecretConfigPath, cfg); err != nil {
|
||||||
|
return fmt.Errorf("save declarative secret grant: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), "grant declared; reconcile OpenBao through the reviewed bootstrap workflow")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAppSecretStatus(cmd *cobra.Command, args []string) error {
|
||||||
|
path, kubeconfig, err := appSecretTarget(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tokenFile, cleanup, err := appSecretTokenPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
present, err := appSecretStatus(kubeconfig, tokenFile, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if present {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%s: present\n", path)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%s: absent\n", path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appSecretTarget(args []string) (string, string, error) {
|
||||||
|
if len(args) != 2 {
|
||||||
|
return "", "", errors.New("secret target requires an application or shared group and a secret name")
|
||||||
|
}
|
||||||
|
path, err := openbao.ManagedSecretPath(appSecretShared, args[0], args[1])
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
kubeconfig, err := appSecretKubeconfigFromConfig()
|
||||||
|
return path, kubeconfig, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRequiredAppSecretConfig() (config.Config, error) {
|
||||||
|
if appSecretConfigPath == "" {
|
||||||
|
return config.Config{}, errors.New("--config is required")
|
||||||
|
}
|
||||||
|
return loadAppSecretConfig(appSecretConfigPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appSecretKubeconfigFromConfig() (string, error) {
|
||||||
|
cfg, err := loadRequiredAppSecretConfig()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir, "kubeconfig"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAppSecretValue(cmd *cobra.Command) ([]byte, error) {
|
||||||
|
if appSecretGenerate {
|
||||||
|
if appSecretFile != "" {
|
||||||
|
return nil, errors.New("--generate and --file cannot be used together")
|
||||||
|
}
|
||||||
|
value := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []byte(base64.RawURLEncoding.EncodeToString(value)), nil
|
||||||
|
}
|
||||||
|
if appSecretFile != "" {
|
||||||
|
value, err := os.ReadFile(appSecretFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read secret file: %w", err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
value, err := io.ReadAll(cmd.InOrStdin())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read secret stdin: %w", err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appSecretTokenPath() (string, func(), error) {
|
||||||
|
if appSecretTokenFile != "" {
|
||||||
|
return appSecretTokenFile, func() {}, nil
|
||||||
|
}
|
||||||
|
if appSecretIdentity == "" {
|
||||||
|
return "", nil, errors.New("--token-file or --identity is required")
|
||||||
|
}
|
||||||
|
cfg, err := loadRequiredAppSecretConfig()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
path := ""
|
||||||
|
if appSecretIdentity == "admin" {
|
||||||
|
path = "cicd/app-secret-admin"
|
||||||
|
} else if strings.HasPrefix(appSecretIdentity, "e2e:") {
|
||||||
|
path = "cicd/e2e-" + strings.TrimPrefix(appSecretIdentity, "e2e:")
|
||||||
|
} else {
|
||||||
|
return "", nil, errors.New("--identity must be admin or e2e:<app>")
|
||||||
|
}
|
||||||
|
secrets, err := bootstrap.ReadOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, errors.New("read encrypted app-secret identity")
|
||||||
|
}
|
||||||
|
token := secrets[path]["token"]
|
||||||
|
if token == "" {
|
||||||
|
return "", nil, errors.New("configured app-secret identity is absent")
|
||||||
|
}
|
||||||
|
file, err := os.CreateTemp("", "maidn-openbao-token-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if _, err := file.WriteString(token + "\n"); err != nil {
|
||||||
|
file.Close()
|
||||||
|
os.Remove(file.Name())
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
os.Remove(file.Name())
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return file.Name(), func() { _ = os.Remove(file.Name()) }, nil
|
||||||
|
}
|
||||||
87
cmd/app_secret_test.go
Normal file
87
cmd/app_secret_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppSecretSetReadsValueFromStdinWithoutOutput(t *testing.T) {
|
||||||
|
originalLoad, originalStore := loadAppSecretConfig, storeAppSecret
|
||||||
|
originalConfig, originalFile, originalToken, originalShared := appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretShared
|
||||||
|
t.Cleanup(func() {
|
||||||
|
loadAppSecretConfig, storeAppSecret = originalLoad, originalStore
|
||||||
|
appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretShared = originalConfig, originalFile, originalToken, originalShared
|
||||||
|
})
|
||||||
|
appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretShared = "private.yaml", "", "restricted-token", false
|
||||||
|
loadAppSecretConfig = func(string) (config.Config, error) {
|
||||||
|
return config.Config{Git: config.GitConfig{CloneParent: "checkouts"}, Talos: config.TalosConfig{RepoDirName: "talos", GeneratedDir: "generated"}, SOPS: config.SOPSConfig{RecoveryIdentityPath: "must-not-pass", RecoveryBundlePath: "must-not-pass"}}, nil
|
||||||
|
}
|
||||||
|
const value = "do-not-print"
|
||||||
|
storeAppSecret = func(kubeconfig, tokenPath, path string, got []byte) error {
|
||||||
|
if kubeconfig != filepath.Join("checkouts", "talos", "generated", "kubeconfig") || tokenPath != "restricted-token" || path != "apps/orders-api/publish" || string(got) != value {
|
||||||
|
t.Fatal("set did not pass only kubeconfig, token path, target, and stdin value")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
output := new(bytes.Buffer)
|
||||||
|
command := &cobra.Command{}
|
||||||
|
command.SetIn(strings.NewReader(value))
|
||||||
|
command.SetOut(output)
|
||||||
|
if err := runAppSecretSet(command, []string{"orders-api", "publish"}); err != nil || strings.Contains(output.String(), value) {
|
||||||
|
t.Fatal("set leaked its value or failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppSecretDeleteRequiresExplicitConfirmation(t *testing.T) {
|
||||||
|
originalYes := appSecretDeleteYes
|
||||||
|
t.Cleanup(func() { appSecretDeleteYes = originalYes })
|
||||||
|
appSecretDeleteYes = false
|
||||||
|
if err := runAppSecretDelete(&cobra.Command{}, []string{"orders-api", "publish"}); err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||||
|
t.Fatalf("delete confirmation error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppSecretGenerateDoesNotReadOrPrintValue(t *testing.T) {
|
||||||
|
originalGenerate, originalFile := appSecretGenerate, appSecretFile
|
||||||
|
t.Cleanup(func() { appSecretGenerate, appSecretFile = originalGenerate, originalFile })
|
||||||
|
appSecretGenerate, appSecretFile = true, ""
|
||||||
|
value, err := readAppSecretValue(&cobra.Command{})
|
||||||
|
if err != nil || len(value) < 40 || strings.Contains(string(value), "\n") {
|
||||||
|
t.Fatal("generated app secret is not a bounded opaque value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppSecretGrantOnlySavesNewDeclarativeDefinition(t *testing.T) {
|
||||||
|
originalLoad, originalSave := loadAppSecretConfig, saveAppSecretConfig
|
||||||
|
originalConfig, originalEnvironment, originalSecrets, originalShared := appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared
|
||||||
|
t.Cleanup(func() {
|
||||||
|
loadAppSecretConfig, saveAppSecretConfig = originalLoad, originalSave
|
||||||
|
appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared = originalConfig, originalEnvironment, originalSecrets, originalShared
|
||||||
|
})
|
||||||
|
appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared = "private.yaml", "production", []string{"database"}, []string{"rabbitmq"}
|
||||||
|
loadAppSecretConfig = func(string) (config.Config, error) { return config.Config{}, nil }
|
||||||
|
saved := false
|
||||||
|
saveAppSecretConfig = func(path string, cfg config.Config) error {
|
||||||
|
saved = path == "private.yaml" && len(cfg.SecretGrants) == 1 && cfg.SecretGrants[0].Application == "orders-api" && cfg.SecretGrants[0].Consumer == "runtime" && cfg.SecretGrants[0].Environment == "production" && len(cfg.SecretGrants[0].Secrets) == 1 && cfg.SecretGrants[0].Secrets[0] == "database" && len(cfg.SecretGrants[0].Shared) == 1 && cfg.SecretGrants[0].Shared[0] == "rabbitmq"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
command := &cobra.Command{}
|
||||||
|
command.SetOut(new(bytes.Buffer))
|
||||||
|
if err := runAppSecretGrant(command, []string{"orders-api", "runtime"}); err != nil || !saved {
|
||||||
|
t.Fatalf("runAppSecretGrant() = %v, saved = %t", err, saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAppSecretConfig = func(string) (config.Config, error) {
|
||||||
|
return config.Config{SecretGrants: []config.SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"other"}}}}, nil
|
||||||
|
}
|
||||||
|
saveAppSecretConfig = func(string, config.Config) error { return errors.New("must not save ambiguous grant") }
|
||||||
|
if err := runAppSecretGrant(command, []string{"orders-api", "runtime"}); err == nil || !strings.Contains(err.Error(), "different definition") {
|
||||||
|
t.Fatalf("ambiguous grant error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
204
cmd/bootstrap.go
204
cmd/bootstrap.go
|
|
@ -1,7 +1,10 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
|
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
|
|
@ -13,6 +16,7 @@ import (
|
||||||
|
|
||||||
var bootstrapConfigPath string
|
var bootstrapConfigPath string
|
||||||
var bootstrapOutputPath string
|
var bootstrapOutputPath string
|
||||||
|
var bootstrapWorkspaceDir string
|
||||||
var bootstrapMode string
|
var bootstrapMode string
|
||||||
var bootstrapYes bool
|
var bootstrapYes bool
|
||||||
var bootstrapPromptDemocraticCSI bool
|
var bootstrapPromptDemocraticCSI bool
|
||||||
|
|
@ -20,16 +24,23 @@ var bootstrapPromptOperationalSecrets bool
|
||||||
var bootstrapInitializeOpenBaoRecovery bool
|
var bootstrapInitializeOpenBaoRecovery bool
|
||||||
var bootstrapInitializeOpenBao bool
|
var bootstrapInitializeOpenBao bool
|
||||||
var bootstrapCreateForgejoRegistryToken bool
|
var bootstrapCreateForgejoRegistryToken bool
|
||||||
|
var bootstrapForgejoPasswordFile string
|
||||||
|
var bootstrapCreateForgejoDeliveryStatusToken bool
|
||||||
|
var bootstrapProvisionAppSecretIdentities bool
|
||||||
|
var bootstrapE2EApp string
|
||||||
var bootstrapRegisterWebhook bool
|
var bootstrapRegisterWebhook bool
|
||||||
var bootstrapRotateWebhookAuthorization bool
|
var bootstrapRotateWebhookAuthorization bool
|
||||||
var bootstrapPublishAppFrom string
|
|
||||||
var bootstrapMergeBootstrapPR bool
|
var bootstrapMergeBootstrapPR bool
|
||||||
var bootstrapManageNetworkBridges bool
|
var bootstrapManageNetworkBridges bool
|
||||||
|
var bootstrapEnableDelivery bool
|
||||||
|
var bootstrapDestroyDemocraticCSIStorage bool
|
||||||
|
var bootstrapRefreshTemplateRevisions bool
|
||||||
|
|
||||||
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
|
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
|
||||||
var loadPublishAppConfig = config.Load
|
var readOperationalSecrets = bootstrap.ReadOperationalSecrets
|
||||||
var ensurePublishAppCheckoutClean = forgejo.EnsureCleanCheckout
|
var initializeOpenBao = bootstrap.InitializeOpenBao
|
||||||
|
var createForgejoDeliveryStatusToken = forgejo.CreateDeliveryStatusToken
|
||||||
|
var promptForgejoDeliveryStatusToken = ui.PromptForgejoDeliveryStatusToken
|
||||||
var bootstrapCmd = &cobra.Command{
|
var bootstrapCmd = &cobra.Command{
|
||||||
Use: "bootstrap",
|
Use: "bootstrap",
|
||||||
Short: "Bootstrap Talos and Flux from config or an interactive wizard.",
|
Short: "Bootstrap Talos and Flux from config or an interactive wizard.",
|
||||||
|
|
@ -40,6 +51,7 @@ func init() {
|
||||||
rootCmd.AddCommand(bootstrapCmd)
|
rootCmd.AddCommand(bootstrapCmd)
|
||||||
bootstrapCmd.Flags().StringVar(&bootstrapConfigPath, "config", "", "Path to bootstrap config YAML")
|
bootstrapCmd.Flags().StringVar(&bootstrapConfigPath, "config", "", "Path to bootstrap config YAML")
|
||||||
bootstrapCmd.Flags().StringVar(&bootstrapOutputPath, "out", "maidn-bootstrap.yaml", "Path to save generated config")
|
bootstrapCmd.Flags().StringVar(&bootstrapOutputPath, "out", "maidn-bootstrap.yaml", "Path to save generated config")
|
||||||
|
bootstrapCmd.Flags().StringVar(&bootstrapWorkspaceDir, "workspace-dir", "", "Override workspace directory for this bootstrap run")
|
||||||
bootstrapCmd.Flags().StringVar(&bootstrapMode, "mode", string(bootstrap.Reconcile), "Lifecycle mode: reconcile or rebuild")
|
bootstrapCmd.Flags().StringVar(&bootstrapMode, "mode", string(bootstrap.Reconcile), "Lifecycle mode: reconcile or rebuild")
|
||||||
bootstrapCmd.Flags().BoolVar(&bootstrapYes, "yes", false, "Confirm destructive rebuild")
|
bootstrapCmd.Flags().BoolVar(&bootstrapYes, "yes", false, "Confirm destructive rebuild")
|
||||||
bootstrapCmd.Flags().BoolVar(&bootstrapPromptDemocraticCSI, "prompt-democratic-csi", false, "Prompt for and save Democratic CSI settings in --config")
|
bootstrapCmd.Flags().BoolVar(&bootstrapPromptDemocraticCSI, "prompt-democratic-csi", false, "Prompt for and save Democratic CSI settings in --config")
|
||||||
|
|
@ -47,16 +59,41 @@ func init() {
|
||||||
bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBaoRecovery, "initialize-openbao-recovery", false, "Create and save a separate OpenBao recovery age identity for --config")
|
bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBaoRecovery, "initialize-openbao-recovery", false, "Create and save a separate OpenBao recovery age identity for --config")
|
||||||
bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBao, "initialize-openbao", false, "Initialize OpenBao and seed encrypted operational secrets for --config")
|
bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBao, "initialize-openbao", false, "Initialize OpenBao and seed encrypted operational secrets for --config")
|
||||||
bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao")
|
bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao")
|
||||||
|
bootstrapCmd.Flags().StringVar(&bootstrapForgejoPasswordFile, "forgejo-password-file", "", "Read the Forgejo password from this local file when creating a registry token")
|
||||||
|
bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoDeliveryStatusToken, "create-forgejo-delivery-status-token", false, "Create or reuse the Forgejo delivery-status token and seed it through OpenBao")
|
||||||
|
bootstrapCmd.Flags().BoolVar(&bootstrapProvisionAppSecretIdentities, "provision-app-secret-identities", false, "Create restricted app-secret and E2E OpenBao identities")
|
||||||
|
bootstrapCmd.Flags().StringVar(&bootstrapE2EApp, "e2e-app", "", "Fixture app granted an E2E probe identity")
|
||||||
bootstrapCmd.Flags().BoolVar(&bootstrapRegisterWebhook, "register-webhook", false, "Seed OpenBao secrets and register the Forgejo webhook")
|
bootstrapCmd.Flags().BoolVar(&bootstrapRegisterWebhook, "register-webhook", false, "Seed OpenBao secrets and register the Forgejo webhook")
|
||||||
bootstrapCmd.Flags().BoolVar(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao")
|
bootstrapCmd.Flags().BoolVar(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao")
|
||||||
bootstrapCmd.Flags().StringVar(&bootstrapPublishAppFrom, "publish-app-from", "", "Push this app checkout's current branch and create a Forgejo delivery PR")
|
|
||||||
bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping")
|
bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping")
|
||||||
bootstrapCmd.Flags().BoolVar(&bootstrapManageNetworkBridges, "manage-network-bridges", false, "Persist Terraform management for existing Talos network bridges")
|
bootstrapCmd.Flags().BoolVar(&bootstrapManageNetworkBridges, "manage-network-bridges", false, "Persist Terraform management for existing Talos network bridges")
|
||||||
|
bootstrapCmd.Flags().BoolVar(&bootstrapEnableDelivery, "enable-delivery", false, "Resolve delivery defaults and reconcile the configured app delivery source")
|
||||||
|
bootstrapCmd.Flags().BoolVar(&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 bootstrapConfigPath == "" || bootstrapE2EApp == "" {
|
||||||
|
return errors.New("--provision-app-secret-identities requires --config and --e2e-app")
|
||||||
|
}
|
||||||
|
cfg, err = config.Load(bootstrapConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return bootstrap.ProvisionAppSecretIdentities(cfg, bootstrapE2EApp)
|
||||||
|
}
|
||||||
if bootstrapCreateForgejoRegistryToken {
|
if bootstrapCreateForgejoRegistryToken {
|
||||||
if bootstrapConfigPath == "" {
|
if bootstrapConfigPath == "" {
|
||||||
return fmt.Errorf("--create-forgejo-registry-token requires --config")
|
return fmt.Errorf("--create-forgejo-registry-token requires --config")
|
||||||
|
|
@ -65,7 +102,8 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := config.ValidateDelivery(cfg); err != nil {
|
cfg, err = config.ResolveDelivery(cfg)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
|
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
|
||||||
|
|
@ -73,6 +111,23 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
return createForgejoRegistryToken(cfg)
|
return createForgejoRegistryToken(cfg)
|
||||||
}
|
}
|
||||||
|
if bootstrapCreateForgejoDeliveryStatusToken {
|
||||||
|
if bootstrapConfigPath == "" {
|
||||||
|
return fmt.Errorf("--create-forgejo-delivery-status-token requires --config")
|
||||||
|
}
|
||||||
|
cfg, err = config.Load(bootstrapConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg, err = config.ResolveDelivery(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return createOrReuseForgejoDeliveryStatusToken(cfg)
|
||||||
|
}
|
||||||
if bootstrapRotateWebhookAuthorization {
|
if bootstrapRotateWebhookAuthorization {
|
||||||
if bootstrapConfigPath == "" {
|
if bootstrapConfigPath == "" {
|
||||||
return fmt.Errorf("--rotate-webhook-authorization requires --config")
|
return fmt.Errorf("--rotate-webhook-authorization requires --config")
|
||||||
|
|
@ -81,7 +136,8 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := config.ValidateDelivery(cfg); err != nil {
|
cfg, err = config.ResolveDelivery(cfg)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
authorization, err := bootstrap.NewWebhookAuthorization()
|
authorization, err := bootstrap.NewWebhookAuthorization()
|
||||||
|
|
@ -91,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 == "" {
|
||||||
|
|
@ -118,66 +174,17 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, cfg.Git.Owner, cfg.Git.Username, "", "", cfg.Flux.Branch, "")
|
manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, cfg.Git.Owner, cfg.Git.Username, "", "", cfg.Flux.Branch, "")
|
||||||
if err := manager.MergePullRequest(cfg.Flux.RepoName, "maidn/bootstrap-"+cfg.ClusterID); err != nil {
|
for _, repository := range []string{cfg.Flux.ManifestsRepo, cfg.Flux.RepoName} {
|
||||||
return err
|
open, err := manager.HasOpenPullRequest(repository, "maidn/bootstrap-"+cfg.ClusterID)
|
||||||
}
|
|
||||||
}
|
|
||||||
if bootstrapPublishAppFrom != "" {
|
|
||||||
if bootstrapConfigPath == "" {
|
|
||||||
return fmt.Errorf("--publish-app-from requires --config")
|
|
||||||
}
|
|
||||||
cfg, err = loadPublishAppConfig(bootstrapConfigPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := config.ValidateDelivery(cfg); err != nil {
|
if open {
|
||||||
|
if err := manager.MergePullRequest(repository, "maidn/bootstrap-"+cfg.ClusterID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := ensurePublishAppCheckoutClean(bootstrapPublishAppFrom); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
origin, err := forgejo.CheckoutOrigin(bootstrapPublishAppFrom)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if config.RedactURL(origin) != cfg.Delivery.AppRepoURL {
|
|
||||||
return fmt.Errorf("--publish-app-from origin does not match delivery appRepoUrl")
|
|
||||||
}
|
|
||||||
branch, err := forgejo.CurrentBranch(bootstrapPublishAppFrom)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
deliveryBranch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
owner, repo, err := forgejo.RepositoryFromURL(cfg.Delivery.AppRepoURL)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, owner, cfg.Git.Username, "", "", cfg.Delivery.AppRepoRef, "")
|
|
||||||
created, err := manager.EnsureRepository(repo, "Application source for Maidn CI/CD delivery")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if created && branch != cfg.Delivery.AppRepoRef {
|
|
||||||
return fmt.Errorf("new application repository requires the checkout branch to match delivery appRepoRef")
|
|
||||||
}
|
|
||||||
if err := manager.EnsureProtectedBranch(repo, cfg.Delivery.ProductionBranch); err != nil {
|
|
||||||
return fmt.Errorf("protect Forgejo production branch: %w", err)
|
|
||||||
}
|
|
||||||
if err := manager.PushRef(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, branch, branch); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := manager.PublishDeliveryBranch(bootstrapPublishAppFrom, branch, cfg.Delivery.AppRepoURL, deliveryBranch, func(dir string) error {
|
|
||||||
return bootstrap.GenerateAppDelivery(dir, cfg)
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return manager.CreatePullRequest(repo, "feat: migrate delivery to Tekton", deliveryBranch, cfg.Delivery.AppRepoRef)
|
|
||||||
}
|
}
|
||||||
if bootstrapConfigPath != "" {
|
if bootstrapConfigPath != "" {
|
||||||
if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery || bootstrapManageNetworkBridges {
|
if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery || bootstrapManageNetworkBridges {
|
||||||
|
|
@ -192,7 +199,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
||||||
cfg, err = config.Resolve(cfg)
|
cfg, err = config.Resolve(cfg)
|
||||||
}
|
}
|
||||||
if err == nil && bootstrapPromptOperationalSecrets {
|
if err == nil && bootstrapPromptOperationalSecrets {
|
||||||
err = config.ValidateDelivery(cfg)
|
cfg, err = config.ResolveDelivery(cfg)
|
||||||
}
|
}
|
||||||
if err == nil && bootstrapPromptOperationalSecrets {
|
if err == nil && bootstrapPromptOperationalSecrets {
|
||||||
var secrets map[string]map[string]string
|
var secrets map[string]map[string]string
|
||||||
|
|
@ -234,8 +241,12 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if bootstrapWorkspaceDir != "" {
|
||||||
|
cfg.WorkspaceDir = bootstrapWorkspaceDir
|
||||||
|
}
|
||||||
if bootstrapRegisterWebhook {
|
if bootstrapRegisterWebhook {
|
||||||
if err := config.ValidateDelivery(cfg); err != nil {
|
cfg, err = config.ResolveDelivery(cfg)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := seedForgejoOperationalCredentials(cfg); err != nil {
|
if err := seedForgejoOperationalCredentials(cfg); err != nil {
|
||||||
|
|
@ -243,10 +254,17 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes, RegisterWebhook: bootstrapRegisterWebhook}
|
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)
|
||||||
|
|
@ -261,7 +279,7 @@ func createForgejoRegistryToken(cfg config.Config) error {
|
||||||
if _, err := bootstrap.ReadOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath); err != nil {
|
if _, err := bootstrap.ReadOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
password, otp, name, err := ui.PromptForgejoRegistryToken()
|
password, otp, name, err := forgejoRegistryTokenCredentials()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -281,3 +299,59 @@ func createForgejoRegistryToken(cfg config.Config) error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func forgejoRegistryTokenCredentials() (password, otp, name string, err error) {
|
||||||
|
if bootstrapForgejoPasswordFile == "" {
|
||||||
|
return ui.PromptForgejoRegistryToken()
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(bootstrapForgejoPasswordFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("read Forgejo password file: %w", err)
|
||||||
|
}
|
||||||
|
password = strings.TrimSpace(string(data))
|
||||||
|
if password == "" {
|
||||||
|
return "", "", "", errors.New("Forgejo password file is empty")
|
||||||
|
}
|
||||||
|
return password, "", "maidn-registry", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createOrReuseForgejoDeliveryStatusToken(cfg config.Config) error {
|
||||||
|
secrets, err := readOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
values, found := secrets["cicd/forgejo-delivery-status"]
|
||||||
|
var token string
|
||||||
|
if found {
|
||||||
|
if len(values) != 1 || values["token"] == "" {
|
||||||
|
return errors.New("operational SOPS secrets has ambiguous cicd/forgejo-delivery-status state; refusing to create another token")
|
||||||
|
}
|
||||||
|
token = values["token"]
|
||||||
|
} else {
|
||||||
|
password, otp, err := promptForgejoDeliveryStatusToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token, err = createForgejoDeliveryStatusToken(cfg.Git.BaseURL, cfg.Git.Username, password, otp)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create Forgejo delivery-status token: %w", redactCredentialError(err, password, otp))
|
||||||
|
}
|
||||||
|
if err := upsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo-delivery-status", "token", token); err != nil {
|
||||||
|
return errors.New("Forgejo delivery-status token was created but could not be saved; revoke the new token in Forgejo and retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := initializeOpenBao(cfg); err != nil {
|
||||||
|
return fmt.Errorf("Forgejo delivery-status token is in encrypted operational secrets but OpenBao seeding failed; rerun bootstrap with --config and --create-forgejo-delivery-status-token: %w", redactCredentialError(err, token))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactCredentialError(err error, sensitive ...string) error {
|
||||||
|
message := err.Error()
|
||||||
|
for _, value := range sensitive {
|
||||||
|
if value != "" {
|
||||||
|
message = strings.ReplaceAll(message, value, "[REDACTED]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New(message)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
@ -21,31 +43,6 @@ func TestCreateForgejoRegistryTokenRequiresConfig(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublishAppRequiresDeliveryConfigBeforeCheckout(t *testing.T) {
|
|
||||||
originalConfigPath, originalPublish := bootstrapConfigPath, bootstrapPublishAppFrom
|
|
||||||
originalLoad, originalClean := loadPublishAppConfig, ensurePublishAppCheckoutClean
|
|
||||||
t.Cleanup(func() {
|
|
||||||
bootstrapConfigPath = originalConfigPath
|
|
||||||
bootstrapPublishAppFrom = originalPublish
|
|
||||||
loadPublishAppConfig = originalLoad
|
|
||||||
ensurePublishAppCheckoutClean = originalClean
|
|
||||||
})
|
|
||||||
bootstrapConfigPath = "test-config.yaml"
|
|
||||||
bootstrapPublishAppFrom = "app-checkout"
|
|
||||||
loadPublishAppConfig = func(string) (config.Config, error) {
|
|
||||||
return config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}}, nil
|
|
||||||
}
|
|
||||||
ensurePublishAppCheckoutClean = func(string) error {
|
|
||||||
t.Fatal("publish inspected the checkout before validating delivery config")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err := runBootstrap(nil, nil)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "delivery appName") {
|
|
||||||
t.Fatalf("runBootstrap() error = %v, want incomplete delivery error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSeedForgejoOperationalCredentialsUsesEncryptedUpsertBoundary(t *testing.T) {
|
func TestSeedForgejoOperationalCredentialsUsesEncryptedUpsertBoundary(t *testing.T) {
|
||||||
original := upsertOperationalSecret
|
original := upsertOperationalSecret
|
||||||
t.Cleanup(func() { upsertOperationalSecret = original })
|
t.Cleanup(func() { upsertOperationalSecret = original })
|
||||||
|
|
@ -87,3 +84,72 @@ func TestSeedForgejoOperationalCredentialsHidesTokenOnUpsertFailure(t *testing.T
|
||||||
t.Fatal("credential upsert failure was not clear and token-safe")
|
t.Fatal("credential upsert failure was not clear and token-safe")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateOrReuseForgejoDeliveryStatusTokenCreatesSeedsAndRedacts(t *testing.T) {
|
||||||
|
originalRead, originalPrompt, originalCreate, originalUpsert, originalInitialize := readOperationalSecrets, promptForgejoDeliveryStatusToken, createForgejoDeliveryStatusToken, upsertOperationalSecret, initializeOpenBao
|
||||||
|
t.Cleanup(func() {
|
||||||
|
readOperationalSecrets, promptForgejoDeliveryStatusToken, createForgejoDeliveryStatusToken, upsertOperationalSecret, initializeOpenBao = originalRead, originalPrompt, originalCreate, originalUpsert, originalInitialize
|
||||||
|
})
|
||||||
|
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Username: "delivery-bot"}, SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets.sops.yaml", AgeKeyPath: "age-key.txt"}}
|
||||||
|
readOperationalSecrets = func(_, _ string) (map[string]map[string]string, error) { return map[string]map[string]string{}, nil }
|
||||||
|
promptForgejoDeliveryStatusToken = func() (string, string, error) { return "password", "otp", nil }
|
||||||
|
createForgejoDeliveryStatusToken = func(baseURL, username, password, otp string) (string, error) {
|
||||||
|
if baseURL != cfg.Git.BaseURL || username != cfg.Git.Username || password != "password" || otp != "otp" {
|
||||||
|
t.Fatal("delivery token creation crossed an unexpected credential boundary")
|
||||||
|
}
|
||||||
|
return "delivery-token", nil
|
||||||
|
}
|
||||||
|
seeded := false
|
||||||
|
upsertOperationalSecret = func(path, agePath, secretPath, key, value string) error {
|
||||||
|
seeded = path == cfg.SOPS.OperationalSecretsPath && agePath == cfg.SOPS.AgeKeyPath && secretPath == "cicd/forgejo-delivery-status" && key == "token" && value == "delivery-token"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
initializeOpenBao = func(config.Config) error { return errors.New("OpenBao rejected delivery-token") }
|
||||||
|
err := createOrReuseForgejoDeliveryStatusToken(cfg)
|
||||||
|
if !seeded || err == nil || strings.Contains(err.Error(), "delivery-token") {
|
||||||
|
t.Fatalf("delivery token create/seed failure leaked or skipped a credential: seeded=%t err=%v", seeded, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOrReuseForgejoDeliveryStatusTokenReusesAndReseeds(t *testing.T) {
|
||||||
|
originalRead, originalPrompt, originalCreate, originalUpsert, originalInitialize := readOperationalSecrets, promptForgejoDeliveryStatusToken, createForgejoDeliveryStatusToken, upsertOperationalSecret, initializeOpenBao
|
||||||
|
t.Cleanup(func() {
|
||||||
|
readOperationalSecrets, promptForgejoDeliveryStatusToken, createForgejoDeliveryStatusToken, upsertOperationalSecret, initializeOpenBao = originalRead, originalPrompt, originalCreate, originalUpsert, originalInitialize
|
||||||
|
})
|
||||||
|
cfg := config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets.sops.yaml", AgeKeyPath: "age-key.txt"}}
|
||||||
|
readOperationalSecrets = func(_, _ string) (map[string]map[string]string, error) {
|
||||||
|
return map[string]map[string]string{"cicd/forgejo-delivery-status": {"token": "existing-token"}}, nil
|
||||||
|
}
|
||||||
|
promptForgejoDeliveryStatusToken = func() (string, string, error) { t.Fatal("existing delivery token must not prompt"); return "", "", nil }
|
||||||
|
createForgejoDeliveryStatusToken = func(string, string, string, string) (string, error) {
|
||||||
|
t.Fatal("existing delivery token must not be recreated")
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
upsertOperationalSecret = func(string, string, string, string, string) error {
|
||||||
|
t.Fatal("existing delivery token must not be rewritten")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seeded := false
|
||||||
|
initializeOpenBao = func(received config.Config) error {
|
||||||
|
seeded = received.SOPS.OperationalSecretsPath == cfg.SOPS.OperationalSecretsPath
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := createOrReuseForgejoDeliveryStatusToken(cfg); err != nil || !seeded {
|
||||||
|
t.Fatalf("delivery token reuse did not reseed OpenBao: seeded=%t err=%v", seeded, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOrReuseForgejoDeliveryStatusTokenRejectsAmbiguousState(t *testing.T) {
|
||||||
|
originalRead, originalPrompt := readOperationalSecrets, promptForgejoDeliveryStatusToken
|
||||||
|
t.Cleanup(func() { readOperationalSecrets, promptForgejoDeliveryStatusToken = originalRead, originalPrompt })
|
||||||
|
readOperationalSecrets = func(_, _ string) (map[string]map[string]string, error) {
|
||||||
|
return map[string]map[string]string{"cicd/forgejo-delivery-status": {"token": "", "legacy": "value"}}, nil
|
||||||
|
}
|
||||||
|
promptForgejoDeliveryStatusToken = func() (string, string, error) {
|
||||||
|
t.Fatal("ambiguous state must not create a replacement token")
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
if err := createOrReuseForgejoDeliveryStatusToken(config.Config{}); err == nil || !strings.Contains(err.Error(), "ambiguous") {
|
||||||
|
t.Fatalf("ambiguous delivery token state = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
77
cmd/e2e.go
Normal file
77
cmd/e2e.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/e2e"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
e2eKubeconfig, e2eContext, e2eExternalSecret, e2ePipelineRun string
|
||||||
|
e2ePreviewURL, e2ePreviewSentinel string
|
||||||
|
e2ePromotionPullsURL, e2ePromotionOwner, e2ePromotionHead string
|
||||||
|
e2ePromotionTokenEnv, e2ePromotionTokenFile string
|
||||||
|
e2eFluxKustomizations []string
|
||||||
|
e2eTimeout, e2eInterval time.Duration
|
||||||
|
e2eRunner = e2e.DefaultRunner
|
||||||
|
errE2EChecks = errors.New("e2e checks failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
var e2eCmd = &cobra.Command{
|
||||||
|
Use: "e2e",
|
||||||
|
Short: "Run bounded, read-only delivery checks and emit JSON.",
|
||||||
|
RunE: runE2E,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(e2eCmd)
|
||||||
|
e2eCmd.Flags().StringVar(&e2eKubeconfig, "kubeconfig", "", "Path to a read-only kubeconfig")
|
||||||
|
e2eCmd.Flags().StringVar(&e2eContext, "context", "", "Kubernetes context name")
|
||||||
|
e2eCmd.Flags().StringSliceVar(&e2eFluxKustomizations, "flux-kustomization", nil, "Flux Kustomization namespace/name (repeatable)")
|
||||||
|
e2eCmd.Flags().StringVar(&e2eExternalSecret, "external-secret", "", "ExternalSecret namespace/name")
|
||||||
|
e2eCmd.Flags().StringVar(&e2ePipelineRun, "pipelinerun", "", "PipelineRun namespace/name")
|
||||||
|
e2eCmd.Flags().StringVar(&e2ePreviewURL, "preview-url", "", "Credential-free preview HTTP(S) URL")
|
||||||
|
e2eCmd.Flags().StringVar(&e2ePreviewSentinel, "preview-sentinel", "", "Non-secret text expected in the preview response")
|
||||||
|
e2eCmd.Flags().StringVar(&e2ePromotionPullsURL, "promotion-pulls-url", "", "Credential-free Forgejo pulls API URL without query parameters")
|
||||||
|
e2eCmd.Flags().StringVar(&e2ePromotionOwner, "promotion-owner", "", "Forgejo owner for the promotion branch")
|
||||||
|
e2eCmd.Flags().StringVar(&e2ePromotionHead, "promotion-head", "", "Expected promotion branch name")
|
||||||
|
e2eCmd.Flags().StringVar(&e2ePromotionTokenEnv, "promotion-token-env", "", "Environment variable containing the Forgejo token")
|
||||||
|
e2eCmd.Flags().StringVar(&e2ePromotionTokenFile, "promotion-token-file", "", "Path to a file containing the Forgejo token")
|
||||||
|
e2eCmd.Flags().DurationVar(&e2eTimeout, "timeout", 2*time.Minute, "Maximum wait for each check (up to 10m)")
|
||||||
|
e2eCmd.Flags().DurationVar(&e2eInterval, "interval", 2*time.Second, "Polling interval")
|
||||||
|
for _, name := range []string{"kubeconfig", "flux-kustomization", "external-secret", "pipelinerun", "preview-url", "preview-sentinel", "promotion-pulls-url", "promotion-owner", "promotion-head"} {
|
||||||
|
_ = e2eCmd.MarkFlagRequired(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runE2E(cmd *cobra.Command, _ []string) error {
|
||||||
|
token, err := e2e.ReadToken(e2ePromotionTokenEnv, e2ePromotionTokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx := cmd.Context()
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
result, err := e2eRunner().Run(ctx, e2e.Options{
|
||||||
|
Kubeconfig: e2eKubeconfig, Context: e2eContext, FluxKustomizations: e2eFluxKustomizations,
|
||||||
|
ExternalSecret: e2eExternalSecret, PipelineRun: e2ePipelineRun,
|
||||||
|
PreviewURL: e2ePreviewURL, PreviewSentinel: e2ePreviewSentinel,
|
||||||
|
PromotionPullsURL: e2ePromotionPullsURL, PromotionOwner: e2ePromotionOwner, PromotionHead: e2ePromotionHead,
|
||||||
|
PromotionToken: token, Timeout: e2eTimeout, Interval: e2eInterval,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !result.Passed {
|
||||||
|
return errE2EChecks
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
56
cmd/e2e_mutate.go
Normal file
56
cmd/e2e_mutate.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/e2emutate"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
e2eMutateForgejoURL, e2eMutateOwner, e2eMutateRepo, e2eMutateBranch, e2eMutateSHA string
|
||||||
|
e2eMutateTokenEnv, e2eMutateTokenFile string
|
||||||
|
e2eMutateOpenPR bool
|
||||||
|
e2eMutator = e2emutate.DefaultMutator
|
||||||
|
)
|
||||||
|
|
||||||
|
var e2eMutateCmd = &cobra.Command{
|
||||||
|
Use: "e2e-mutate",
|
||||||
|
Short: "Update a Forgejo E2E fixture branch and optionally open its PR.",
|
||||||
|
RunE: runE2EMutate,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(e2eMutateCmd)
|
||||||
|
e2eMutateCmd.Flags().StringVar(&e2eMutateForgejoURL, "forgejo-url", "", "Credential-free Forgejo base URL")
|
||||||
|
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(&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(&e2eMutateTokenEnv, "token-env", "", "Environment variable containing the Forgejo token")
|
||||||
|
e2eMutateCmd.Flags().StringVar(&e2eMutateTokenFile, "token-file", "", "Path to a file containing the Forgejo token")
|
||||||
|
e2eMutateCmd.Flags().BoolVar(&e2eMutateOpenPR, "open-pr", false, "Open one pull request from the fixture branch to main")
|
||||||
|
for _, name := range []string{"forgejo-url", "owner", "repo", "branch", "sha"} {
|
||||||
|
_ = e2eMutateCmd.MarkFlagRequired(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runE2EMutate(cmd *cobra.Command, _ []string) error {
|
||||||
|
token, err := e2emutate.ReadToken(e2eMutateTokenEnv, e2eMutateTokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx := cmd.Context()
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
return e2eMutator().Run(ctx, e2emutate.Options{
|
||||||
|
ForgejoURL: e2eMutateForgejoURL,
|
||||||
|
Owner: e2eMutateOwner,
|
||||||
|
Repo: e2eMutateRepo,
|
||||||
|
Branch: e2eMutateBranch,
|
||||||
|
SHA: e2eMutateSHA,
|
||||||
|
Token: token,
|
||||||
|
OpenPR: e2eMutateOpenPR,
|
||||||
|
})
|
||||||
|
}
|
||||||
46
cmd/e2e_mutate_test.go
Normal file
46
cmd/e2e_mutate_test.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/e2emutate"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type commandMutationHTTP struct {
|
||||||
|
calls []*http.Request
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *commandMutationHTTP) Do(request *http.Request) (*http.Response, error) {
|
||||||
|
f.calls = append(f.calls, request)
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2EMutateCommandWiringUsesOnlyTokenReferences(t *testing.T) {
|
||||||
|
originalMutator := e2eMutator
|
||||||
|
originalURL, originalOwner, originalRepo, originalBranch, originalSHA := e2eMutateForgejoURL, e2eMutateOwner, e2eMutateRepo, e2eMutateBranch, e2eMutateSHA
|
||||||
|
originalEnv, originalFile, originalOpenPR := e2eMutateTokenEnv, e2eMutateTokenFile, e2eMutateOpenPR
|
||||||
|
t.Cleanup(func() {
|
||||||
|
e2eMutator = originalMutator
|
||||||
|
e2eMutateForgejoURL, e2eMutateOwner, e2eMutateRepo, e2eMutateBranch, e2eMutateSHA = originalURL, originalOwner, originalRepo, originalBranch, originalSHA
|
||||||
|
e2eMutateTokenEnv, e2eMutateTokenFile, e2eMutateOpenPR = originalEnv, originalFile, originalOpenPR
|
||||||
|
})
|
||||||
|
|
||||||
|
command, _, err := rootCmd.Find([]string{"e2e-mutate"})
|
||||||
|
if err != nil || command != e2eMutateCmd || command.Flags().Lookup("token") != nil {
|
||||||
|
t.Fatalf("e2e-mutate command or token flags are not wired safely: %v", err)
|
||||||
|
}
|
||||||
|
fake := &commandMutationHTTP{}
|
||||||
|
e2eMutator = func() e2emutate.Mutator { return e2emutate.Mutator{HTTP: fake} }
|
||||||
|
e2eMutateForgejoURL, e2eMutateOwner = "https://git.example.test", "Maidn"
|
||||||
|
e2eMutateRepo, e2eMutateBranch = "maidn-e2e-repo", "maidn-e2e-branch"
|
||||||
|
e2eMutateSHA = "0123456789abcdef0123456789abcdef01234567"
|
||||||
|
e2eMutateTokenEnv, e2eMutateTokenFile, e2eMutateOpenPR = "E2E_MUTATE_TEST_TOKEN", "", false
|
||||||
|
t.Setenv(e2eMutateTokenEnv, "test-token")
|
||||||
|
if err := runE2EMutate(&cobra.Command{}, nil); err != nil || len(fake.calls) != 1 || fake.calls[0].Method != http.MethodPatch {
|
||||||
|
t.Fatalf("runE2EMutate() = %v, calls = %#v", err, fake.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
cmd/fresh.go
Normal file
107
cmd/fresh.go
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string
|
||||||
|
var onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration string
|
||||||
|
var freshCreateOrganization, freshEnableDelivery, freshYes bool
|
||||||
|
var freshMode string
|
||||||
|
|
||||||
|
var loadFreshConfig = config.Load
|
||||||
|
var loadAppOnboardConfig = config.LoadRaw
|
||||||
|
var runFreshOrganization = bootstrap.RunFreshOrganization
|
||||||
|
var resolveAppOnboarding = config.ResolveAppOnboarding
|
||||||
|
var onboardApp = bootstrap.OnboardApp
|
||||||
|
|
||||||
|
var bootstrapInitCmd = &cobra.Command{
|
||||||
|
Use: "init",
|
||||||
|
Short: "Create and lock a fresh Forgejo organization bootstrap workspace.",
|
||||||
|
RunE: runBootstrapInit,
|
||||||
|
}
|
||||||
|
|
||||||
|
var appCmd = &cobra.Command{
|
||||||
|
Use: "app",
|
||||||
|
Short: "Manage application delivery scaffolding.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var appOnboardCmd = &cobra.Command{
|
||||||
|
Use: "onboard",
|
||||||
|
Short: "Validate an application checkout and add its centrally-owned delivery contract.",
|
||||||
|
RunE: runAppOnboard,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
bootstrapCmd.AddCommand(bootstrapInitCmd)
|
||||||
|
bootstrapInitCmd.Flags().StringVar(&freshConfigPath, "config", "", "Path to private bootstrap config YAML")
|
||||||
|
bootstrapInitCmd.Flags().StringVar(&freshOrganization, "organization", "", "New Forgejo organization name")
|
||||||
|
bootstrapInitCmd.Flags().BoolVar(&freshCreateOrganization, "create-organization", false, "Create the Forgejo organization when absent")
|
||||||
|
bootstrapInitCmd.Flags().BoolVar(&freshEnableDelivery, "enable-delivery", false, "Deprecated: init always initializes the shared delivery platform")
|
||||||
|
bootstrapInitCmd.Flags().StringVar(&freshMode, "mode", string(bootstrap.Reconcile), "Lifecycle mode: reconcile or rebuild")
|
||||||
|
bootstrapInitCmd.Flags().BoolVar(&freshYes, "yes", false, "Confirm destructive rebuild")
|
||||||
|
_ = bootstrapInitCmd.MarkFlagRequired("config")
|
||||||
|
_ = bootstrapInitCmd.MarkFlagRequired("organization")
|
||||||
|
|
||||||
|
rootCmd.AddCommand(appCmd)
|
||||||
|
appCmd.AddCommand(appOnboardCmd)
|
||||||
|
appOnboardCmd.Flags().StringVar(&onboardConfigPath, "config", "", "Path to private bootstrap config YAML")
|
||||||
|
appOnboardCmd.Flags().StringVar(&onboardFrom, "from", "", "Clean application checkout to validate")
|
||||||
|
appOnboardCmd.Flags().StringVar(&onboardAppName, "app-name", "", "Application name 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(&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("from")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBootstrapInit(cmd *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := loadFreshConfig(freshConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
plan, err := runFreshOrganization(cfg, bootstrap.FreshOrganizationOptions{Organization: freshOrganization, CreateOrganization: freshCreateOrganization, EnableDelivery: freshEnableDelivery, Mode: bootstrap.Mode(freshMode), ConfirmRebuild: freshYes})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, phase := range plan.Phases {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "[PLAN] %s\n", phase)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAppOnboard(_ *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := loadAppOnboardConfig(onboardConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if onboardAppName != "" {
|
||||||
|
cfg.Delivery.AppName = onboardAppName
|
||||||
|
}
|
||||||
|
if onboardAppRepoURL != "" {
|
||||||
|
cfg.Delivery.AppRepoURL = onboardAppRepoURL
|
||||||
|
}
|
||||||
|
if 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)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return onboardApp(cfg, onboardFrom)
|
||||||
|
}
|
||||||
112
cmd/fresh_test.go
Normal file
112
cmd/fresh_test.go
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBootstrapInitAppliesFluxDefaultsBeforeFreshValidation(t *testing.T) {
|
||||||
|
originalRun := runFreshOrganization
|
||||||
|
originalConfigPath, originalOrganization := freshConfigPath, freshOrganization
|
||||||
|
originalCreate, originalDelivery, originalMode, originalYes := freshCreateOrganization, freshEnableDelivery, freshMode, freshYes
|
||||||
|
t.Cleanup(func() {
|
||||||
|
runFreshOrganization = originalRun
|
||||||
|
freshConfigPath, freshOrganization = originalConfigPath, originalOrganization
|
||||||
|
freshCreateOrganization, freshEnableDelivery, freshMode, freshYes = originalCreate, originalDelivery, originalMode, originalYes
|
||||||
|
})
|
||||||
|
|
||||||
|
workspace := t.TempDir()
|
||||||
|
cfg := config.Config{
|
||||||
|
ClusterID: "test-cluster",
|
||||||
|
WorkspaceDir: workspace,
|
||||||
|
Git: config.GitConfig{Provider: "forgejo", BaseURL: "https://git.example.test", Username: "bot", Token: "test-token", Owner: "new-org", CloneParent: filepath.Join(workspace, "checkouts")},
|
||||||
|
Flux: config.FluxConfig{RepoName: "cluster", ClusterDomain: "example.test"},
|
||||||
|
Talos: config.TalosConfig{
|
||||||
|
Proxmox: config.TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "test-secret"},
|
||||||
|
Cluster: config.TalosClusterConfig{Name: "test-cluster", Domain: "example.test"},
|
||||||
|
Image: config.TalosImageConfig{TalosVersion: "v1.13.6", SchematicID: "abcdefghijkl"},
|
||||||
|
Nodes: []config.TalosNode{{Name: "cp-01", VMID: 100, Role: "controlplane", Networks: []config.TalosNetwork{{IP: "192.168.45.3", CIDR: "192.168.45.0/28", Gateway: "192.168.45.1", VLANID: 45}, {IP: "192.168.45.18", CIDR: "192.168.45.16/28", VLANID: 451}}}},
|
||||||
|
},
|
||||||
|
Cilium: config.CiliumConfig{LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"},
|
||||||
|
DemocraticCSI: config.DemocraticCSIConfig{TrueNASAPIKey: "test-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"},
|
||||||
|
}
|
||||||
|
data, err := yaml.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runFreshOrganization = func(got config.Config, options bootstrap.FreshOrganizationOptions) (bootstrap.FreshOrganizationPlan, error) {
|
||||||
|
if got.Flux.Branch != "main" || got.Flux.ClusterPath != "./clusters/maidn-cd-0" || got.Flux.ManifestsRepo != "cicd-deployment-manifests" || got.Flux.TektonCatalogRepo != "tekton-pipelines" {
|
||||||
|
t.Fatalf("fresh init Flux defaults = %#v", got.Flux)
|
||||||
|
}
|
||||||
|
_, plan, err := bootstrap.PlanFreshOrganization(got, options)
|
||||||
|
return plan, err
|
||||||
|
}
|
||||||
|
freshConfigPath, freshOrganization = path, "new-org"
|
||||||
|
freshCreateOrganization, freshEnableDelivery, freshMode, freshYes = true, false, string(bootstrap.Reconcile), false
|
||||||
|
command := &cobra.Command{}
|
||||||
|
command.SetOut(io.Discard)
|
||||||
|
if err := runBootstrapInit(command, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
|
||||||
|
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
|
||||||
|
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
||||||
|
originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration := onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration
|
||||||
|
t.Cleanup(func() {
|
||||||
|
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
|
||||||
|
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
||||||
|
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration
|
||||||
|
})
|
||||||
|
loadAppOnboardConfig = func(string) (config.Config, error) { return config.Config{}, nil }
|
||||||
|
resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") }
|
||||||
|
onboardApp = func(config.Config, string) error {
|
||||||
|
t.Fatal("onboarding reached external work before validating config")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
|
||||||
|
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = "", "", "", "", "", ""
|
||||||
|
if err := runAppOnboard(nil, nil); err == nil {
|
||||||
|
t.Fatal("onboarding accepted invalid configuration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppOnboardPassesOnlyValidatedConfigAndCheckout(t *testing.T) {
|
||||||
|
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
|
||||||
|
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
|
||||||
|
originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration := onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration
|
||||||
|
t.Cleanup(func() {
|
||||||
|
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
|
||||||
|
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
|
||||||
|
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"}}
|
||||||
|
loadAppOnboardConfig = func(string) (config.Config, error) { return cfg, nil }
|
||||||
|
resolveAppOnboarding = func(got config.Config) (config.Config, error) { return got, nil }
|
||||||
|
calls := 0
|
||||||
|
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" || got.Delivery.BuildStrategy != "runtime" || got.Delivery.BuildOutputDirectory != "dist/fixture" || got.Delivery.BuildConfiguration != "ci" {
|
||||||
|
t.Fatal("onboarding used the wrong checkout or config")
|
||||||
|
}
|
||||||
|
calls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
|
||||||
|
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 {
|
||||||
|
t.Fatalf("runAppOnboard() = %v, calls = %d", err, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
|
@ -10,12 +11,15 @@ import (
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "cicd-tool",
|
Use: "cicd-tool",
|
||||||
Short: "A CLI tool to manage CI/CD setup for applications.",
|
Short: "A CLI tool to manage CI/CD setup for applications.",
|
||||||
|
SilenceErrors: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||||
func Execute() {
|
func Execute() {
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
fmt.Println(err)
|
if !errors.Is(err, errE2EChecks) {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
}
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
|
||||||
23
docs/delivery-feedback.md
Normal file
23
docs/delivery-feedback.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Delivery feedback
|
||||||
|
|
||||||
|
The central cluster registration owns the Tekton tasks that update one marked
|
||||||
|
Forgejo pull-request comment. Application repositories do not carry active
|
||||||
|
delivery Tasks. The comment contains only the verified preview URL, a redacted
|
||||||
|
task-status summary, and the PipelineRun name. Set the optional
|
||||||
|
`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
|
||||||
|
`bootstrap --config <private-bootstrap-config> --create-forgejo-delivery-status-token`.
|
||||||
|
It creates or reuses `maidn-delivery-status` at
|
||||||
|
`cicd/forgejo-delivery-status.token`, with only `write:issue` and
|
||||||
|
`write:repository` for pull-request comments and commit statuses. It does not
|
||||||
|
reuse the Git clone/push token. The generated task never prints the token or
|
||||||
|
Forgejo API responses.
|
||||||
|
|
||||||
|
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
|
||||||
|
the manifest-repository promotion PR; it does not claim a production deploy.
|
||||||
|
The protected `maidn/platform-<app>` chart must name both resources after
|
||||||
|
`delivery.appName`; the HTTPRoute's first hostname must be the public HTTPS
|
||||||
|
preview/staging URL.
|
||||||
38
docs/e2e.md
Normal file
38
docs/e2e.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# OCI E2E runner
|
||||||
|
|
||||||
|
`cicd-tool e2e` is a read-only verifier: it uses `kubectl get` and HTTP GET
|
||||||
|
only. It never applies resources, reconciles Flux, or calls bootstrap/rebuild.
|
||||||
|
It emits one JSON result and exits non-zero when a check fails.
|
||||||
|
|
||||||
|
Supply explicit resource identifiers and credential-free URLs. The runner waits
|
||||||
|
independently (bounded by `--timeout`, maximum ten minutes) for Flux
|
||||||
|
Kustomizations and an ExternalSecret `Ready=True`, a terminal PipelineRun,
|
||||||
|
the preview response sentinel, and exactly one open Forgejo promotion PR for
|
||||||
|
the supplied branch. It reads the Forgejo token only from `--promotion-token-env`
|
||||||
|
or `--promotion-token-file`; do not pass tokens or credential-bearing URLs.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cicd-tool e2e \
|
||||||
|
--kubeconfig /run/secrets/kubeconfig \
|
||||||
|
--flux-kustomization flux-system/tekton \
|
||||||
|
--external-secret tekton-pipelines/forgejo-webhook \
|
||||||
|
--pipelinerun tekton-pipelines/<run-name> \
|
||||||
|
--preview-url https://<preview-host>/ \
|
||||||
|
--preview-sentinel <non-secret-sentinel> \
|
||||||
|
--promotion-pulls-url https://<forgejo>/api/v1/repos/<owner>/<manifests>/pulls \
|
||||||
|
--promotion-owner <owner> \
|
||||||
|
--promotion-head maidn/promotion-<app>-<sha> \
|
||||||
|
--promotion-token-env FORGEJO_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
by the image's non-root user. The build context excludes known secret-bearing
|
||||||
|
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,6 +23,86 @@ 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.
|
||||||
|
|
||||||
|
## Standard Delivery Workflow
|
||||||
|
|
||||||
|
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
|
||||||
|
`tekton.<cluster-domain>` and the configured Forgejo owner:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go run . bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization
|
||||||
|
```
|
||||||
|
|
||||||
|
The source-owned onboarding implementation is retired. Central onboarding
|
||||||
|
creates a reviewed cluster registration only; it never writes `.tekton` or
|
||||||
|
`.maidn` resources to an application repository. Before merging that
|
||||||
|
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
|
||||||
|
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>
|
||||||
|
```
|
||||||
|
|
||||||
|
For a runtime build, set `--build-strategy runtime`; static-only output options
|
||||||
|
remain harmless. Central onboarding creates the reviewed cluster-registration
|
||||||
|
change after the platform package exists. The cluster repository owns
|
||||||
|
`base/tekton/apps/<app>.yaml`; the application repository remains a build input
|
||||||
|
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 PR targets the configured generic
|
||||||
|
`<owner>/<manifests-repo>` repository semantics (for example,
|
||||||
|
`test-org-2/<manifests-repo>`), never through a source checkout's former owner.
|
||||||
|
|
||||||
## Rebuild
|
## Rebuild
|
||||||
|
|
||||||
Use only when an authorized recovery requires recreating the Talos VM:
|
Use only when an authorized recovery requires recreating the Talos VM:
|
||||||
|
|
@ -34,6 +114,22 @@ go run . bootstrap --config <private-bootstrap-config> --mode=rebuild --yes
|
||||||
The rebuild replaces Terraform-managed Talos VMs only. It does not manage or
|
The rebuild replaces Terraform-managed Talos VMs only. It does not manage or
|
||||||
delete TrueNAS datasets or unrelated infrastructure.
|
delete TrueNAS datasets or unrelated infrastructure.
|
||||||
|
|
||||||
|
## Planned Cluster Removal
|
||||||
|
|
||||||
|
There is currently no `maidn cluster remove` or `maidn uninstall` command.
|
||||||
|
Do not substitute direct Terraform or Kubernetes deletion for a supported
|
||||||
|
workflow. A future remove command must require the named-cluster confirmation,
|
||||||
|
limit its Terraform scope to Maidn Talos VMs, and leave external storage and
|
||||||
|
shared infrastructure under their respective operator procedures.
|
||||||
|
|
||||||
|
The command must handle partial removal and retry safely: verify each VM's
|
||||||
|
actual Proxmox state, accept already-absent Maidn VMs, report API timeouts as
|
||||||
|
incomplete rather than successful removal, and leave a sanitized removal
|
||||||
|
record in the isolated workspace. It must not delete shared network bridges,
|
||||||
|
TrueNAS datasets, SOPS identities, or unrelated infrastructure. Until that
|
||||||
|
command exists, an operator must use the authorized recovery procedure and
|
||||||
|
resolve Proxmox API failures before retrying bootstrap.
|
||||||
|
|
||||||
## OpenBao And Webhooks
|
## OpenBao And Webhooks
|
||||||
|
|
||||||
After a rebuild or an OpenBao restart, refresh Kubernetes auth and reseed the
|
After a rebuild or an OpenBao restart, refresh Kubernetes auth and reseed the
|
||||||
|
|
|
||||||
58
docs/runbooks/credential-rotation.md
Normal file
58
docs/runbooks/credential-rotation.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Credential rotation runbook
|
||||||
|
|
||||||
|
## Authorization and inputs
|
||||||
|
|
||||||
|
Before any live action, obtain approval for the exact `<credential-identifier>`, `<credential-purpose>`, `<approved-scope>`, `<consumer-inventory>`, `<overlap-window>`, `<maintenance-window>`, and `<rollback-owner>`. The authorized operator must have access to the approved configuration, SOPS identity, recovery identity and encrypted recovery bundle, OpenBao recovery recipient, and the required Forgejo, Flux, webhook, Tekton, registry, and cluster permissions. Credential values must be entered only through an approved secure prompt or standard input boundary; never provide them to an agent or place them in an argument, file, log, or evidence record.
|
||||||
|
|
||||||
|
## Consumer inventory template
|
||||||
|
|
||||||
|
| Consumer | Logical secret path | Credential identifier | Owner | Validation | Status |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `<consumer>` | `<encrypted-logical-path>` | `<credential-identifier>` | `<owner>` | `<sanitized-check>` | `<pending>` |
|
||||||
|
|
||||||
|
Include Forgejo API access, Flux source authentication, webhook authorization, Tekton pipeline consumers, and registry pull/push consumers when applicable. Stop for an unlisted or ambiguous consumer; do not guess its credential source.
|
||||||
|
|
||||||
|
## Ordered rotation
|
||||||
|
|
||||||
|
1. Confirm the old credential remains valid for the approved overlap window and capture the sanitized baseline status.
|
||||||
|
2. Create the replacement with the approved minimum scope. Do not revoke or replace the old credential yet.
|
||||||
|
3. Put the replacement only in `sops.operationalSecretsPath`; encrypt it with `sops.ageKeyPath`. Keep the recovery identity and encrypted recovery bundle in their configured local paths. Do not write plaintext configuration or generated files.
|
||||||
|
4. Reseed OpenBao from the encrypted operational-secrets file. Verify OpenBao is initialized, unsealed, and ready, then verify External Secrets has refreshed the intended target Secret without reading or printing its data.
|
||||||
|
5. Validate each inventoried consumer: Forgejo authenticated operation, Flux source authentication and reconciliation readiness, webhook delivery authorization, Tekton trigger and PipelineRun behavior, and registry pull/push behavior. Record only identifiers, timestamps, and pass/fail status.
|
||||||
|
6. If every validation passes during the overlap window, revoke the old credential through its owning system and repeat the affected consumer checks.
|
||||||
|
|
||||||
|
## Forgejo registry package-write recovery
|
||||||
|
|
||||||
|
Use this procedure when a Tekton image push fails with `401 Unauthorized: reqPackageAccess` for the target organization.
|
||||||
|
|
||||||
|
1. Obtain approval for package write access to the named Forgejo organization. Do not reuse a repository-status token or a user-wide credential without this approval.
|
||||||
|
2. From the configured MaidnCLI checkout, run the prompted command below. Enter the Forgejo password and optional OTP only at its secure prompts.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cicd-tool bootstrap --config <bootstrap-config> --create-forgejo-registry-token
|
||||||
|
```
|
||||||
|
|
||||||
|
3. The command creates or rotates the registry credential, saves its Docker configuration only in encrypted operational secrets, seeds OpenBao, and refreshes `tekton-pipelines/forgejo-registry-credentials`. Never copy the generated token or Docker configuration into a shell command, manifest, or report.
|
||||||
|
4. Confirm the ExternalSecret is ready without reading Secret data, then retry one disposable PipelineRun targeting the approved organization. Record only the image repository, PipelineRun name, and pass/fail result.
|
||||||
|
5. If the retry still returns `reqPackageAccess`, stop. Confirm the token owner has package write permission for the target organization and create a replacement through the same prompted command. Do not broaden application, Forgejo status, or webhook credentials as a workaround.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If OpenBao reseed, readiness, External Secrets refresh, or any consumer validation fails, stop before revocation. Restore the previously encrypted operational-secret version, reseed OpenBao, verify readiness and all affected consumers, and keep the old credential active. Escalate if the previous encrypted version or recovery material is unavailable; do not reconstruct values from logs or configuration.
|
||||||
|
|
||||||
|
## Sanitized evidence template
|
||||||
|
|
||||||
|
| Field | Record |
|
||||||
|
| --- | --- |
|
||||||
|
| Rotation ID | `<rotation-id>` |
|
||||||
|
| Credential identifier | `<credential-identifier>` |
|
||||||
|
| Scope approval | `<approval-reference>` |
|
||||||
|
| Operator | `<operator-id>` |
|
||||||
|
| Started / completed | `<timestamp>` / `<timestamp>` |
|
||||||
|
| OpenBao initialized / unsealed / ready | `<status>` / `<status>` / `<status>` |
|
||||||
|
| External Secrets target refresh | `<target-identifier>: <status>` |
|
||||||
|
| Forgejo / Flux / webhook / Tekton / registry | `<status>` / `<status>` / `<status>` / `<status>` / `<status>` |
|
||||||
|
| Old credential revocation | `<not-attempted|completed|rolled-back>` |
|
||||||
|
| Follow-up | `<sanitized-reference>` |
|
||||||
|
|
||||||
|
Never include values, encoded values, headers, token fragments, private keys, recovery shares, or decrypted manifest content in the evidence.
|
||||||
150
docs/secret-grants.md
Normal file
150
docs/secret-grants.md
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
# Application secret grants
|
||||||
|
|
||||||
|
Maidn stores secret values in OpenBao. Git contains only references and access
|
||||||
|
policy. A repository does not get OpenBao access: one named workload identity
|
||||||
|
gets one reviewed grant.
|
||||||
|
|
||||||
|
## Grant classes
|
||||||
|
|
||||||
|
| Consumer | OpenBao path | Kubernetes namespace | Intended use |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `build` | declared `apps/<app>/<secret>` entries | `tekton-pipelines` | Read-only dependency credentials |
|
||||||
|
| `publish` | declared `apps/<app>/<secret>` entries | `tekton-pipelines` | One app's artifact repository credential |
|
||||||
|
| `runtime` | declared `apps/<app>/<secret>` entries | `staging` or `production` | Service runtime credentials |
|
||||||
|
| shared | `shared/<name>/*` | Granted consumer only | Deliberately shared broker, database, or API credentials |
|
||||||
|
|
||||||
|
`build` code is repository-controlled. Anything granted to it is readable by a
|
||||||
|
pull request author. Do not grant deployment, production, Git write, or
|
||||||
|
administrator credentials to a build.
|
||||||
|
|
||||||
|
## Bootstrap configuration
|
||||||
|
|
||||||
|
Declare access in the private bootstrap configuration. This declaration has no
|
||||||
|
secret values and is reviewed with the platform configuration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
secretGrants:
|
||||||
|
- application: orders-api
|
||||||
|
consumer: publish
|
||||||
|
secrets:
|
||||||
|
- registry
|
||||||
|
shared:
|
||||||
|
- internal-npm
|
||||||
|
- application: orders-api
|
||||||
|
consumer: runtime
|
||||||
|
environment: staging
|
||||||
|
secrets:
|
||||||
|
- database-staging
|
||||||
|
shared:
|
||||||
|
- rabbitmq
|
||||||
|
- application: orders-api
|
||||||
|
consumer: runtime
|
||||||
|
environment: production
|
||||||
|
secrets:
|
||||||
|
- database-production
|
||||||
|
shared:
|
||||||
|
- rabbitmq
|
||||||
|
```
|
||||||
|
|
||||||
|
MaidnCLI validates application, consumer, environment, application-secret, and
|
||||||
|
shared-grant names.
|
||||||
|
It creates one OpenBao policy and Kubernetes-auth role for every declaration.
|
||||||
|
The role names are deterministic:
|
||||||
|
|
||||||
|
```text
|
||||||
|
maidn-<app>-build
|
||||||
|
maidn-<app>-publish
|
||||||
|
maidn-<app>-runtime-<environment>
|
||||||
|
```
|
||||||
|
|
||||||
|
The policy permits only the exact `apps/<app>/<secret>` paths and
|
||||||
|
`shared/<name>/*` paths listed in its declaration. The CLI stores one property
|
||||||
|
named `value` at each `apps/<app>/<secret>` or `shared/<group>/<secret>` path.
|
||||||
|
A shared value is stored once, for example at `shared/rabbitmq/password`, and
|
||||||
|
each service requiring it declares that shared grant. Do not copy it into
|
||||||
|
application paths.
|
||||||
|
|
||||||
|
## GitOps resources
|
||||||
|
|
||||||
|
The application environment manifests create the matching ServiceAccount,
|
||||||
|
SecretStore, and ExternalSecret. These resources are reviewed GitOps content;
|
||||||
|
never create them with `kubectl apply`.
|
||||||
|
|
||||||
|
For `orders-api` staging, use the matching identity and namespace:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: maidn-orders-api-runtime-staging
|
||||||
|
namespace: orders-api-staging
|
||||||
|
---
|
||||||
|
apiVersion: external-secrets.io/v1
|
||||||
|
kind: SecretStore
|
||||||
|
metadata:
|
||||||
|
name: openbao-orders-api-staging
|
||||||
|
namespace: orders-api-staging
|
||||||
|
spec:
|
||||||
|
provider:
|
||||||
|
vault:
|
||||||
|
server: http://openbao.openbao.svc:8200
|
||||||
|
path: secret
|
||||||
|
version: v2
|
||||||
|
auth:
|
||||||
|
kubernetes:
|
||||||
|
mountPath: kubernetes
|
||||||
|
role: maidn-orders-api-runtime-staging
|
||||||
|
serviceAccountRef:
|
||||||
|
name: maidn-orders-api-runtime-staging
|
||||||
|
---
|
||||||
|
apiVersion: external-secrets.io/v1
|
||||||
|
kind: ExternalSecret
|
||||||
|
metadata:
|
||||||
|
name: orders-api-rabbitmq
|
||||||
|
namespace: orders-api-staging
|
||||||
|
spec:
|
||||||
|
refreshInterval: 1h
|
||||||
|
secretStoreRef:
|
||||||
|
name: openbao-orders-api-staging
|
||||||
|
kind: SecretStore
|
||||||
|
target:
|
||||||
|
name: orders-api-rabbitmq
|
||||||
|
creationPolicy: Owner
|
||||||
|
data:
|
||||||
|
- secretKey: value
|
||||||
|
remoteRef:
|
||||||
|
key: shared/rabbitmq/password
|
||||||
|
property: value
|
||||||
|
```
|
||||||
|
|
||||||
|
The workload references only `orders-api-rabbitmq` in its own namespace. Each
|
||||||
|
application gets a dedicated ServiceAccount, SecretStore, and target Secret in
|
||||||
|
the shared environment namespace; do not use another application's identity.
|
||||||
|
|
||||||
|
## Artifact repositories
|
||||||
|
|
||||||
|
Create one credential per application and artifact target. Its upstream
|
||||||
|
permissions must be limited to the exact package, hosted repository, or object
|
||||||
|
prefix. Examples: one npm scope, one Maven hosted repository, one OCI image,
|
||||||
|
or S3 `PutObject` for one prefix.
|
||||||
|
|
||||||
|
Use `publish` for credentials needed to upload a completed artifact. Use
|
||||||
|
`build` only for credentials that a build must read, such as a private package
|
||||||
|
registry. A custom build upload is an exception: it exposes the token to build
|
||||||
|
code and therefore requires a narrowly scoped, disposable credential.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
1. Create the least-privilege upstream credential.
|
||||||
|
2. Write its value with `cicd-tool app secret set` using stdin, `--file`, or
|
||||||
|
`--generate`. Use a least-privilege `--token-file` or a bootstrap-provisioned
|
||||||
|
`--identity`; never put a value or token in YAML, a URL, a command argument,
|
||||||
|
output, or Git.
|
||||||
|
3. Add the reviewed grant and GitOps resources.
|
||||||
|
4. Bootstrap or reconcile to create the OpenBao role and policy.
|
||||||
|
5. Verify the target ExternalSecret becomes Ready without printing its Secret.
|
||||||
|
6. On revocation, remove the grant and ExternalSecret, revoke the upstream
|
||||||
|
credential, then restart affected workloads.
|
||||||
|
|
||||||
|
See [secrets.md](secrets.md) for encrypted operational-material rules and
|
||||||
|
[runbooks/credential-rotation.md](runbooks/credential-rotation.md) for rotation.
|
||||||
|
|
@ -1,66 +1,34 @@
|
||||||
# Bootstrap Secret Inputs
|
# Secrets and recovery material
|
||||||
|
|
||||||
`bootstrap` uses the Flux age identity in ignored `.age/` storage to encrypt
|
## Storage contract
|
||||||
the configured Democratic CSI Secret directly into the generated cluster
|
|
||||||
repository. Its TrueNAS API key is never printed or committed in plaintext.
|
|
||||||
|
|
||||||
`operational-secrets.sops.yaml` is decrypted only in MaidnCLI memory after
|
- `sops.operationalSecretsPath` defaults to `<workspace>/operational-secrets.sops.yaml`. It is a SOPS-encrypted input containing operational secrets, keyed by logical OpenBao paths. It is never plaintext Git content.
|
||||||
OpenBao is initialized. It is not copied to the cluster repository. Its schema
|
- `sops.ageKeyPath` defaults to `<workspace>/.age/key.txt`. This Flux SOPS identity is private local material; do not commit, print, or copy it.
|
||||||
is:
|
- `sops.recoveryIdentityPath` defaults to `<workspace>/.age/recovery-key.txt`. This separate OpenBao-recovery identity is private local material; do not commit, print, or copy it.
|
||||||
|
- `sops.recoveryBundlePath` defaults to `<workspace>/.recovery/openbao-recovery.age`. This bundle is encrypted to `sops.recoveryRecipient`; it contains OpenBao recovery material and is never plaintext Git content.
|
||||||
|
- During generated Flux repository reconciliation, recovery material is rendered only into `base/openbao/unseal.sops.yaml`, encrypted with the Flux SOPS identity. This is the narrowly scoped encrypted recovery path for the `openbao-unseal` controller; no plaintext recovery material belongs in a workload, config, log, or command argument.
|
||||||
|
|
||||||
```yaml
|
## Bootstrap gates
|
||||||
secrets:
|
|
||||||
cicd/forgejo:
|
|
||||||
username: encrypted-value
|
|
||||||
token: encrypted-value
|
|
||||||
cicd/forgejo-registry:
|
|
||||||
dockerconfigjson: encrypted-value
|
|
||||||
cicd/forgejo-webhook:
|
|
||||||
authorization: encrypted-value
|
|
||||||
platform/pihole:
|
|
||||||
server: encrypted-value
|
|
||||||
password: encrypted-value
|
|
||||||
platform/cloudflare:
|
|
||||||
api-token: encrypted-value
|
|
||||||
platform/cloudflare-tunnel:
|
|
||||||
credentials: encrypted-value
|
|
||||||
config: encrypted-value
|
|
||||||
```
|
|
||||||
|
|
||||||
Keys are written to OpenBao KV v2 under `secret/<path>`. Additional paths are
|
`bootstrap` resolves and validates the configuration before reconciliation. Any bootstrap requires a readable SOPS age identity. `talos.autoBootstrapFlux: true` additionally requires an existing encrypted operational-secrets file and a nonempty recovery recipient. A non-webhook bootstrap additionally requires configured, existing recovery-identity and recovery-bundle files.
|
||||||
allowed when they use lowercase path characters and scalar property names.
|
|
||||||
|
|
||||||
Set all `democraticCsi` settings in the bootstrap configuration or provide
|
The webhook-only path requires a complete delivery contract, an approved configuration, and a readable SOPS age identity. It must reseed OpenBao and observe the refreshed `forgejo-webhook` target Secret plus the EventListener and Pipeline before Forgejo is changed. The registry-token, delivery-status-token, and webhook-authorization flows also require an explicit configuration; they are live credential operations and are not offline-safe.
|
||||||
them through the interactive wizard. The CLI writes those values only to
|
|
||||||
`base/democratic-csi/secret.sops.yaml` in the generated cluster repository.
|
|
||||||
For an existing configuration, run `bootstrap --config <path>
|
|
||||||
--prompt-democratic-csi` to enter the settings with the API key masked.
|
|
||||||
|
|
||||||
For a new operational-secret input, run `bootstrap --config <path>
|
## Rules
|
||||||
--prompt-operational-secrets`. It derives Forgejo Git and registry credentials
|
|
||||||
from the configured Forgejo account, prompts for the Pi-hole server and masked
|
|
||||||
password and Cloudflare API token, and generates the webhook authorization
|
|
||||||
value. The Cloudflare token issues the Gateway certificate and manages explicit
|
|
||||||
Tunnel CNAME records; it is not used by ExternalDNS.
|
|
||||||
|
|
||||||
Import the user-approved local credential file before bootstrap validates the
|
- Credential values are accepted only at an approved secure input boundary and stored only in encrypted operational or recovery material.
|
||||||
tunnel state:
|
- Never pass credential values in CLI arguments, URLs, logs, Git commits, generated config, tickets, or evidence.
|
||||||
|
- Do not revoke a previous credential until OpenBao, External Secrets, and every listed consumer have passed validation.
|
||||||
|
- Use the sanitized procedure in [runbooks/credential-rotation.md](runbooks/credential-rotation.md) for any live rotation.
|
||||||
|
- Application, artifact, and shared-secret access is documented in [secret-grants.md](secret-grants.md). Secret values remain outside that declaration.
|
||||||
|
|
||||||
```powershell
|
## Automated app-secret identities
|
||||||
go run . cloudflare-tunnel import --config <private-bootstrap-config> --credentials-file <local-credentials-json>
|
|
||||||
```
|
|
||||||
|
|
||||||
The import command reads the file only in memory, validates its credential
|
Use `bootstrap --provision-app-secret-identities --e2e-app <app>` to create a
|
||||||
shape, stores only the credentials JSON and terminal-404 local config shown
|
short-lived non-root `admin` identity and an exact-path `e2e-probe` identity.
|
||||||
above, and seeds OpenBao. It never saves a Tunnel run token.
|
Bootstrap reads root recovery material only through its encrypted recovery
|
||||||
The template maps these simple OpenBao properties to the `credentials.json` and
|
bundle, writes the generated tokens only into encrypted operational state, and
|
||||||
`config.yml` Kubernetes filenames.
|
never prints either value. `app secret --identity admin` and
|
||||||
|
`app secret --identity e2e:<app>` create a temporary local token file only for
|
||||||
`cicd/forgejo-webhook.authorization` is required for delivery bootstrap. The
|
the command lifetime. Root tokens, recovery bundles, and unseal shares are not
|
||||||
CLI supplies it as the Forgejo webhook Authorization header and Tekton compares
|
valid app-secret identities.
|
||||||
that header against the ExternalSecret-derived `forgejo-webhook` Secret.
|
|
||||||
|
|
||||||
Run `bootstrap --config <path> --initialize-openbao-recovery` to create and
|
|
||||||
save a separate recovery age identity for `openbao-recovery.age`. The Flux SOPS
|
|
||||||
age identity is installed in `flux-system`; it must not encrypt OpenBao
|
|
||||||
recovery material.
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ metadata:
|
||||||
namespace: tekton-pipelines
|
namespace: tekton-pipelines
|
||||||
spec:
|
spec:
|
||||||
stepTemplate:
|
stepTemplate:
|
||||||
|
env:
|
||||||
|
- name: HOME
|
||||||
|
value: /tekton/home
|
||||||
securityContext:
|
securityContext:
|
||||||
runAsNonRoot: true
|
runAsNonRoot: true
|
||||||
runAsUser: 1000
|
runAsUser: 1000
|
||||||
|
|
@ -24,9 +27,11 @@ spec:
|
||||||
- name: environment
|
- name: environment
|
||||||
- name: pr-number
|
- name: pr-number
|
||||||
default: ""
|
default: ""
|
||||||
- name: app-url
|
- name: forgejo-base-url
|
||||||
default: ""
|
default: ""
|
||||||
- name: app-revision
|
- name: forgejo-owner
|
||||||
|
default: ""
|
||||||
|
- name: manifests-repo
|
||||||
default: ""
|
default: ""
|
||||||
steps:
|
steps:
|
||||||
- name: update
|
- name: update
|
||||||
|
|
@ -48,10 +53,12 @@ spec:
|
||||||
value: $(params.environment)
|
value: $(params.environment)
|
||||||
- name: PR_NUMBER
|
- name: PR_NUMBER
|
||||||
value: $(params.pr-number)
|
value: $(params.pr-number)
|
||||||
- name: APP_URL
|
- name: FORGEJO_BASE_URL
|
||||||
value: $(params.app-url)
|
value: $(params.forgejo-base-url)
|
||||||
- name: APP_REVISION
|
- name: FORGEJO_OWNER
|
||||||
value: $(params.app-revision)
|
value: $(params.forgejo-owner)
|
||||||
|
- name: MANIFESTS_REPO
|
||||||
|
value: $(params.manifests-repo)
|
||||||
script: |
|
script: |
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
@ -61,18 +68,32 @@ spec:
|
||||||
valid_commit() { [ "${#1}" -eq 40 ] || fail; case "$1" in *[!0-9a-fA-F]*) fail ;; esac; }
|
valid_commit() { [ "${#1}" -eq 40 ] || fail; case "$1" in *[!0-9a-fA-F]*) fail ;; esac; }
|
||||||
valid_url() { case "$1" in https://*/*.git) ;; *) fail ;; esac; case "$1" in *[@?#]*) fail ;; esac; }
|
valid_url() { case "$1" in https://*/*.git) ;; *) fail ;; esac; case "$1" in *[@?#]*) fail ;; esac; }
|
||||||
valid_repository() { case "$1" in */*) ;; *) fail ;; esac; case "$1" in *..*|*//*|/*|*/) fail ;; esac; }
|
valid_repository() { case "$1" in */*) ;; *) fail ;; esac; case "$1" in *..*|*//*|/*|*/) fail ;; esac; }
|
||||||
|
valid_repository_part() { case "$1" in ''|*[!A-Za-z0-9._-]*|.*|*.) fail ;; esac; }
|
||||||
|
valid_forgejo_base_url() { case "$1" in https://*) ;; *) fail ;; esac; host=${1#https://}; case "$host" in *:*) name=${host%:*}; port=${host##*:}; case "$port" in ''|*[!0-9]*) fail ;; esac ;; *) name=$host ;; esac; case "$name" in ''|*[!A-Za-z0-9.-]*|.*|*.) fail ;; esac; }
|
||||||
valid_pr_number() { case "$1" in [1-9]*) ;; *) fail ;; esac; case "$1" in *[!0-9]*) fail ;; esac; [ $((${#APP_NAME} + ${#1} + 4)) -le 63 ] || fail; }
|
valid_pr_number() { case "$1" in [1-9]*) ;; *) fail ;; esac; case "$1" in *[!0-9]*) fail ;; esac; [ $((${#APP_NAME} + ${#1} + 4)) -le 63 ] || fail; }
|
||||||
valid_name "$APP_NAME"
|
valid_name "$APP_NAME"
|
||||||
valid_url "$MANIFESTS_URL"
|
valid_url "$MANIFESTS_URL"
|
||||||
valid_revision "$MANIFESTS_BRANCH"
|
valid_revision "$MANIFESTS_BRANCH"
|
||||||
valid_repository "$APP_REPOSITORY"
|
valid_repository "$APP_REPOSITORY"
|
||||||
valid_commit "$TAG"
|
valid_commit "$TAG"
|
||||||
|
if [ "$ENVIRONMENT" = production ]; then
|
||||||
|
valid_forgejo_base_url "$FORGEJO_BASE_URL"
|
||||||
|
valid_repository_part "$FORGEJO_OWNER"
|
||||||
|
valid_repository_part "$MANIFESTS_REPO"
|
||||||
|
[ "$MANIFESTS_URL" = "$FORGEJO_BASE_URL/$FORGEJO_OWNER/$MANIFESTS_REPO.git" ] || fail
|
||||||
|
PROMOTION_BRANCH="maidn/promotion-$APP_NAME-$TAG"
|
||||||
|
if git ls-remote --exit-code "$MANIFESTS_URL" "refs/heads/$PROMOTION_BRANCH" >/dev/null 2>&1; then
|
||||||
|
git clone --branch "$PROMOTION_BRANCH" "$MANIFESTS_URL" /tmp/manifests
|
||||||
|
else
|
||||||
git clone --branch "$MANIFESTS_BRANCH" "$MANIFESTS_URL" /tmp/manifests
|
git clone --branch "$MANIFESTS_BRANCH" "$MANIFESTS_URL" /tmp/manifests
|
||||||
|
git -C /tmp/manifests checkout -b "$PROMOTION_BRANCH"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
git clone --branch "$MANIFESTS_BRANCH" "$MANIFESTS_URL" /tmp/manifests
|
||||||
|
fi
|
||||||
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
|
||||||
|
|
@ -98,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
|
||||||
|
|
@ -138,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
|
||||||
|
|
@ -159,10 +181,108 @@ spec:
|
||||||
root=apps/previews/kustomization.yaml
|
root=apps/previews/kustomization.yaml
|
||||||
grep -q '^resources:' "$root" || fail
|
grep -q '^resources:' "$root" || fail
|
||||||
grep -qxF " - $APP_NAME-pr-$PR_NUMBER" "$root" 2>/dev/null || printf ' - %s-pr-%s\n' "$APP_NAME" "$PR_NUMBER" >> "$root"
|
grep -qxF " - $APP_NAME-pr-$PR_NUMBER" "$root" 2>/dev/null || printf ' - %s-pr-%s\n' "$APP_NAME" "$PR_NUMBER" >> "$root"
|
||||||
elif [ "$ENVIRONMENT" = staging ] || [ "$ENVIRONMENT" = production ]; then
|
elif [ "$ENVIRONMENT" = staging ]; then
|
||||||
app_dir="apps/$ENVIRONMENT/$APP_NAME"
|
app_dir="apps/staging/$APP_NAME"
|
||||||
[ -f "$app_dir/release.yaml" ] || fail
|
root=apps/staging/kustomization.yaml
|
||||||
|
if [ -e "$app_dir/release.yaml" ]; then
|
||||||
|
[ -d "$app_dir" ] && [ ! -L "$app_dir" ] && [ -f "$app_dir/release.yaml" ] && [ ! -L "$app_dir/release.yaml" ] || fail
|
||||||
sed -i -E "s|^([[:space:]]*tag:).*|\1 $TAG|" "$app_dir/release.yaml"
|
sed -i -E "s|^([[:space:]]*tag:).*|\1 $TAG|" "$app_dir/release.yaml"
|
||||||
|
grep -qxF " namespace: staging" "$app_dir/release.yaml" || sed -i "/^ name: $APP_NAME$/a\ namespace: staging" "$app_dir/release.yaml"
|
||||||
|
grep -qxF " namespace: staging" "$app_dir/release.yaml" || fail
|
||||||
|
else
|
||||||
|
[ ! -e "$app_dir" ] || fail
|
||||||
|
mkdir -p "$app_dir"
|
||||||
|
cat > "$app_dir/release.yaml" <<EOF
|
||||||
|
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||||
|
kind: HelmRelease
|
||||||
|
metadata:
|
||||||
|
name: $APP_NAME
|
||||||
|
namespace: staging
|
||||||
|
spec:
|
||||||
|
interval: 5m
|
||||||
|
chart:
|
||||||
|
spec:
|
||||||
|
chart: ./charts/$APP_NAME
|
||||||
|
reconcileStrategy: Revision
|
||||||
|
sourceRef:
|
||||||
|
kind: GitRepository
|
||||||
|
name: $APP_NAME
|
||||||
|
namespace: flux-system
|
||||||
|
valuesFiles:
|
||||||
|
- ./charts/$APP_NAME/values.yaml
|
||||||
|
- ./staging/values.yaml
|
||||||
|
values:
|
||||||
|
image:
|
||||||
|
repository: $IMAGE
|
||||||
|
tag: $TAG
|
||||||
|
EOF
|
||||||
|
cat > "$app_dir/kustomization.yaml" <<EOF
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- release.yaml
|
||||||
|
EOF
|
||||||
|
if [ ! -e "$root" ]; then
|
||||||
|
mkdir -p "$(dirname "$root")"
|
||||||
|
cat > "$root" <<EOF
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
[ -f "$root" ] && [ ! -L "$root" ] && grep -qx 'resources:' "$root" || fail
|
||||||
|
grep -qxF " - $APP_NAME" "$root" 2>/dev/null || printf ' - %s\n' "$APP_NAME" >> "$root"
|
||||||
|
fi
|
||||||
|
elif [ "$ENVIRONMENT" = production ]; then
|
||||||
|
app_dir="apps/production/$APP_NAME"
|
||||||
|
root=apps/production/kustomization.yaml
|
||||||
|
if [ -e "$app_dir" ]; then
|
||||||
|
[ -d "$app_dir" ] && [ ! -L "$app_dir" ] || fail
|
||||||
|
fi
|
||||||
|
mkdir -p "$app_dir"
|
||||||
|
for file in "$app_dir/release.yaml" "$app_dir/kustomization.yaml"; do
|
||||||
|
[ ! -e "$file" ] || { [ -f "$file" ] && [ ! -L "$file" ]; } || fail
|
||||||
|
done
|
||||||
|
cat > "$app_dir/release.yaml" <<EOF
|
||||||
|
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||||
|
kind: HelmRelease
|
||||||
|
metadata:
|
||||||
|
name: $APP_NAME
|
||||||
|
namespace: production
|
||||||
|
spec:
|
||||||
|
interval: 5m
|
||||||
|
chart:
|
||||||
|
spec:
|
||||||
|
chart: ./charts/$APP_NAME
|
||||||
|
reconcileStrategy: Revision
|
||||||
|
sourceRef:
|
||||||
|
kind: GitRepository
|
||||||
|
name: $APP_NAME
|
||||||
|
namespace: flux-system
|
||||||
|
valuesFiles:
|
||||||
|
- ./charts/$APP_NAME/values.yaml
|
||||||
|
- ./production/values.yaml
|
||||||
|
values:
|
||||||
|
image:
|
||||||
|
repository: $IMAGE
|
||||||
|
tag: $TAG
|
||||||
|
EOF
|
||||||
|
cat > "$app_dir/kustomization.yaml" <<EOF
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- release.yaml
|
||||||
|
EOF
|
||||||
|
if [ ! -e "$root" ]; then
|
||||||
|
mkdir -p "$(dirname "$root")"
|
||||||
|
cat > "$root" <<EOF
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
[ -f "$root" ] && [ ! -L "$root" ] && grep -qx 'resources:' "$root" || fail
|
||||||
|
grep -qxF " - $APP_NAME" "$root" 2>/dev/null || printf ' - %s\n' "$APP_NAME" >> "$root"
|
||||||
else
|
else
|
||||||
fail
|
fail
|
||||||
fi
|
fi
|
||||||
|
|
@ -170,7 +290,311 @@ spec:
|
||||||
git config user.email maidn@free-maidn.com
|
git config user.email maidn@free-maidn.com
|
||||||
git add apps
|
git add apps
|
||||||
git diff --cached --quiet || git commit -m "chore: deploy $APP_NAME $TAG"
|
git diff --cached --quiet || git commit -m "chore: deploy $APP_NAME $TAG"
|
||||||
|
if [ "$ENVIRONMENT" = production ]; then
|
||||||
|
git push origin "HEAD:$PROMOTION_BRANCH"
|
||||||
|
credentials=$(printf 'url=%s\n\n' "$MANIFESTS_URL" | GIT_TERMINAL_PROMPT=0 git credential fill)
|
||||||
|
forgejo_user=$(printf '%s\n' "$credentials" | sed -n 's/^username=//p')
|
||||||
|
forgejo_password=$(printf '%s\n' "$credentials" | sed -n 's/^password=//p')
|
||||||
|
unset credentials
|
||||||
|
[ -n "$forgejo_user" ] && [ -n "$forgejo_password" ] || fail
|
||||||
|
forgejo_auth=$(printf '%s:%s' "$forgejo_user" "$forgejo_password" | base64 | tr -d '\n')
|
||||||
|
unset forgejo_user forgejo_password
|
||||||
|
pr_endpoint="$FORGEJO_BASE_URL/api/v1/repos/$FORGEJO_OWNER/$MANIFESTS_REPO/pulls"
|
||||||
|
pr_query="$pr_endpoint?state=open&head=$FORGEJO_OWNER%3A$PROMOTION_BRANCH"
|
||||||
|
pr_response=$(mktemp)
|
||||||
|
pr_body=$(mktemp)
|
||||||
|
trap 'rm -f "$pr_response" "$pr_body"' EXIT
|
||||||
|
open_pr_count() {
|
||||||
|
grep -q '^[[:space:]]*\[' "$pr_response" || fail
|
||||||
|
grep -o '"number"[[:space:]]*:[[:space:]]*[0-9][0-9]*' "$pr_response" | wc -l | tr -d ' '
|
||||||
|
}
|
||||||
|
wget -q -O "$pr_response" --header="Authorization: Basic $forgejo_auth" "$pr_query" || fail
|
||||||
|
case "$(open_pr_count)" in
|
||||||
|
0)
|
||||||
|
printf '{"title":"chore: promote %s %s","head":"%s","base":"%s"}' "$APP_NAME" "$TAG" "$PROMOTION_BRANCH" "$MANIFESTS_BRANCH" > "$pr_body"
|
||||||
|
wget -q -O "$pr_response" --header="Authorization: Basic $forgejo_auth" --header="Content-Type: application/json" --post-file "$pr_body" "$pr_endpoint" || true
|
||||||
|
wget -q -O "$pr_response" --header="Authorization: Basic $forgejo_auth" "$pr_query" || fail
|
||||||
|
[ "$(open_pr_count)" = 1 ] || fail
|
||||||
|
;;
|
||||||
|
1) ;;
|
||||||
|
*) fail ;;
|
||||||
|
esac
|
||||||
|
promotion_pr_number=$(grep -o '"number"[[:space:]]*:[[:space:]]*[0-9][0-9]*' "$pr_response" | sed -n '1s/.*:[[:space:]]*//p')
|
||||||
|
valid_pr_number "$promotion_pr_number"
|
||||||
|
printf 'Promotion PR opened or updated: %s/%s/%s/pulls/%s\n' "$FORGEJO_BASE_URL" "$FORGEJO_OWNER" "$MANIFESTS_REPO" "$promotion_pr_number"
|
||||||
|
unset forgejo_auth
|
||||||
|
else
|
||||||
git push origin "$MANIFESTS_BRANCH"
|
git push origin "$MANIFESTS_BRANCH"
|
||||||
|
fi
|
||||||
|
---
|
||||||
|
apiVersion: tekton.dev/v1
|
||||||
|
kind: Task
|
||||||
|
metadata:
|
||||||
|
name: {{ .AppName }}-wait-delivery
|
||||||
|
namespace: tekton-pipelines
|
||||||
|
spec:
|
||||||
|
params:
|
||||||
|
- name: app-name
|
||||||
|
- name: environment
|
||||||
|
- name: image
|
||||||
|
- name: tag
|
||||||
|
- name: pr-number
|
||||||
|
default: ""
|
||||||
|
results:
|
||||||
|
- name: preview-url
|
||||||
|
description: Verified preview or staging HTTPRoute URL.
|
||||||
|
stepTemplate:
|
||||||
|
env:
|
||||||
|
- name: HOME
|
||||||
|
value: /tmp
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
steps:
|
||||||
|
- name: wait-for-traffic
|
||||||
|
image: alpine/k8s:1.33.4
|
||||||
|
env:
|
||||||
|
- name: APP_NAME
|
||||||
|
value: $(params.app-name)
|
||||||
|
- name: ENVIRONMENT
|
||||||
|
value: $(params.environment)
|
||||||
|
- name: PR_NUMBER
|
||||||
|
value: $(params.pr-number)
|
||||||
|
- name: IMAGE
|
||||||
|
value: $(params.image)
|
||||||
|
- name: TAG
|
||||||
|
value: $(params.tag)
|
||||||
|
- name: RESULT_PATH
|
||||||
|
value: $(results.preview-url.path)
|
||||||
|
script: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
fail() { exit 1; }
|
||||||
|
valid_app() { case "$1" in ''|*[!a-z0-9-]*|-*|*-) fail ;; esac; [ "${#1}" -le 47 ] || fail; }
|
||||||
|
valid_pr() { case "$1" in [1-9]*) ;; *) fail ;; esac; case "$1" in *[!0-9]*) fail ;; esac; }
|
||||||
|
valid_image() { case "$1" in ''|/*|*/|*..*|*//*|*[!A-Za-z0-9._/:-]*) fail ;; esac; }
|
||||||
|
valid_tag() { [ "${#1}" -eq 40 ] || fail; case "$1" in *[!0-9a-fA-F]*) fail ;; esac; }
|
||||||
|
valid_host() { case "$1" in ''|.*|*.) fail ;; esac; case "$1" in *[!A-Za-z0-9.-]*) fail ;; esac; }
|
||||||
|
valid_app "$APP_NAME"
|
||||||
|
valid_image "$IMAGE"
|
||||||
|
valid_tag "$TAG"
|
||||||
|
case "$ENVIRONMENT" in
|
||||||
|
preview)
|
||||||
|
valid_pr "$PR_NUMBER"
|
||||||
|
namespace="$APP_NAME-pr-$PR_NUMBER"
|
||||||
|
;;
|
||||||
|
staging) namespace=staging ;;
|
||||||
|
*) fail ;;
|
||||||
|
esac
|
||||||
|
attempts=120
|
||||||
|
while :; do
|
||||||
|
deployed_image=$(kubectl -n "$namespace" get "deployment/$APP_NAME" -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || true)
|
||||||
|
[ "$deployed_image" = "$IMAGE:$TAG" ] && break
|
||||||
|
attempts=$((attempts - 1))
|
||||||
|
[ "$attempts" -gt 0 ] || fail
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
kubectl -n "$namespace" rollout status "deployment/$APP_NAME" --timeout=600s
|
||||||
|
kubectl -n "$namespace" wait --for=condition=Available "deployment/$APP_NAME" --timeout=600s
|
||||||
|
kubectl -n "$namespace" wait --for=jsonpath='{.status.parents[0].conditions[?(@.type=="Accepted")].status}'=True "httproute/$APP_NAME" --timeout=600s
|
||||||
|
host=$(kubectl -n "$namespace" get "httproute/$APP_NAME" -o jsonpath='{.spec.hostnames[0]}')
|
||||||
|
valid_host "$host"
|
||||||
|
url="https://$host"
|
||||||
|
attempts=12
|
||||||
|
while ! wget -q --spider --timeout=10 "$url" >/dev/null 2>&1; do
|
||||||
|
attempts=$((attempts - 1))
|
||||||
|
[ "$attempts" -gt 0 ] || fail
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
printf '%s' "$url" > "$RESULT_PATH"
|
||||||
|
---
|
||||||
|
apiVersion: tekton.dev/v1
|
||||||
|
kind: Task
|
||||||
|
metadata:
|
||||||
|
name: {{ .AppName }}-report-delivery
|
||||||
|
namespace: tekton-pipelines
|
||||||
|
spec:
|
||||||
|
params:
|
||||||
|
- name: app-name
|
||||||
|
- name: app-repository
|
||||||
|
- name: pr-number
|
||||||
|
- name: event-action
|
||||||
|
- name: pipeline-run
|
||||||
|
- name: clone-status
|
||||||
|
- name: build-status
|
||||||
|
- name: push-status
|
||||||
|
- name: update-status
|
||||||
|
- name: readiness-status
|
||||||
|
- name: cleanup-status
|
||||||
|
volumes:
|
||||||
|
- name: delivery-status
|
||||||
|
emptyDir: {}
|
||||||
|
stepTemplate:
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
steps:
|
||||||
|
- name: read-preview-url
|
||||||
|
image: alpine/k8s:1.33.4
|
||||||
|
env:
|
||||||
|
- name: APP_NAME
|
||||||
|
value: $(params.app-name)
|
||||||
|
- name: PR_NUMBER
|
||||||
|
value: $(params.pr-number)
|
||||||
|
- name: EVENT_ACTION
|
||||||
|
value: $(params.event-action)
|
||||||
|
- name: READINESS_STATUS
|
||||||
|
value: $(params.readiness-status)
|
||||||
|
volumeMounts:
|
||||||
|
- name: delivery-status
|
||||||
|
mountPath: /delivery
|
||||||
|
script: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
fail() { exit 1; }
|
||||||
|
valid_app() { case "$1" in ''|*[!a-z0-9-]*|-*|*-) fail ;; esac; [ "${#1}" -le 47 ] || fail; }
|
||||||
|
valid_pr() { case "$1" in [1-9]*) ;; *) fail ;; esac; case "$1" in *[!0-9]*) fail ;; esac; }
|
||||||
|
valid_host() { case "$1" in ''|.*|*.) fail ;; esac; case "$1" in *[!A-Za-z0-9.-]*) fail ;; esac; }
|
||||||
|
[ "$EVENT_ACTION" = closed ] && exit 0
|
||||||
|
[ "$READINESS_STATUS" = Succeeded ] || exit 0
|
||||||
|
valid_app "$APP_NAME"
|
||||||
|
valid_pr "$PR_NUMBER"
|
||||||
|
host=$(kubectl -n "$APP_NAME-pr-$PR_NUMBER" get "httproute/$APP_NAME" -o jsonpath='{.spec.hostnames[0]}')
|
||||||
|
valid_host "$host"
|
||||||
|
printf 'https://%s' "$host" > /delivery/preview-url
|
||||||
|
- name: update-pr-comment
|
||||||
|
image: python:3.13-alpine
|
||||||
|
env:
|
||||||
|
- name: FORGEJO_BASE_URL
|
||||||
|
value: {{ quote .ForgejoBaseURL }}
|
||||||
|
- name: APP_NAME
|
||||||
|
value: $(params.app-name)
|
||||||
|
- name: APP_REPOSITORY
|
||||||
|
value: $(params.app-repository)
|
||||||
|
- name: PR_NUMBER
|
||||||
|
value: $(params.pr-number)
|
||||||
|
- name: EVENT_ACTION
|
||||||
|
value: $(params.event-action)
|
||||||
|
- name: PIPELINE_RUN
|
||||||
|
value: $(params.pipeline-run)
|
||||||
|
- name: CLONE_STATUS
|
||||||
|
value: $(params.clone-status)
|
||||||
|
- name: BUILD_STATUS
|
||||||
|
value: $(params.build-status)
|
||||||
|
- name: PUSH_STATUS
|
||||||
|
value: $(params.push-status)
|
||||||
|
- name: UPDATE_STATUS
|
||||||
|
value: $(params.update-status)
|
||||||
|
- name: READINESS_STATUS
|
||||||
|
value: $(params.readiness-status)
|
||||||
|
- name: CLEANUP_STATUS
|
||||||
|
value: $(params.cleanup-status)
|
||||||
|
- name: FORGEJO_DELIVERY_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: forgejo-delivery-status
|
||||||
|
key: token
|
||||||
|
- name: TEKTON_DASHBOARD_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: maidn-preview-delivery-config
|
||||||
|
key: tekton-dashboard-url
|
||||||
|
optional: true
|
||||||
|
volumeMounts:
|
||||||
|
- name: delivery-status
|
||||||
|
mountPath: /delivery
|
||||||
|
script: |
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote, urlsplit
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
marker = "<!-- maidn-delivery-status -->"
|
||||||
|
statuses = {"Succeeded", "Failed", "None", "Skipped", "Cancelled", "Unknown", "Pending"}
|
||||||
|
|
||||||
|
def fail():
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
def origin(value):
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment:
|
||||||
|
fail()
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9.-]+(?::[0-9]{1,5})?", parsed.netloc):
|
||||||
|
fail()
|
||||||
|
return value
|
||||||
|
|
||||||
|
def status(name):
|
||||||
|
value = os.environ.get(name, "Unknown")
|
||||||
|
return value if value in statuses else "Unknown"
|
||||||
|
|
||||||
|
def request(method, endpoint, payload=None):
|
||||||
|
data = None if payload is None else json.dumps(payload).encode()
|
||||||
|
req = Request(endpoint, data=data, method=method)
|
||||||
|
req.add_header("Authorization", "token " + token)
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
with urlopen(req, timeout=15) as response:
|
||||||
|
return json.load(response) if response.length != 0 else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
base = origin(os.environ["FORGEJO_BASE_URL"])
|
||||||
|
app = os.environ["APP_NAME"]
|
||||||
|
repository = os.environ["APP_REPOSITORY"]
|
||||||
|
pr = os.environ["PR_NUMBER"]
|
||||||
|
run = os.environ["PIPELINE_RUN"]
|
||||||
|
action = os.environ["EVENT_ACTION"]
|
||||||
|
token = os.environ["FORGEJO_DELIVERY_TOKEN"]
|
||||||
|
if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,45}[a-z0-9])?", app) or not re.fullmatch(r"[1-9][0-9]{0,8}", pr) or not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?", run) or action not in {"opened", "reopened", "synchronize", "closed"} or not token:
|
||||||
|
fail()
|
||||||
|
owner, repo = repository.split("/", 1)
|
||||||
|
if not all(re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?", part) and ".." not in part for part in (owner, repo)):
|
||||||
|
fail()
|
||||||
|
task_status = {name: status(name) for name in ("CLONE_STATUS", "BUILD_STATUS", "PUSH_STATUS", "UPDATE_STATUS", "READINESS_STATUS", "CLEANUP_STATUS")}
|
||||||
|
if action == "closed":
|
||||||
|
delivery = "closed"
|
||||||
|
preview = "Preview cleanup requested."
|
||||||
|
elif task_status["READINESS_STATUS"] == "Succeeded" and task_status["UPDATE_STATUS"] == "Succeeded":
|
||||||
|
delivery = "ready"
|
||||||
|
preview_url = Path("/delivery/preview-url").read_text() if Path("/delivery/preview-url").is_file() else ""
|
||||||
|
parsed_preview = urlsplit(preview_url)
|
||||||
|
if parsed_preview.scheme != "https" or not re.fullmatch(r"[A-Za-z0-9.-]+", parsed_preview.netloc) or parsed_preview.path or parsed_preview.query or parsed_preview.fragment:
|
||||||
|
fail()
|
||||||
|
preview = "Preview: " + preview_url
|
||||||
|
else:
|
||||||
|
delivery = "failed"
|
||||||
|
preview = "Preview unavailable."
|
||||||
|
dashboard = os.environ.get("TEKTON_DASHBOARD_URL", "").strip()
|
||||||
|
run_text = "PipelineRun: `" + run + "`"
|
||||||
|
if dashboard:
|
||||||
|
dashboard = origin(dashboard.rstrip("/"))
|
||||||
|
run_text += " ([details](" + dashboard + "/#/pipelineruns/tekton-pipelines/" + quote(run, safe="") + "))"
|
||||||
|
summary = ", ".join(name.removesuffix("_STATUS").lower() + "=" + value for name, value in task_status.items())
|
||||||
|
body = "\n".join((marker, "## Maidn delivery", "Status: **" + delivery + "**", preview, run_text, "Summary: " + summary))
|
||||||
|
endpoint = base + "/api/v1/repos/" + quote(owner, safe="") + "/" + quote(repo, safe="") + "/issues/" + pr + "/comments"
|
||||||
|
comments = request("GET", endpoint + "?limit=100")
|
||||||
|
matches = [comment for comment in comments if marker in comment.get("body", "")]
|
||||||
|
if len(matches) > 1:
|
||||||
|
fail()
|
||||||
|
if matches:
|
||||||
|
request("PATCH", base + "/api/v1/repos/" + quote(owner, safe="") + "/" + quote(repo, safe="") + "/issues/comments/" + str(matches[0]["id"]), {"body": body})
|
||||||
|
else:
|
||||||
|
request("POST", endpoint, {"body": body})
|
||||||
|
except Exception:
|
||||||
|
sys.exit("delivery status update failed")
|
||||||
---
|
---
|
||||||
apiVersion: tekton.dev/v1
|
apiVersion: tekton.dev/v1
|
||||||
kind: Pipeline
|
kind: Pipeline
|
||||||
|
|
@ -197,18 +621,20 @@ spec:
|
||||||
default: {{ quote .ManifestsURL }}
|
default: {{ quote .ManifestsURL }}
|
||||||
- name: manifests-branch
|
- name: manifests-branch
|
||||||
default: {{ quote .ManifestsBranch }}
|
default: {{ quote .ManifestsBranch }}
|
||||||
workspaces:
|
- name: forgejo-base-url
|
||||||
- name: source
|
default: {{ quote .ForgejoBaseURL }}
|
||||||
taskRunTemplate:
|
- name: forgejo-owner
|
||||||
serviceAccountName: tekton-delivery
|
default: {{ quote .ForgejoOwner }}
|
||||||
|
- name: manifests-repo
|
||||||
|
default: {{ quote .ManifestsRepo }}
|
||||||
tasks:
|
tasks:
|
||||||
- name: build-and-push
|
- name: build-layer
|
||||||
when:
|
when:
|
||||||
- input: $(params.event-action)
|
- input: $(params.event-action)
|
||||||
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 }}
|
||||||
|
|
@ -216,15 +642,14 @@ 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 }}
|
||||||
workspaces:
|
{{ end }}
|
||||||
- name: source
|
|
||||||
workspace: source
|
|
||||||
- name: update-preview
|
- name: update-preview
|
||||||
runAfter: [build-and-push]
|
runAfter: [build-layer]
|
||||||
when:
|
when:
|
||||||
- input: $(params.event-type)
|
- input: $(params.event-type)
|
||||||
operator: in
|
operator: in
|
||||||
|
|
@ -251,12 +676,8 @@ 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-and-push]
|
runAfter: [build-layer]
|
||||||
when:
|
when:
|
||||||
- input: $(params.event-type)
|
- input: $(params.event-type)
|
||||||
operator: in
|
operator: in
|
||||||
|
|
@ -281,8 +702,50 @@ spec:
|
||||||
value: $(params.git-revision)
|
value: $(params.git-revision)
|
||||||
- name: environment
|
- name: environment
|
||||||
value: staging
|
value: staging
|
||||||
|
- name: wait-preview
|
||||||
|
runAfter: [update-preview]
|
||||||
|
when:
|
||||||
|
- input: $(params.event-type)
|
||||||
|
operator: in
|
||||||
|
values: [pull_request]
|
||||||
|
- input: $(params.event-action)
|
||||||
|
operator: in
|
||||||
|
values: [opened, reopened, synchronize]
|
||||||
|
taskRef:
|
||||||
|
name: {{ .AppName }}-wait-delivery
|
||||||
|
params:
|
||||||
|
- name: app-name
|
||||||
|
value: {{ quote .AppName }}
|
||||||
|
- name: environment
|
||||||
|
value: preview
|
||||||
|
- name: image
|
||||||
|
value: $(params.image)
|
||||||
|
- name: tag
|
||||||
|
value: $(params.git-revision)
|
||||||
|
- name: pr-number
|
||||||
|
value: $(params.pr-number)
|
||||||
|
- name: wait-staging
|
||||||
|
runAfter: [update-staging]
|
||||||
|
when:
|
||||||
|
- input: $(params.event-type)
|
||||||
|
operator: in
|
||||||
|
values: [push]
|
||||||
|
- input: $(params.branch)
|
||||||
|
operator: in
|
||||||
|
values: [{{ quote .AppRepoRef }}]
|
||||||
|
taskRef:
|
||||||
|
name: {{ .AppName }}-wait-delivery
|
||||||
|
params:
|
||||||
|
- name: app-name
|
||||||
|
value: {{ quote .AppName }}
|
||||||
|
- name: environment
|
||||||
|
value: staging
|
||||||
|
- name: image
|
||||||
|
value: $(params.image)
|
||||||
|
- name: tag
|
||||||
|
value: $(params.git-revision)
|
||||||
- name: promote-production
|
- name: promote-production
|
||||||
runAfter: [build-and-push]
|
runAfter: [build-layer]
|
||||||
when:
|
when:
|
||||||
- input: $(params.event-type)
|
- input: $(params.event-type)
|
||||||
operator: in
|
operator: in
|
||||||
|
|
@ -307,6 +770,12 @@ spec:
|
||||||
value: $(params.git-revision)
|
value: $(params.git-revision)
|
||||||
- name: environment
|
- name: environment
|
||||||
value: production
|
value: production
|
||||||
|
- name: forgejo-base-url
|
||||||
|
value: $(params.forgejo-base-url)
|
||||||
|
- name: forgejo-owner
|
||||||
|
value: $(params.forgejo-owner)
|
||||||
|
- name: manifests-repo
|
||||||
|
value: $(params.manifests-repo)
|
||||||
- name: cleanup-preview
|
- name: cleanup-preview
|
||||||
when:
|
when:
|
||||||
- input: $(params.event-type)
|
- input: $(params.event-type)
|
||||||
|
|
@ -324,3 +793,34 @@ spec:
|
||||||
value: $(params.pr-number)
|
value: $(params.pr-number)
|
||||||
- name: app-repository
|
- name: app-repository
|
||||||
value: {{ quote .AppRepository }}
|
value: {{ quote .AppRepository }}
|
||||||
|
finally:
|
||||||
|
- name: report-delivery
|
||||||
|
when:
|
||||||
|
- input: $(params.event-type)
|
||||||
|
operator: in
|
||||||
|
values: [pull_request]
|
||||||
|
taskRef:
|
||||||
|
name: {{ .AppName }}-report-delivery
|
||||||
|
params:
|
||||||
|
- name: app-name
|
||||||
|
value: {{ quote .AppName }}
|
||||||
|
- name: app-repository
|
||||||
|
value: {{ quote .AppRepository }}
|
||||||
|
- name: pr-number
|
||||||
|
value: $(params.pr-number)
|
||||||
|
- name: event-action
|
||||||
|
value: $(params.event-action)
|
||||||
|
- name: pipeline-run
|
||||||
|
value: $(context.pipelineRun.name)
|
||||||
|
- name: clone-status
|
||||||
|
value: $(tasks.build-layer.status)
|
||||||
|
- name: build-status
|
||||||
|
value: $(tasks.build-layer.status)
|
||||||
|
- name: push-status
|
||||||
|
value: $(tasks.build-layer.status)
|
||||||
|
- name: update-status
|
||||||
|
value: $(tasks.update-preview.status)
|
||||||
|
- name: readiness-status
|
||||||
|
value: $(tasks.wait-preview.status)
|
||||||
|
- name: cleanup-status
|
||||||
|
value: $(tasks.cleanup-preview.status)
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,12 @@ type Runner struct {
|
||||||
Mode Mode
|
Mode Mode
|
||||||
ConfirmRebuild bool
|
ConfirmRebuild bool
|
||||||
RegisterWebhook bool
|
RegisterWebhook bool
|
||||||
|
RefreshWebhookSecret bool
|
||||||
|
EnableDelivery bool
|
||||||
|
SkipDeliveryScaffolding bool
|
||||||
|
AutoMergeBootstrapMigration bool
|
||||||
|
DestroyDemocraticCSIStorage bool
|
||||||
|
RefreshTemplateRevisions bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type operationalSecrets struct {
|
type operationalSecrets struct {
|
||||||
|
|
@ -52,6 +58,8 @@ type operationalSecrets struct {
|
||||||
|
|
||||||
var initializeOpenBao = openbao.Initialize
|
var initializeOpenBao = openbao.Initialize
|
||||||
|
|
||||||
|
var configureOpenBaoSecretGrants = openbao.ConfigureSecretGrants
|
||||||
|
|
||||||
var readOpenBaoRecovery = openbao.ReadRecoveryMaterial
|
var readOpenBaoRecovery = openbao.ReadRecoveryMaterial
|
||||||
|
|
||||||
var decryptGeneratedSOPS = decryptSOPSFile
|
var decryptGeneratedSOPS = decryptSOPSFile
|
||||||
|
|
@ -67,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
|
||||||
|
|
@ -86,21 +96,53 @@ var runGitEnvironment = func(dir string, environment []string, args ...string) (
|
||||||
|
|
||||||
var verifyTalosVMs = verifyConfiguredTalosVMs
|
var verifyTalosVMs = verifyConfiguredTalosVMs
|
||||||
|
|
||||||
var terraformStateResources = func(terraformDir string, environment []string) ([]string, error) {
|
var terraformStateResources = listTerraformStateResources
|
||||||
|
|
||||||
|
var runTerraformStateList = func(terraformDir string, environment []string) ([]byte, []byte, error) {
|
||||||
command := exec.Command("terraform", "state", "list")
|
command := exec.Command("terraform", "state", "list")
|
||||||
command.Dir = terraformDir
|
command.Dir = terraformDir
|
||||||
command.Env = append(os.Environ(), environment...)
|
command.Env = append(os.Environ(), environment...)
|
||||||
state, err := command.Output()
|
var stdout, stderr bytes.Buffer
|
||||||
|
command.Stdout = &stdout
|
||||||
|
command.Stderr = &stderr
|
||||||
|
err := command.Run()
|
||||||
|
return stdout.Bytes(), stderr.Bytes(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func listTerraformStateResources(terraformDir string, environment []string) ([]string, error) {
|
||||||
|
state, stderr, err := runTerraformStateList(terraformDir, environment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if terraformNoStateFile(stderr) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("list Terraform state: %w", err)
|
return nil, fmt.Errorf("list Terraform state: %w", err)
|
||||||
}
|
}
|
||||||
return strings.Fields(string(state)), nil
|
return strings.Fields(string(state)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var destroyTalosVMs = func(terraformDir string, environment []string) error {
|
var ansiEscapeSequence = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
|
||||||
return utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "destroy", "-auto-approve", "-input=false", "-target=proxmox_virtual_environment_vm.vm")
|
|
||||||
|
func terraformNoStateFile(stderr []byte) bool {
|
||||||
|
return strings.Contains(strings.ToLower(ansiEscapeSequence.ReplaceAllString(string(stderr), "")), "no state file")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var destroyTalosVMs = func(terraformDir string, environment []string) error {
|
||||||
|
command := exec.Command("terraform", "destroy", "-auto-approve", "-input=false", "-target=proxmox_virtual_environment_vm.vm")
|
||||||
|
command.Dir = terraformDir
|
||||||
|
command.Env = append(os.Environ(), environment...)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
command.Stdout = os.Stdout
|
||||||
|
command.Stderr = io.MultiWriter(os.Stderr, &stderr)
|
||||||
|
if err := command.Run(); err != nil {
|
||||||
|
return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var runTerraform = utils.RunCommandInDirEnv
|
||||||
|
|
||||||
|
var destroyTalosVMRetryDelay = 10 * time.Second
|
||||||
|
|
||||||
var webhookTargetTimeout = 70 * time.Minute
|
var webhookTargetTimeout = 70 * time.Minute
|
||||||
|
|
||||||
var webhookTargetPollInterval = 2 * time.Second
|
var webhookTargetPollInterval = 2 * time.Second
|
||||||
|
|
@ -117,16 +159,29 @@ var generatedTemplateFiles = map[string]map[string]bool{
|
||||||
var requiredClusterKustomizations = []string{"snapshot-crds-kustomization.yaml", "democratic-csi-kustomization.yaml", "cert-manager-kustomization.yaml", "cluster-issuers-kustomization.yaml", "gateway-api-kustomization.yaml", "cilium-kustomization.yaml", "cilium-config-kustomization.yaml", "openbao-kustomization.yaml", "external-secrets-kustomization.yaml", "cnpg-kustomization.yaml", "gateway-kustomization.yaml", "external-dns-kustomization.yaml", "cloudflare-tunnel-kustomization.yaml", "monitoring-kustomization.yaml", "tekton-kustomization.yaml", "tekton-triggers-kustomization.yaml", "cicd-manifests-repo.yaml"}
|
var requiredClusterKustomizations = []string{"snapshot-crds-kustomization.yaml", "democratic-csi-kustomization.yaml", "cert-manager-kustomization.yaml", "cluster-issuers-kustomization.yaml", "gateway-api-kustomization.yaml", "cilium-kustomization.yaml", "cilium-config-kustomization.yaml", "openbao-kustomization.yaml", "external-secrets-kustomization.yaml", "cnpg-kustomization.yaml", "gateway-kustomization.yaml", "external-dns-kustomization.yaml", "cloudflare-tunnel-kustomization.yaml", "monitoring-kustomization.yaml", "tekton-kustomization.yaml", "tekton-triggers-kustomization.yaml", "cicd-manifests-repo.yaml"}
|
||||||
|
|
||||||
func (r Runner) Run() error {
|
func (r Runner) Run() error {
|
||||||
|
if r.SkipDeliveryScaffolding {
|
||||||
|
r.Config.Delivery = config.DeliveryConfig{}
|
||||||
|
}
|
||||||
resolved, err := config.Resolve(r.Config)
|
resolved, err := config.Resolve(r.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
r.Config = resolved
|
r.Config = resolved
|
||||||
deliveryConfigured := r.Config.Delivery.Configured()
|
if r.RegisterWebhook || r.EnableDelivery {
|
||||||
if r.RegisterWebhook || r.Config.Talos.AutoBootstrapFlux {
|
resolvedDelivery, err := config.ResolveDelivery(r.Config)
|
||||||
if err := config.ValidateDelivery(r.Config); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
|
|
@ -142,16 +197,12 @@ func (r Runner) Run() error {
|
||||||
return errors.New("SOPS recoveryRecipient is required")
|
return errors.New("SOPS recoveryRecipient is required")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r.Mode == "" {
|
|
||||||
r.Mode = Reconcile
|
|
||||||
}
|
|
||||||
if r.Mode != Reconcile && r.Mode != Rebuild {
|
|
||||||
return fmt.Errorf("unsupported bootstrap mode %q", r.Mode)
|
|
||||||
}
|
|
||||||
if r.Mode == Rebuild && !r.ConfirmRebuild {
|
|
||||||
return fmt.Errorf("rebuild is destructive; rerun with --mode=rebuild --yes")
|
|
||||||
}
|
|
||||||
if r.RegisterWebhook {
|
if r.RegisterWebhook {
|
||||||
|
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 == "" {
|
||||||
|
|
@ -164,9 +215,15 @@ 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 r.RefreshTemplateRevisions {
|
||||||
|
if err := RefreshTemplateRevisions(r.Config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if err := EnsureTemplateRevisions(r.Config); err != nil {
|
if err := EnsureTemplateRevisions(r.Config); err != nil {
|
||||||
return err
|
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 {
|
||||||
return fmt.Errorf("initialize Tekton catalog repository: %w", err)
|
return fmt.Errorf("initialize Tekton catalog repository: %w", err)
|
||||||
|
|
@ -188,23 +245,16 @@ func (r Runner) Run() error {
|
||||||
},
|
},
|
||||||
func(dir string) error {
|
func(dir string) error {
|
||||||
clusterDir := filepath.Join(dir, strings.TrimPrefix(r.Config.Flux.ClusterPath, "./"))
|
clusterDir := filepath.Join(dir, strings.TrimPrefix(r.Config.Flux.ClusterPath, "./"))
|
||||||
if deliveryConfigured {
|
|
||||||
if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil {
|
if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else if err := copyDirExcept(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false, deliveryTemplateBaseComponents); err != nil {
|
if err := copyTemplateBaseComponents(cicdTemplateDir, dir, true); err != nil {
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := copyTemplateBaseComponents(cicdTemplateDir, dir, deliveryConfigured); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := copyClusterTemplate(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir); err != nil {
|
if err := copyClusterTemplate(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, name := range []string{"external-secrets", "cnpg", "cloudflare-tunnel", "external-dns", "monitoring", "tekton"} {
|
for _, name := range []string{"external-secrets", "cnpg", "cloudflare-tunnel", "external-dns", "monitoring", "tekton"} {
|
||||||
if !deliveryConfigured && name == "tekton" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
content, err := os.ReadFile(filepath.Join(cicdTemplateDir, "clusters", "template", name+"-kustomization.yaml"))
|
content, err := os.ReadFile(filepath.Join(cicdTemplateDir, "clusters", "template", name+"-kustomization.yaml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -223,11 +273,9 @@ func (r Runner) Run() error {
|
||||||
if err := copyAndRenderCiliumBases(cicdTemplateDir, dir, r.Config); err != nil {
|
if err := copyAndRenderCiliumBases(cicdTemplateDir, dir, r.Config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if deliveryConfigured {
|
if err := copyAndRenderPlatformDeliveryBases(cicdTemplateDir, dir, r.Config); err != nil {
|
||||||
if err := copyAndRenderDeliveryBases(cicdTemplateDir, dir, r.Config); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if err := writeDemocraticCSISecret(filepath.Join(dir, "base", "democratic-csi", "secret.sops.yaml"), r.Config.DemocraticCSI, r.Config.SOPS.AgeKeyPath); err != nil {
|
if err := writeDemocraticCSISecret(filepath.Join(dir, "base", "democratic-csi", "secret.sops.yaml"), r.Config.DemocraticCSI, r.Config.SOPS.AgeKeyPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -238,7 +286,7 @@ func (r Runner) Run() error {
|
||||||
if err := ensureOpenBaoUnsealKustomization(filepath.Join(openbaoDir, "kustomization.yaml")); err != nil {
|
if err := ensureOpenBaoUnsealKustomization(filepath.Join(openbaoDir, "kustomization.yaml")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := ensureClusterKustomizations(clusterDir, deliveryConfigured); err != nil {
|
if err := ensureClusterKustomizations(clusterDir, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return ghrepo.WriteFluxStructure(dir, r.Config.Flux.RepoName, r.Config.Flux.ClusterPath, fluxConfig)
|
return ghrepo.WriteFluxStructure(dir, r.Config.Flux.RepoName, r.Config.Flux.ClusterPath, fluxConfig)
|
||||||
|
|
@ -246,8 +294,8 @@ func (r Runner) Run() error {
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if manager.MigrationPending {
|
if err := r.reconcileBootstrapMigration(manager); err != nil {
|
||||||
return errors.New("existing repository migration PR created; merge and rerun bootstrap before infrastructure changes")
|
return err
|
||||||
}
|
}
|
||||||
if err := r.reconcileCloudflareTunnel(); err != nil {
|
if err := r.reconcileCloudflareTunnel(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -268,6 +316,11 @@ func (r Runner) Run() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Config.Talos.AutoRunTerraform {
|
if r.Config.Talos.AutoRunTerraform {
|
||||||
|
if r.DestroyDemocraticCSIStorage {
|
||||||
|
if err := destroyDemocraticCSIStorage(r.Config.DemocraticCSI); err != nil {
|
||||||
|
return fmt.Errorf("destroy Democratic CSI storage: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := r.reconcileTerraform(terraformDir); err != nil {
|
if err := r.reconcileTerraform(terraformDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -290,7 +343,7 @@ func (r Runner) Run() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r.Config.Talos.AutoBootstrapFlux {
|
if r.Config.Talos.AutoBootstrapFlux {
|
||||||
if err := installCilium(generatedDir, r.Config); err != nil {
|
if err := installCilium(generatedDir, filepath.Join(cicdTemplateDir, "base", "cilium", "release.yaml"), r.Config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := utils.RunCommandInDirEnv(generatedDir, []string{"GIT_PASSWORD=" + r.Config.Git.Token}, "flux", "bootstrap", "git", "--url="+forgejo.CloneURL(r.Config.Git.BaseURL, r.Config.Git.Owner, r.Config.Flux.RepoName), "--branch="+r.Config.Flux.Branch, "--path="+r.Config.Flux.ClusterPath, "--cluster-domain="+r.Config.Flux.ClusterDomain, "--username="+r.Config.Git.Username, "--token-auth", "--kubeconfig=kubeconfig"); err != nil {
|
if err := utils.RunCommandInDirEnv(generatedDir, []string{"GIT_PASSWORD=" + r.Config.Git.Token}, "flux", "bootstrap", "git", "--url="+forgejo.CloneURL(r.Config.Git.BaseURL, r.Config.Git.Owner, r.Config.Flux.RepoName), "--branch="+r.Config.Flux.Branch, "--path="+r.Config.Flux.ClusterPath, "--cluster-domain="+r.Config.Flux.ClusterDomain, "--username="+r.Config.Git.Username, "--token-auth", "--kubeconfig=kubeconfig"); err != nil {
|
||||||
|
|
@ -302,11 +355,39 @@ func (r Runner) Run() error {
|
||||||
if err := configureFluxSOPS(generatedDir); err != nil {
|
if err := configureFluxSOPS(generatedDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return r.reconcileWebhook(generatedDir)
|
return r.completeFluxBootstrap(generatedDir)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r Runner) reconcileBootstrapMigration(manager *forgejo.RepoManager) error {
|
||||||
|
if len(manager.MigrationRepositories) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !r.AutoMergeBootstrapMigration {
|
||||||
|
return fmt.Errorf("repository migration PRs created for %s; merge and rerun bootstrap before infrastructure changes", strings.Join(manager.MigrationRepositories, ", "))
|
||||||
|
}
|
||||||
|
for _, repository := range manager.MigrationRepositories {
|
||||||
|
if err := manager.MergePullRequest(repository, manager.MigrationBranch); err != nil {
|
||||||
|
return fmt.Errorf("merge bootstrap migration PR for %s: %w", repository, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLifecycleMode(mode Mode, confirmRebuild bool) (Mode, error) {
|
||||||
|
if mode == "" {
|
||||||
|
mode = Reconcile
|
||||||
|
}
|
||||||
|
if mode != Reconcile && mode != Rebuild {
|
||||||
|
return "", fmt.Errorf("unsupported bootstrap mode %q", mode)
|
||||||
|
}
|
||||||
|
if mode == Rebuild && !confirmRebuild {
|
||||||
|
return "", fmt.Errorf("rebuild is destructive; rerun with --mode=rebuild --yes")
|
||||||
|
}
|
||||||
|
return mode, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r Runner) reconcileCloudflareTunnel() error {
|
func (r Runner) reconcileCloudflareTunnel() error {
|
||||||
secrets, err := readCloudflareOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath)
|
secrets, err := readCloudflareOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -335,15 +416,26 @@ func (r Runner) reconcileCloudflareTunnel() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) reconcileWebhook(generatedDir string) error {
|
func (r Runner) reconcileWebhook(generatedDir string) error {
|
||||||
operationalSecrets, err := initializeOpenBao(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath)
|
var (
|
||||||
|
operationalSecrets map[string]map[string]string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if r.RefreshWebhookSecret {
|
||||||
|
operationalSecrets, err = r.initializeOpenBaoForCluster(generatedDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("initialize OpenBao: %w", err)
|
return fmt.Errorf("initialize OpenBao: %w", err)
|
||||||
}
|
}
|
||||||
|
} 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 {
|
||||||
|
|
@ -352,6 +444,26 @@ func (r Runner) reconcileWebhook(generatedDir string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r Runner) initializeOpenBaoForCluster(generatedDir string) (map[string]map[string]string, error) {
|
||||||
|
kubeconfig := filepath.Join(generatedDir, "kubeconfig")
|
||||||
|
secrets, err := initializeOpenBao(kubeconfig, r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := configureOpenBaoSecretGrants(kubeconfig, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SecretGrants); err != nil {
|
||||||
|
return nil, fmt.Errorf("configure OpenBao secret grants: %w", err)
|
||||||
|
}
|
||||||
|
return secrets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeFluxBootstrap runs the post-Flux platform initialization only.
|
||||||
|
func (r Runner) completeFluxBootstrap(generatedDir string) error {
|
||||||
|
if _, err := r.initializeOpenBaoForCluster(generatedDir); err != nil {
|
||||||
|
return fmt.Errorf("initialize OpenBao: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func renderCiliumConfig(dir string, cfg config.Config) error {
|
func renderCiliumConfig(dir string, cfg config.Config) error {
|
||||||
replacements := strings.NewReplacer(
|
replacements := strings.NewReplacer(
|
||||||
"${CILIUM_K8S_SERVICE_HOST}", cfg.Talos.KubeconfigEndpoint,
|
"${CILIUM_K8S_SERVICE_HOST}", cfg.Talos.KubeconfigEndpoint,
|
||||||
|
|
@ -371,19 +483,16 @@ func renderCiliumConfig(dir string, cfg config.Config) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderDeliveryConfig(dir string, cfg config.Config) error {
|
func renderPlatformDeliveryConfig(dir string, cfg config.Config) error {
|
||||||
replacements := strings.NewReplacer(
|
replacements := strings.NewReplacer(
|
||||||
"${APP_NAME}", cfg.Delivery.AppName,
|
|
||||||
"${APP_REPO_URL}", cfg.Delivery.AppRepoURL,
|
|
||||||
"${APP_REPO_REF}", cfg.Delivery.AppRepoRef,
|
|
||||||
"${PRODUCTION_BRANCH}", cfg.Delivery.ProductionBranch,
|
|
||||||
"${IMAGE_REPOSITORY}", cfg.Delivery.ImageRepository,
|
|
||||||
"${FORGEJO_BASE_URL}", cfg.Git.BaseURL,
|
"${FORGEJO_BASE_URL}", cfg.Git.BaseURL,
|
||||||
"${CLUSTER_DOMAIN}", cfg.Flux.ClusterDomain,
|
"${CLUSTER_DOMAIN}", cfg.Flux.ClusterDomain,
|
||||||
|
"${GIT_OWNER}", cfg.Git.Owner,
|
||||||
|
"${FORGEJO_OWNER}", cfg.Git.Owner,
|
||||||
"${TEKTON_CATALOG_REPO_URL}", forgejo.CloneURL(cfg.Git.BaseURL, cfg.Git.Owner, cfg.Flux.TektonCatalogRepo),
|
"${TEKTON_CATALOG_REPO_URL}", forgejo.CloneURL(cfg.Git.BaseURL, cfg.Git.Owner, cfg.Flux.TektonCatalogRepo),
|
||||||
"${TEKTON_CATALOG_REPO_REF}", cfg.Templates.TektonCatalogRepoRef,
|
"${TEKTON_CATALOG_REPO_REF}", cfg.Templates.TektonCatalogRepoRef,
|
||||||
"${WEBHOOK_HOSTNAME}", cfg.Delivery.WebhookHostname,
|
"${WEBHOOK_HOSTNAME}", "tekton."+cfg.Flux.ClusterDomain,
|
||||||
"${WEBHOOK_PATH}", cfg.Delivery.WebhookPath,
|
"${WEBHOOK_PATH}", "/",
|
||||||
)
|
)
|
||||||
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil || info.IsDir() {
|
if err != nil || info.IsDir() {
|
||||||
|
|
@ -397,20 +506,39 @@ func renderDeliveryConfig(dir string, cfg config.Config) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyAndRenderDeliveryBases(templateDir, repoDir string, cfg config.Config) error {
|
func copyAndRenderPlatformDeliveryBases(templateDir, repoDir string, cfg config.Config) error {
|
||||||
if err := config.ValidateDelivery(cfg); err != nil {
|
bases := []string{"gateway", "tekton", "tekton-triggers"}
|
||||||
|
apps := filepath.Join(repoDir, "base", "tekton", "apps")
|
||||||
|
backup := filepath.Join(repoDir, ".maidn-preserve-tekton-apps")
|
||||||
|
if err := os.RemoveAll(backup); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(apps); err == nil {
|
||||||
|
if err := os.Rename(apps, backup); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
bases := []string{"gateway", "tekton", "tekton-triggers"}
|
|
||||||
for _, base := range bases {
|
for _, base := range bases {
|
||||||
baseDir := filepath.Join(repoDir, "base", base)
|
baseDir := filepath.Join(repoDir, "base", base)
|
||||||
if err := copyDir(filepath.Join(templateDir, "base", base), baseDir, true); err != nil {
|
if err := copyDir(filepath.Join(templateDir, "base", base), baseDir, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, err := os.Stat(backup); err == nil {
|
||||||
|
if err := os.RemoveAll(apps); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(backup, apps); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
for _, base := range bases {
|
for _, base := range bases {
|
||||||
baseDir := filepath.Join(repoDir, "base", base)
|
baseDir := filepath.Join(repoDir, "base", base)
|
||||||
if err := renderDeliveryConfig(baseDir, cfg); err != nil {
|
if err := renderPlatformDeliveryConfig(baseDir, cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -436,6 +564,7 @@ func writePreviewDeliveryConfig(dir string, cfg config.Config) error {
|
||||||
"forgejo-origin": origin,
|
"forgejo-origin": origin,
|
||||||
"manifests-url": manifestsURL,
|
"manifests-url": manifestsURL,
|
||||||
"manifests-branch": cfg.Flux.Branch,
|
"manifests-branch": cfg.Flux.Branch,
|
||||||
|
"tekton-dashboard-url": cfg.Delivery.TektonDashboardURL,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -449,13 +578,35 @@ func writePreviewDeliveryConfig(dir string, cfg config.Config) error {
|
||||||
if !strings.Contains("\n"+string(data), "\nresources:") {
|
if !strings.Contains("\n"+string(data), "\nresources:") {
|
||||||
return errors.New("Tekton Kustomization must define resources before adding preview delivery configuration")
|
return errors.New("Tekton Kustomization must define resources before adding preview delivery configuration")
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(dir, "maidn-preview-delivery-config.yaml"), content, 0644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "preview-delivery-config.yaml"), content, 0644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if strings.Contains(string(data), "maidn-preview-delivery-config.yaml") {
|
legacyPath := filepath.Join(dir, "maidn-preview-delivery-config.yaml")
|
||||||
|
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updated := strings.ReplaceAll(string(data), " - maidn-preview-delivery-config.yaml\n", "")
|
||||||
|
if !strings.Contains(updated, "preview-delivery-config.yaml") {
|
||||||
|
updated += " - preview-delivery-config.yaml\n"
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, []byte(updated), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeDuplicateAppDeliverySource(dir, appName string) error {
|
||||||
|
filename := appName + "-source.yaml"
|
||||||
|
if err := os.Remove(filepath.Join(dir, filename)); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "kustomization.yaml")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updated := strings.ReplaceAll(string(data), " - "+filename+"\n", "")
|
||||||
|
if updated == string(data) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, append(data, []byte(" - maidn-preview-delivery-config.yaml\n")...), 0644)
|
return os.WriteFile(path, []byte(updated), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalForgejoOrigin(value string) (string, error) {
|
func canonicalForgejoOrigin(value string) (string, error) {
|
||||||
|
|
@ -473,69 +624,26 @@ 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
|
||||||
|
ForgejoOwner string
|
||||||
ManifestsURL string
|
ManifestsURL string
|
||||||
|
ManifestsRepo string
|
||||||
ManifestsBranch string
|
ManifestsBranch string
|
||||||
|
ClusterDomain string
|
||||||
}
|
}
|
||||||
|
|
||||||
var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[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, want := range files {
|
|
||||||
got, err := os.ReadFile(filepath.Join(target, name))
|
|
||||||
if err != nil || !bytes.Equal(got, want) {
|
|
||||||
return errors.New("app delivery .tekton differs from Maidn generated content")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
||||||
|
|
@ -547,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, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, 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, "manifestsUrl": values.ManifestsURL, "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)
|
||||||
}
|
}
|
||||||
|
|
@ -563,7 +671,7 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) {
|
||||||
if err := tmpl.Execute(&rendered, values); err != nil {
|
if err := tmpl.Execute(&rendered, values); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return rendered.Bytes(), nil
|
return bytes.ReplaceAll(rendered.Bytes(), []byte("\r\n"), []byte("\n")), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deliveryRepository(baseURL, repositoryURL string) (string, error) {
|
func deliveryRepository(baseURL, repositoryURL string) (string, error) {
|
||||||
|
|
@ -739,10 +847,10 @@ func writeOpenBaoUnsealSecret(path, recoveryIdentityPath, recoveryBundlePath, ag
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderOpenBaoUnsealSecret(material openbao.RecoveryMaterial) ([]byte, error) {
|
func renderOpenBaoUnsealSecret(material openbao.RecoveryMaterial) ([]byte, error) {
|
||||||
if material.RootToken == "" || material.UnsealThreshold < 1 || len(material.UnsealKeysB64) < material.UnsealThreshold {
|
if material.UnsealThreshold < 1 || len(material.UnsealKeysB64) < material.UnsealThreshold {
|
||||||
return nil, errors.New("OpenBao recovery material is incomplete")
|
return nil, errors.New("OpenBao recovery material is incomplete")
|
||||||
}
|
}
|
||||||
data := map[string]string{"root-token": material.RootToken}
|
data := make(map[string]string, len(material.UnsealKeysB64))
|
||||||
for index, share := range material.UnsealKeysB64 {
|
for index, share := range material.UnsealKeysB64 {
|
||||||
if share == "" {
|
if share == "" {
|
||||||
return nil, errors.New("OpenBao recovery material contains an invalid unseal key")
|
return nil, errors.New("OpenBao recovery material contains an invalid unseal key")
|
||||||
|
|
@ -873,9 +981,29 @@ func InitializeOpenBao(cfg config.Config) error {
|
||||||
if _, err := initializeOpenBao(kubeconfig, cfg.SOPS.RecoveryRecipient, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, cfg.SOPS.AgeKeyPath, cfg.SOPS.OperationalSecretsPath); err != nil {
|
if _, err := initializeOpenBao(kubeconfig, cfg.SOPS.RecoveryRecipient, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, cfg.SOPS.AgeKeyPath, cfg.SOPS.OperationalSecretsPath); err != nil {
|
||||||
return fmt.Errorf("initialize OpenBao: %w", err)
|
return fmt.Errorf("initialize OpenBao: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := configureOpenBaoSecretGrants(kubeconfig, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, cfg.SecretGrants); err != nil {
|
||||||
|
return fmt.Errorf("configure OpenBao secret grants: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProvisionAppSecretIdentities rotates short-lived restricted tokens and
|
||||||
|
// immediately reseeds encrypted operational state through OpenBao.
|
||||||
|
func ProvisionAppSecretIdentities(cfg config.Config, app string) error {
|
||||||
|
kubeconfig := filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir, "kubeconfig")
|
||||||
|
tokens, err := openbao.ProvisionAppSecretIdentities(kubeconfig, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, app)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/app-secret-admin", "token", tokens.Admin); err != nil {
|
||||||
|
return errors.New("save app-secret admin token")
|
||||||
|
}
|
||||||
|
if err := UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/e2e-"+app, "token", tokens.E2E); err != nil {
|
||||||
|
return errors.New("save E2E app-secret token")
|
||||||
|
}
|
||||||
|
return InitializeOpenBao(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
func NewWebhookAuthorization() (string, error) {
|
func NewWebhookAuthorization() (string, error) {
|
||||||
value := make([]byte, 32)
|
value := make([]byte, 32)
|
||||||
if _, err := rand.Read(value); err != nil {
|
if _, err := rand.Read(value); err != nil {
|
||||||
|
|
@ -950,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{"deployment/el-" + cfg.Delivery.AppName, "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)))
|
|
||||||
if decodeErr == nil && string(observed) == authorization {
|
|
||||||
return nil
|
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)
|
||||||
}
|
}
|
||||||
|
|
@ -1001,9 +1113,6 @@ func ensureClusterKustomizations(clusterDir string, includeDelivery bool) error
|
||||||
updated := string(content)
|
updated := string(content)
|
||||||
updated = strings.ReplaceAll(updated, " - bootstrap-secrets.sops.yaml\n", "")
|
updated = strings.ReplaceAll(updated, " - bootstrap-secrets.sops.yaml\n", "")
|
||||||
for _, resource := range requiredClusterKustomizations {
|
for _, resource := range requiredClusterKustomizations {
|
||||||
if !includeDelivery && (resource == "gateway-kustomization.yaml" || resource == "tekton-kustomization.yaml" || resource == "tekton-triggers-kustomization.yaml") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.Contains(updated, resource) {
|
if !strings.Contains(updated, resource) {
|
||||||
updated += " - " + resource + "\n"
|
updated += " - " + resource + "\n"
|
||||||
}
|
}
|
||||||
|
|
@ -1016,10 +1125,15 @@ func ensureClusterKustomizations(clusterDir string, includeDelivery bool) error
|
||||||
|
|
||||||
func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery bool) error {
|
func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery bool) error {
|
||||||
for _, component := range templateBaseComponents {
|
for _, component := range templateBaseComponents {
|
||||||
if !includeDelivery && deliveryTemplateBaseComponents[component] {
|
excluded := generatedTemplateFiles[component]
|
||||||
continue
|
if component == "tekton" {
|
||||||
|
excluded = make(map[string]bool, len(excluded)+1)
|
||||||
|
for path, skip := range generatedTemplateFiles[component] {
|
||||||
|
excluded[path] = skip
|
||||||
}
|
}
|
||||||
if err := copyDirExcept(filepath.Join(templateDir, "base", component), filepath.Join(repoDir, "base", component), true, generatedTemplateFiles[component]); err != nil {
|
excluded["apps"] = true
|
||||||
|
}
|
||||||
|
if err := copyDirExcept(filepath.Join(templateDir, "base", component), filepath.Join(repoDir, "base", component), true, excluded); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1027,6 +1141,9 @@ func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery boo
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyClusterTemplate(source, destination string) error {
|
func copyClusterTemplate(source, destination string) error {
|
||||||
|
if err := os.MkdirAll(destination, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
entries, err := os.ReadDir(destination)
|
entries, err := os.ReadDir(destination)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return err
|
return err
|
||||||
|
|
@ -1064,12 +1181,40 @@ func ensureManifestsKustomizations(dir string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func installCilium(dir string, cfg config.Config) error {
|
func installCilium(dir, releasePath string, cfg config.Config) error {
|
||||||
helmDir := filepath.Join(dir, ".helm")
|
helmDir := filepath.Join(dir, ".helm")
|
||||||
if err := os.MkdirAll(helmDir, 0755); err != nil {
|
if err := os.MkdirAll(helmDir, 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return utils.RunCommandInDir(dir, "helm", "upgrade", "--install", "cilium", "cilium", "--repo=https://helm.cilium.io", "--version=1.19.6", "--repository-config="+filepath.Join(helmDir, "repositories.yaml"), "--repository-cache="+helmDir, "--namespace=kube-system", "--create-namespace", "--kubeconfig=kubeconfig", "--wait", "--timeout=5m", "--set=kubeProxyReplacement=true", "--set=ipam.mode=kubernetes", "--set=k8sServiceHost=localhost", "--set=k8sServicePort=7445", "--set=cgroup.autoMount.enabled=false", "--set=cgroup.hostRoot=/sys/fs/cgroup", "--set=bpf.hostLegacyRouting=true", "--set=securityContext.capabilities.ciliumAgent={CHOWN,KILL,NET_ADMIN,NET_RAW,IPC_LOCK,SYS_ADMIN,SYS_RESOURCE,DAC_OVERRIDE,FOWNER,SETGID,SETUID}", "--set=securityContext.capabilities.cleanCiliumState={NET_ADMIN,SYS_ADMIN,SYS_RESOURCE}", "--set=envoy.enabled=true", "--set=gatewayAPI.enabled=true", "--set=l2announcements.enabled=true", "--set=rollOutCiliumPods=true", "--set=operator.replicas=1", "--set=operator.rollOutPods=true")
|
// The generated HelmRelease is the sole Cilium chart-version authority.
|
||||||
|
version, err := ciliumChartVersion(releasePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return utils.RunCommandInDir(dir, "helm", "upgrade", "--install", "cilium", "cilium", "--repo=https://helm.cilium.io", "--version="+version, "--repository-config="+filepath.Join(helmDir, "repositories.yaml"), "--repository-cache="+helmDir, "--namespace=kube-system", "--create-namespace", "--kubeconfig=kubeconfig", "--wait", "--timeout=5m", "--set=kubeProxyReplacement=true", "--set=ipam.mode=kubernetes", "--set=k8sServiceHost=localhost", "--set=k8sServicePort=7445", "--set=cgroup.autoMount.enabled=false", "--set=cgroup.hostRoot=/sys/fs/cgroup", "--set=bpf.hostLegacyRouting=true", "--set=securityContext.capabilities.ciliumAgent={CHOWN,KILL,NET_ADMIN,NET_RAW,IPC_LOCK,SYS_ADMIN,SYS_RESOURCE,DAC_OVERRIDE,FOWNER,SETGID,SETUID}", "--set=securityContext.capabilities.cleanCiliumState={NET_ADMIN,SYS_ADMIN,SYS_RESOURCE}", "--set=envoy.enabled=true", "--set=gatewayAPI.enabled=true", "--set=hubble.enabled=true", "--set=hubble.relay.enabled=true", "--set=hubble.ui.enabled=true", "--set=l2announcements.enabled=true", "--set=rollOutCiliumPods=true", "--set=operator.replicas=1", "--set=operator.rollOutPods=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ciliumChartVersion(path string) (string, error) {
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var release struct {
|
||||||
|
Spec struct {
|
||||||
|
Chart struct {
|
||||||
|
Spec struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
} `yaml:"spec"`
|
||||||
|
} `yaml:"chart"`
|
||||||
|
} `yaml:"spec"`
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(content, &release); err != nil {
|
||||||
|
return "", fmt.Errorf("parse Cilium HelmRelease: %w", err)
|
||||||
|
}
|
||||||
|
if release.Spec.Chart.Spec.Version == "" {
|
||||||
|
return "", errors.New("Cilium HelmRelease chart version is required")
|
||||||
|
}
|
||||||
|
return release.Spec.Chart.Spec.Version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyDir(source, destination string, overwrite bool) error {
|
func copyDir(source, destination string, overwrite bool) error {
|
||||||
|
|
@ -1259,7 +1404,7 @@ func ensureLifecycleIdentity(terraformDir string, cfg config.Config) error {
|
||||||
|
|
||||||
func (r Runner) reconcileTerraform(terraformDir string) error {
|
func (r Runner) reconcileTerraform(terraformDir string) error {
|
||||||
environment := []string{"TF_VAR_proxmox_api_token=" + r.Config.Talos.Proxmox.APITokenID + "=" + r.Config.Talos.Proxmox.APITokenSecret}
|
environment := []string{"TF_VAR_proxmox_api_token=" + r.Config.Talos.Proxmox.APITokenID + "=" + r.Config.Talos.Proxmox.APITokenSecret}
|
||||||
if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "init", "-input=false"); err != nil {
|
if err := runTerraform(terraformDir, environment, "terraform", "init", "-input=false"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := importNetworkBridges(terraformDir, environment, r.Config); err != nil {
|
if err := importNetworkBridges(terraformDir, environment, r.Config); err != nil {
|
||||||
|
|
@ -1269,19 +1414,52 @@ func (r Runner) reconcileTerraform(terraformDir string) error {
|
||||||
if err := r.rebuildTalosVMs(terraformDir, environment); err != nil {
|
if err := r.rebuildTalosVMs(terraformDir, environment); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else if err := r.importConfiguredTalosVMs(terraformDir, environment); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
planPath, err := terraformPlanPath(terraformDir, r.Config.ClusterID)
|
planPath, err := terraformPlanPath(terraformDir, r.Config.ClusterID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer os.Remove(planPath)
|
defer os.Remove(planPath)
|
||||||
if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "plan", "-input=false", "-out="+planPath); err != nil {
|
planArgs := []string{"plan", "-input=false"}
|
||||||
|
if r.Mode == Reconcile {
|
||||||
|
planArgs = append(planArgs, terraformReconcileTargets(r.Config)...)
|
||||||
|
}
|
||||||
|
planArgs = append(planArgs, "-out="+planPath)
|
||||||
|
if err := runTerraform(terraformDir, environment, "terraform", planArgs...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "apply", "-input=false", "-auto-approve", planPath)
|
return runTerraform(terraformDir, environment, "terraform", "apply", "-input=false", "-auto-approve", planPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func terraformReconcileTargets(cfg config.Config) []string {
|
||||||
|
targets := []string{"-target=proxmox_virtual_environment_download_file.talos_iso", "-target=local_file.talconfig"}
|
||||||
|
if cfg.Talos.Cluster.ManageNetworkBridges {
|
||||||
|
targets = append(targets, "-target=proxmox_virtual_environment_network_linux_bridge.cluster_bridge")
|
||||||
|
}
|
||||||
|
return targets
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) rebuildTalosVMs(terraformDir string, environment []string) error {
|
func (r Runner) rebuildTalosVMs(terraformDir string, environment []string) error {
|
||||||
|
if err := r.importConfiguredTalosVMs(terraformDir, environment); err != nil {
|
||||||
|
var apiErr proxmox.APIError
|
||||||
|
if errors.As(err, &apiErr) && (apiErr.StatusCode == 404 || apiErr.StatusCode == 500 && apiErr.Message == `{"data":null}` && strings.HasSuffix(apiErr.Path, "/config")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
err := destroyTalosVMs(terraformDir, environment)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "context deadline exceeded") || attempt == 2 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(destroyTalosVMRetryDelay)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Runner) importConfiguredTalosVMs(terraformDir string, environment []string) error {
|
||||||
if err := verifyTalosVMs(r.Config); err != nil {
|
if err := verifyTalosVMs(r.Config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -1302,7 +1480,7 @@ func (r Runner) rebuildTalosVMs(terraformDir string, environment []string) error
|
||||||
if err := verifyStateTalosVMs(resources, r.Config, true); err != nil {
|
if err := verifyStateTalosVMs(resources, r.Config, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return destroyTalosVMs(terraformDir, environment)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type terraformImport struct {
|
type terraformImport struct {
|
||||||
|
|
@ -1379,7 +1557,7 @@ func importTerraformResources(terraformDir string, environment []string, imports
|
||||||
if resources[resource.Address] {
|
if resources[resource.Address] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "import", "-input=false", resource.Address, resource.ID); err != nil {
|
if err := runTerraform(terraformDir, environment, "terraform", "import", "-input=false", resource.Address, resource.ID); err != nil {
|
||||||
return fmt.Errorf("import Terraform resource: %w", err)
|
return fmt.Errorf("import Terraform resource: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1507,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)
|
||||||
|
|
@ -1543,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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -17,6 +18,7 @@ import (
|
||||||
"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"
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/openbao"
|
"github.com/Pingu-Studio/MaidnCLI/internal/openbao"
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/proxmox"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -39,53 +41,159 @@ func TestRenderCiliumConfig(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderDeliveryConfig(t *testing.T) {
|
func TestInitializeOpenBaoConfiguresDeclaredSecretGrants(t *testing.T) {
|
||||||
dir := t.TempDir()
|
originalInitialize, originalGrants := initializeOpenBao, configureOpenBaoSecretGrants
|
||||||
path := filepath.Join(dir, "webhook.yaml")
|
t.Cleanup(func() { initializeOpenBao, configureOpenBaoSecretGrants = originalInitialize, originalGrants })
|
||||||
if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\nproduction: ${PRODUCTION_BRANCH}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil {
|
initializeOpenBao = func(_ string, _ string, _ string, _ string, _ string, _ string) (map[string]map[string]string, error) {
|
||||||
|
return map[string]map[string]string{"cicd/forgejo": {"token": "redacted"}}, nil
|
||||||
|
}
|
||||||
|
called := false
|
||||||
|
configureOpenBaoSecretGrants = func(kubeconfig, identity, bundle string, grants []config.SecretGrant) error {
|
||||||
|
called = kubeconfig == filepath.Join("generated", "kubeconfig") && identity == "identity" && bundle == "bundle" && len(grants) == 1 && grants[0].Application == "orders-api"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r := Runner{Config: config.Config{SOPS: config.SOPSConfig{RecoveryIdentityPath: "identity", RecoveryBundlePath: "bundle", RecoveryRecipient: "recipient", AgeKeyPath: "age", OperationalSecretsPath: "secrets"}, SecretGrants: []config.SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "staging", Secrets: []string{"database"}}}}}
|
||||||
|
if _, err := r.initializeOpenBaoForCluster("generated"); err != nil || !called {
|
||||||
|
t.Fatalf("secret grant configuration was not invoked: called=%t err=%v", called, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCiliumChartVersion(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "release.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte("spec:\n chart:\n spec:\n version: test-version\n"), 0644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog"}, Delivery: config.DeliveryConfig{ProductionBranch: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}}
|
version, err := ciliumChartVersion(path)
|
||||||
if err := renderDeliveryConfig(dir, cfg); err != nil {
|
if err != nil || version != "test-version" {
|
||||||
|
t.Fatalf("ciliumChartVersion() = %q, %v", version, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderPlatformDeliveryConfig(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "webhook.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\nowner: ${GIT_OWNER}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{ClusterDomain: "example.test", TektonCatalogRepo: "my-tekton-catalog"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}}
|
||||||
|
if err := renderPlatformDeliveryConfig(dir, cfg); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
content, err := os.ReadFile(path)
|
content, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if strings.Contains(string(content), "${") || !strings.Contains(string(content), "/hooks/forgejo") || !strings.Contains(string(content), "production: production") || !strings.Contains(string(content), "https://git.example.test/user-org/my-tekton-catalog.git") || !strings.Contains(string(content), "ref: release") {
|
if strings.Contains(string(content), "${") || !strings.Contains(string(content), "host: tekton.example.test") || !strings.Contains(string(content), "owner: user-org") || !strings.Contains(string(content), "https://git.example.test/user-org/my-tekton-catalog.git") || !strings.Contains(string(content), "ref: release") {
|
||||||
t.Fatalf("delivery configuration was not rendered: %s", content)
|
t.Fatalf("platform delivery configuration was not rendered: %s", content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGeneratedDeliveryIsGenericAndUsesSafePreviewCleanupContract(t *testing.T) {
|
func TestRemoveDuplicateAppDeliverySource(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte("resources:\n - easycsr-frontend-source.yaml\n - delivery-source.yaml\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "easycsr-frontend-source.yaml"), []byte("stale: source\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := removeDuplicateAppDeliverySource(dir, "easycsr-frontend"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "easycsr-frontend-source.yaml")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("duplicate app source remains: %v", err)
|
||||||
|
}
|
||||||
|
kustomization, err := os.ReadFile(filepath.Join(dir, "kustomization.yaml"))
|
||||||
|
if err != nil || strings.Contains(string(kustomization), "easycsr-frontend-source.yaml") || !strings.Contains(string(kustomization), "delivery-source.yaml") {
|
||||||
|
t.Fatalf("Tekton Kustomization = %q, %v", kustomization, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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{"maidn-node-static-image", "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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(string(content), "easycsr") || strings.Contains(string(content), "test-org") || strings.Contains(string(content), "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"} {
|
||||||
t.Fatal("generated delivery contains a non-generic or unsafe literal")
|
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) {
|
||||||
|
t.Fatalf("generated delivery contains unexpected %q", unexpected)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateAppDeliveryRequiresCompleteConfig(t *testing.T) {
|
func TestGeneratedDeliveryUsesRuntimeImageWithoutStaticParameters(t *testing.T) {
|
||||||
dir := t.TempDir()
|
cfg := config.Config{
|
||||||
err := GenerateAppDelivery(dir, config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}})
|
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
|
||||||
if err == nil || !strings.Contains(err.Error(), "delivery appName") {
|
Flux: config.FluxConfig{Branch: "main", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
|
||||||
t.Fatalf("GenerateAppDelivery() error = %v, want incomplete delivery error", err)
|
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"},
|
||||||
}
|
}
|
||||||
if _, statErr := os.Stat(filepath.Join(dir, ".tekton")); !os.IsNotExist(statErr) {
|
content, err := renderAppDelivery(cfg)
|
||||||
t.Fatal("GenerateAppDelivery() wrote delivery files before rejecting incomplete config")
|
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) {
|
||||||
|
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: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production"},
|
||||||
|
}
|
||||||
|
content, err := renderAppDelivery(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rendered := string(content)
|
||||||
|
for _, expected := range []string{
|
||||||
|
`app_dir="apps/staging/$APP_NAME"`, `root=apps/staging/kustomization.yaml`, `name: $APP_NAME`, `namespace: flux-system`,
|
||||||
|
`PROMOTION_BRANCH="maidn/promotion-$APP_NAME-$TAG"`, `git push origin "HEAD:$PROMOTION_BRANCH"`, `--post-file "$pr_body"`,
|
||||||
|
`head=$FORGEJO_OWNER%3A$PROMOTION_BRANCH`, `value: $(params.forgejo-base-url)`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(rendered, expected) {
|
||||||
|
t.Fatalf("generated delivery does not contain %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start := strings.Index(rendered, `if [ "$ENVIRONMENT" = production ]; then`)
|
||||||
|
if start < 0 {
|
||||||
|
t.Fatal("generated delivery does not contain a production manifest path")
|
||||||
|
}
|
||||||
|
production := rendered[start:]
|
||||||
|
if end := strings.Index(production, "\n else\n"); end >= 0 {
|
||||||
|
production = production[:end]
|
||||||
|
}
|
||||||
|
if strings.Contains(production, `git push origin "$MANIFESTS_BRANCH"`) {
|
||||||
|
t.Fatal("production path pushes directly to manifests main")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderAppDeliveryRequiresCompleteConfig(t *testing.T) {
|
||||||
|
_, err := renderAppDelivery(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") {
|
||||||
|
t.Fatalf("renderAppDelivery() error = %v, want incomplete delivery error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,14 +202,21 @@ func TestWritePreviewDeliveryConfigIsTrustedAndNonSecret(t *testing.T) {
|
||||||
if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform", Token: "secret"}, Flux: config.FluxConfig{ManifestsRepo: "manifests", Branch: "main"}, Delivery: config.DeliveryConfig{AppName: "dynamic-app"}}
|
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform", Token: "secret"}, Flux: config.FluxConfig{ManifestsRepo: "manifests", Branch: "main"}, Delivery: config.DeliveryConfig{AppName: "dynamic-app", TektonDashboardURL: "https://tekton.example.test"}}
|
||||||
if err := writePreviewDeliveryConfig(dir, cfg); err != nil {
|
if err := writePreviewDeliveryConfig(dir, cfg); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
content, err := os.ReadFile(filepath.Join(dir, "maidn-preview-delivery-config.yaml"))
|
content, err := os.ReadFile(filepath.Join(dir, "preview-delivery-config.yaml"))
|
||||||
if err != nil || !strings.Contains(string(content), "forgejo-origin: https://git.example.test") || strings.Contains(string(content), "forgejo-base-url") || !strings.Contains(string(content), "manifests-url: https://git.example.test/platform/manifests.git") || strings.Contains(string(content), "secret") || strings.Contains(string(content), "dynamic-app") {
|
if err != nil || !strings.Contains(string(content), "forgejo-origin: https://git.example.test") || strings.Contains(string(content), "forgejo-base-url") || !strings.Contains(string(content), "manifests-url: https://git.example.test/platform/manifests.git") || !strings.Contains(string(content), "tekton-dashboard-url: https://tekton.example.test") || strings.Contains(string(content), "secret") || strings.Contains(string(content), "dynamic-app") {
|
||||||
t.Fatalf("preview delivery config is not trusted and non-secret: %q, %v", content, err)
|
t.Fatalf("preview delivery config is not trusted and non-secret: %q, %v", content, err)
|
||||||
}
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "maidn-preview-delivery-config.yaml")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("obsolete preview config remains: %v", err)
|
||||||
|
}
|
||||||
|
kustomization, err := os.ReadFile(filepath.Join(dir, "kustomization.yaml"))
|
||||||
|
if err != nil || strings.Contains(string(kustomization), "maidn-preview-delivery-config.yaml") || !strings.Contains(string(kustomization), "preview-delivery-config.yaml") {
|
||||||
|
t.Fatalf("preview config resource was not replaced: %q, %v", kustomization, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWritePreviewDeliveryConfigRejectsAmbiguousKustomization(t *testing.T) {
|
func TestWritePreviewDeliveryConfigRejectsAmbiguousKustomization(t *testing.T) {
|
||||||
|
|
@ -115,15 +230,15 @@ func TestWritePreviewDeliveryConfigRejectsAmbiguousKustomization(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing.T) {
|
func TestCopyAndRenderPlatformDeliveryBasesOverwritesExistingMigrationOutput(t *testing.T) {
|
||||||
templateDir := t.TempDir()
|
templateDir := t.TempDir()
|
||||||
repoDir := t.TempDir()
|
repoDir := t.TempDir()
|
||||||
files := []struct {
|
files := []struct {
|
||||||
base, name, template, want string
|
base, name, template, want string
|
||||||
}{
|
}{
|
||||||
{"gateway", "route.yaml", "host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\n", "host: tekton.example.test\npath: /hooks/forgejo\n"},
|
{"gateway", "route.yaml", "host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\n", "host: tekton.example.test\npath: /\n"},
|
||||||
{"tekton", "catalog-source.yaml", "url: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n", "url: https://git.example.test/user-org/my-tekton-catalog.git\nref: release\n"},
|
{"tekton", "catalog-source.yaml", "url: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n", "url: https://git.example.test/user-org/my-tekton-catalog.git\nref: release\n"},
|
||||||
{"tekton-triggers", "trigger.yaml", "app: ${APP_NAME}\nrepo: ${APP_REPO_URL}\n", "app: demo\nrepo: https://git.example.test/demo.git\n"},
|
{"tekton-triggers", "trigger.yaml", "owner: ${FORGEJO_OWNER}\nhost: ${WEBHOOK_HOSTNAME}\n", "owner: user-org\nhost: tekton.example.test\n"},
|
||||||
}
|
}
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
templatePath := filepath.Join(templateDir, "base", file.base, file.name)
|
templatePath := filepath.Join(templateDir, "base", file.base, file.name)
|
||||||
|
|
@ -144,18 +259,30 @@ func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing.
|
||||||
if err := os.WriteFile(filepath.Join(templateDir, "base", "tekton", "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil {
|
if err := os.WriteFile(filepath.Join(templateDir, "base", "tekton", "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(templateDir, "base", "tekton", "apps"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(templateDir, "base", "tekton", "apps", "kustomization.yaml"), []byte("resources: []\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
customGatewayFile := filepath.Join(repoDir, "base", "gateway", "custom.yaml")
|
customGatewayFile := filepath.Join(repoDir, "base", "gateway", "custom.yaml")
|
||||||
if err := os.WriteFile(customGatewayFile, []byte("custom: route\n"), 0644); err != nil {
|
if err := os.WriteFile(customGatewayFile, []byte("custom: route\n"), 0644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
appRegistration := filepath.Join(repoDir, "base", "tekton", "apps", "fixture.yaml")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(appRegistration), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(appRegistration, []byte("registered: fixture\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
cfg := config.Config{
|
cfg := config.Config{
|
||||||
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"},
|
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"},
|
||||||
Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"},
|
Flux: config.FluxConfig{ClusterDomain: "example.test", TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"},
|
||||||
Delivery: config.DeliveryConfig{AppName: "demo", AppRepoURL: "https://git.example.test/demo.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/demo", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"},
|
|
||||||
Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"},
|
Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"},
|
||||||
}
|
}
|
||||||
if err := copyAndRenderDeliveryBases(templateDir, repoDir, cfg); err != nil {
|
if err := copyAndRenderPlatformDeliveryBases(templateDir, repoDir, cfg); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
|
|
@ -168,6 +295,10 @@ func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing.
|
||||||
if err != nil || string(content) != "custom: route\n" {
|
if err != nil || string(content) != "custom: route\n" {
|
||||||
t.Fatalf("custom gateway file was not preserved: %q, %v", content, err)
|
t.Fatalf("custom gateway file was not preserved: %q, %v", content, err)
|
||||||
}
|
}
|
||||||
|
content, err = os.ReadFile(appRegistration)
|
||||||
|
if err != nil || string(content) != "registered: fixture\n" {
|
||||||
|
t.Fatalf("generated app registration was not preserved: %q, %v", content, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCopyTemplateBaseComponentsRefreshesCNPGAndPreservesGeneratedSecrets(t *testing.T) {
|
func TestCopyTemplateBaseComponentsRefreshesCNPGAndPreservesGeneratedSecrets(t *testing.T) {
|
||||||
|
|
@ -243,6 +374,30 @@ func TestCopyTemplateBaseComponentsRefreshesCNPGAndPreservesGeneratedSecrets(t *
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlatformCopyPreservesExistingDeliveryBases(t *testing.T) {
|
||||||
|
templateDir := t.TempDir()
|
||||||
|
repoDir := t.TempDir()
|
||||||
|
for _, component := range templateBaseComponents {
|
||||||
|
if err := os.MkdirAll(filepath.Join(templateDir, "base", component), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
legacy := filepath.Join(repoDir, "base", "tekton", "legacy-delivery.yaml")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(legacy), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(legacy, []byte("generated: delivery\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := copyTemplateBaseComponents(templateDir, repoDir, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(legacy)
|
||||||
|
if err != nil || string(content) != "generated: delivery\n" {
|
||||||
|
t.Fatalf("platform reconciliation removed existing delivery output: %q, %v", content, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCopyAndRenderCiliumBasesRefreshesTemplateWithoutLeavingPlaceholders(t *testing.T) {
|
func TestCopyAndRenderCiliumBasesRefreshesTemplateWithoutLeavingPlaceholders(t *testing.T) {
|
||||||
templateDir := t.TempDir()
|
templateDir := t.TempDir()
|
||||||
repoDir := t.TempDir()
|
repoDir := t.TempDir()
|
||||||
|
|
@ -335,7 +490,7 @@ func TestWriteDemocraticCSISecretEncryptsValues(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteOpenBaoUnsealSecretRendersRecoveryMaterial(t *testing.T) {
|
func TestWriteOpenBaoUnsealSecretRendersOnlyUnsealShares(t *testing.T) {
|
||||||
originalRead := readOpenBaoRecovery
|
originalRead := readOpenBaoRecovery
|
||||||
originalWrite := writeGeneratedSOPS
|
originalWrite := writeGeneratedSOPS
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
|
@ -348,25 +503,67 @@ func TestWriteOpenBaoUnsealSecretRendersRecoveryMaterial(t *testing.T) {
|
||||||
}
|
}
|
||||||
return openbao.RecoveryMaterial{RootToken: "test-root", UnsealThreshold: 2, UnsealKeysB64: []string{"test-share-1", "test-share-2", "test-share-3"}}, nil
|
return openbao.RecoveryMaterial{RootToken: "test-root", UnsealThreshold: 2, UnsealKeysB64: []string{"test-share-1", "test-share-2", "test-share-3"}}, nil
|
||||||
}
|
}
|
||||||
var rendered []byte
|
|
||||||
writeGeneratedSOPS = func(path, ageKeyPath string, plaintext []byte) error {
|
writeGeneratedSOPS = func(path, ageKeyPath string, plaintext []byte) error {
|
||||||
if ageKeyPath != "flux-age-identity" {
|
if ageKeyPath != "flux-age-identity" {
|
||||||
t.Fatal("OpenBao unseal secret used the wrong Flux age identity")
|
t.Fatal("OpenBao unseal secret used the wrong Flux age identity")
|
||||||
}
|
}
|
||||||
rendered = append([]byte(nil), plaintext...)
|
|
||||||
return os.WriteFile(path, []byte("sops: {}\n"), 0600)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := writeOpenBaoUnsealSecret(filepath.Join(t.TempDir(), "unseal.sops.yaml"), "recovery-identity", "recovery-bundle", "flux-age-identity"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var secret struct {
|
var secret struct {
|
||||||
Metadata map[string]string `yaml:"metadata"`
|
Metadata map[string]string `yaml:"metadata"`
|
||||||
StringData map[string]string `yaml:"stringData"`
|
StringData map[string]string `yaml:"stringData"`
|
||||||
}
|
}
|
||||||
if err := yaml.Unmarshal(rendered, &secret); err != nil || secret.Metadata["name"] != "openbao-unseal" || secret.Metadata["namespace"] != "openbao" || len(secret.StringData) != 4 || secret.StringData["root-token"] == "" || secret.StringData["unseal-3"] == "" {
|
if err := yaml.Unmarshal(plaintext, &secret); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if secret.Metadata["name"] != "openbao-unseal" || secret.Metadata["namespace"] != "openbao" || len(secret.StringData) != 3 {
|
||||||
t.Fatal("OpenBao unseal Secret was not rendered")
|
t.Fatal("OpenBao unseal Secret was not rendered")
|
||||||
}
|
}
|
||||||
|
if _, ok := secret.StringData["root-token"]; ok {
|
||||||
|
t.Fatal("OpenBao unseal Secret contains a root token")
|
||||||
|
}
|
||||||
|
for _, name := range []string{"unseal-1", "unseal-2", "unseal-3"} {
|
||||||
|
if _, ok := secret.StringData[name]; !ok {
|
||||||
|
t.Fatalf("OpenBao unseal Secret is missing %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeOpenBaoUnsealSecret(filepath.Join(t.TempDir(), "unseal.sops.yaml"), "recovery-identity", "recovery-bundle", "flux-age-identity"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteOpenBaoUnsealSecretEncryptsSharesWithoutRootToken(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("age-keygen"); err != nil {
|
||||||
|
t.Skip("age-keygen is required for bootstrap encryption")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("sops"); err != nil {
|
||||||
|
t.Skip("sops is required for bootstrap encryption")
|
||||||
|
}
|
||||||
|
originalRead := readOpenBaoRecovery
|
||||||
|
t.Cleanup(func() { readOpenBaoRecovery = originalRead })
|
||||||
|
readOpenBaoRecovery = func(string, string) (openbao.RecoveryMaterial, error) {
|
||||||
|
return openbao.RecoveryMaterial{RootToken: "test-root", UnsealThreshold: 2, UnsealKeysB64: []string{"test-share-1", "test-share-2", "test-share-3"}}, nil
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
identity := filepath.Join(dir, "age-key.txt")
|
||||||
|
if err := exec.Command("age-keygen", "-o", identity).Run(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "unseal.sops.yaml")
|
||||||
|
if err := writeOpenBaoUnsealSecret(path, "recovery-identity", "recovery-bundle", identity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
encrypted, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(encrypted), "sops:") {
|
||||||
|
t.Fatal("OpenBao unseal Secret was not SOPS encrypted")
|
||||||
|
}
|
||||||
|
if strings.Contains(string(encrypted), "root-token") {
|
||||||
|
t.Fatal("SOPS-encrypted OpenBao unseal Secret contains a root token")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteOpenBaoUnsealSecretIsIdempotent(t *testing.T) {
|
func TestWriteOpenBaoUnsealSecretIsIdempotent(t *testing.T) {
|
||||||
|
|
@ -645,22 +842,177 @@ func TestEnsureClusterKustomizationsUsesTemplateExternalSecretsResource(t *testi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCopyClusterTemplateCopiesCurrentClusterContract(t *testing.T) {
|
||||||
|
templateDir := t.TempDir()
|
||||||
|
clusterTemplate := filepath.Join(templateDir, "clusters", "template")
|
||||||
|
resources := []string{
|
||||||
|
"snapshot-crds-kustomization.yaml", "democratic-csi-kustomization.yaml", "cert-manager-kustomization.yaml", "cluster-issuers-kustomization.yaml", "gateway-api-kustomization.yaml", "cilium-kustomization.yaml", "cilium-config-kustomization.yaml", "openbao-kustomization.yaml", "external-secrets-kustomization.yaml", "external-secrets-config-kustomization.yaml", "cnpg-kustomization.yaml", "gateway-kustomization.yaml", "external-dns-kustomization.yaml", "cloudflare-tunnel-kustomization.yaml", "monitoring-kustomization.yaml", "tekton-kustomization.yaml", "tekton-triggers-kustomization.yaml",
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(clusterTemplate, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var content strings.Builder
|
||||||
|
content.WriteString("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n")
|
||||||
|
for _, resource := range resources {
|
||||||
|
content.WriteString(" - " + resource + "\n")
|
||||||
|
if err := os.WriteFile(filepath.Join(clusterTemplate, resource), []byte("apiVersion: v1\nkind: ConfigMap\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(clusterTemplate, "kustomization.yaml"), []byte(content.String()), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterDir := filepath.Join(t.TempDir(), "clusters", "maidn-cd-0")
|
||||||
|
if err := copyClusterTemplate(clusterTemplate, clusterDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(clusterDir, "cicd-manifests-repo.yaml"), []byte("apiVersion: v1\nkind: ConfigMap\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ensureClusterKustomizations(clusterDir, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, err := os.ReadFile(filepath.Join(clusterDir, "kustomization.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ensureClusterKustomizations(clusterDir, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := os.ReadFile(filepath.Join(clusterDir, "kustomization.yaml"))
|
||||||
|
if err != nil || string(first) != string(second) {
|
||||||
|
t.Fatalf("cluster Kustomization was not generated idempotently: %q, %v", second, err)
|
||||||
|
}
|
||||||
|
for _, resource := range append(resources, "cicd-manifests-repo.yaml") {
|
||||||
|
if !strings.Contains(string(second), " - "+resource+"\n") {
|
||||||
|
t.Fatalf("cluster Kustomization omitted template resource %q: %q", resource, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWebhookTargetTimeoutExceedsExternalSecretRefreshInterval(t *testing.T) {
|
func TestWebhookTargetTimeoutExceedsExternalSecretRefreshInterval(t *testing.T) {
|
||||||
if webhookTargetTimeout <= time.Hour {
|
if webhookTargetTimeout <= time.Hour {
|
||||||
t.Fatal("webhook target timeout must exceed the one-hour ExternalSecret refresh interval")
|
t.Fatal("webhook target timeout must exceed the one-hour ExternalSecret refresh interval")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runnerTestConfig(workspace, ageKeyPath string) config.Config {
|
||||||
|
return config.Config{
|
||||||
|
ClusterID: "test-cluster",
|
||||||
|
WorkspaceDir: workspace,
|
||||||
|
Git: config.GitConfig{Provider: "forgejo", BaseURL: "https://git.example.test", Username: "bot", Token: "token", Owner: "test-org", CloneParent: filepath.Join(workspace, "checkouts")},
|
||||||
|
Flux: config.FluxConfig{RepoName: "cluster", Branch: "main", ClusterPath: "./clusters/test", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
|
||||||
|
Templates: config.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: config.CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"},
|
||||||
|
DemocraticCSI: config.DemocraticCSIConfig{TrueNASAPIKey: "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: config.DeliveryConfig{AppName: "app", AppRepoURL: "https://git.example.test/app.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test/app", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"},
|
||||||
|
SOPS: config.SOPSConfig{AgeKeyPath: ageKeyPath},
|
||||||
|
Talos: config.TalosConfig{
|
||||||
|
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
|
||||||
|
Proxmox: config.TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
|
||||||
|
Cluster: config.TalosClusterConfig{Name: "test-cluster", Domain: "example.test"},
|
||||||
|
Image: config.TalosImageConfig{TalosVersion: "v1.13.6", KubernetesVersion: "v1.33.4", SchematicID: "abcdefghijkl"},
|
||||||
|
Nodes: []config.TalosNode{{Name: "cp-01", VMID: 100, Role: "controlplane", Networks: []config.TalosNetwork{{IP: "192.168.45.3", CIDR: "192.168.45.0/28", Gateway: "192.168.45.1", VLANID: 45}, {IP: "192.168.45.18", CIDR: "192.168.45.16/28", VLANID: 451}}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerAutoBootstrapFluxIgnoresPartialLegacyDelivery(t *testing.T) {
|
||||||
|
originalPreflight := preflight
|
||||||
|
t.Cleanup(func() { preflight = originalPreflight })
|
||||||
|
preflight = func(config.Config) error { return errors.New("reached preflight") }
|
||||||
|
|
||||||
|
cfg := runnerTestConfig(t.TempDir(), "")
|
||||||
|
cfg.Delivery = config.DeliveryConfig{AppName: "legacy-app", AppRepoURL: "https://git.example.test/test-org/legacy-app.git", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/legacy-app"}
|
||||||
|
cfg.Talos.AutoBootstrapFlux = true
|
||||||
|
err := (Runner{Config: cfg}).Run()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "reached preflight") {
|
||||||
|
t.Fatalf("platform reconcile validated partial delivery before preflight: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerEnableDeliveryAppliesDefaults(t *testing.T) {
|
||||||
|
originalPreflight := preflight
|
||||||
|
t.Cleanup(func() { preflight = originalPreflight })
|
||||||
|
preflight = func(cfg config.Config) error {
|
||||||
|
if !cfg.Delivery.Configured() {
|
||||||
|
t.Fatal("delivery defaults were not applied")
|
||||||
|
}
|
||||||
|
return errors.New("reached preflight")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := runnerTestConfig(t.TempDir(), "")
|
||||||
|
cfg.Delivery.AppRepoRef = ""
|
||||||
|
cfg.Delivery.BuildOutputDirectory = ""
|
||||||
|
cfg.Delivery.BuildConfiguration = ""
|
||||||
|
cfg.Delivery.WebhookHostname = ""
|
||||||
|
cfg.Delivery.WebhookPath = ""
|
||||||
|
err := (Runner{Config: cfg, EnableDelivery: true}).Run()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "reached preflight") {
|
||||||
|
t.Fatalf("enable delivery did not resolve defaults before preflight: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerAutoMergesBootstrapMigration(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.Method == http.MethodGet && (request.URL.Path == "/api/v1/repos/test-org/manifests/pulls" || request.URL.Path == "/api/v1/repos/test-org/cluster/pulls") {
|
||||||
|
if request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/bootstrap-test-cluster" {
|
||||||
|
t.Fatalf("unexpected migration lookup query: %q", request.URL.RawQuery)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(writer).Encode([]struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
}{{Number: 4}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if request.Method == http.MethodPost && (request.URL.Path == "/api/v1/repos/test-org/manifests/pulls/4/merge" || request.URL.Path == "/api/v1/repos/test-org/cluster/pulls/4/merge") {
|
||||||
|
var body struct {
|
||||||
|
Do string `json:"Do"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Do != "merge" {
|
||||||
|
t.Fatalf("unexpected migration merge request: %#v, %v", body, err)
|
||||||
|
}
|
||||||
|
writer.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected Forgejo request %s %s", request.Method, request.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
manager := forgejo.NewRepoManager(server.URL, "test-token", "test-org", "bot", "manifests", "cluster", "main", "maidn/bootstrap-test-cluster")
|
||||||
|
manager.HTTPClient = server.Client()
|
||||||
|
manager.MigrationRepositories = []string{"manifests", "cluster"}
|
||||||
|
if err := (Runner{Config: config.Config{Flux: config.FluxConfig{RepoName: "cluster"}}, AutoMergeBootstrapMigration: true}).reconcileBootstrapMigration(manager); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerRetainsMigrationApprovalGate(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Fatal("normal bootstrap must not call Forgejo to merge a migration")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
manager := forgejo.NewRepoManager(server.URL, "test-token", "test-org", "bot", "manifests", "cluster", "main", "maidn/bootstrap-test-cluster")
|
||||||
|
manager.HTTPClient = server.Client()
|
||||||
|
manager.MigrationRepositories = []string{"manifests"}
|
||||||
|
if err := (Runner{Config: config.Config{Flux: config.FluxConfig{RepoName: "cluster"}}}).reconcileBootstrapMigration(manager); err == nil || !strings.Contains(err.Error(), "merge and rerun bootstrap") {
|
||||||
|
t.Fatalf("normal bootstrap migration gate = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
|
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
|
||||||
})
|
})
|
||||||
|
|
@ -679,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
|
||||||
}
|
}
|
||||||
|
|
@ -692,24 +1047,17 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.Config{
|
cfg := runnerTestConfig(workspace, ageKeyPath)
|
||||||
ClusterID: "test-cluster",
|
partial := cfg
|
||||||
WorkspaceDir: workspace,
|
partial.Delivery.ImageRepository = ""
|
||||||
Git: config.GitConfig{Provider: "forgejo", BaseURL: "https://git.example.test", Username: "bot", Token: "token", Owner: "test-org", CloneParent: filepath.Join(workspace, "checkouts")},
|
preflight = func(config.Config) error {
|
||||||
Flux: config.FluxConfig{RepoName: "cluster", Branch: "main", ClusterPath: "./clusters/test", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
|
t.Fatal("webhook registration accepted incomplete delivery config")
|
||||||
Templates: config.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"},
|
return nil
|
||||||
Cilium: config.CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"},
|
|
||||||
DemocraticCSI: config.DemocraticCSIConfig{TrueNASAPIKey: "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: config.DeliveryConfig{AppName: "app", AppRepoURL: "https://git.example.test/app.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test/app", WebhookHostname: "tekton.example.test", WebhookPath: "/"},
|
|
||||||
SOPS: config.SOPSConfig{AgeKeyPath: ageKeyPath},
|
|
||||||
Talos: config.TalosConfig{
|
|
||||||
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
|
|
||||||
Proxmox: config.TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
|
|
||||||
Cluster: config.TalosClusterConfig{Name: "test-cluster", Domain: "example.test"},
|
|
||||||
Image: config.TalosImageConfig{TalosVersion: "v1.13.6", KubernetesVersion: "v1.33.4", SchematicID: "abcdefghijkl"},
|
|
||||||
Nodes: []config.TalosNode{{Name: "cp-01", VMID: 100, Role: "controlplane", Networks: []config.TalosNetwork{{IP: "192.168.45.3", CIDR: "192.168.45.0/28", Gateway: "192.168.45.1", VLANID: 45}, {IP: "192.168.45.18", CIDR: "192.168.45.16/28", VLANID: 451}}}},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
if err := (Runner{Config: partial, RegisterWebhook: true}).Run(); err == nil || !strings.Contains(err.Error(), "imageRepository") {
|
||||||
|
t.Fatalf("webhook registration error = %v, want incomplete delivery error", err)
|
||||||
|
}
|
||||||
|
preflight = func(config.Config) error { return nil }
|
||||||
if err := (Runner{Config: cfg, RegisterWebhook: true}).Run(); err != nil {
|
if err := (Runner{Config: cfg, RegisterWebhook: true}).Run(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -718,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
|
||||||
|
|
@ -733,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
|
||||||
}
|
}
|
||||||
|
|
@ -756,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
|
||||||
}
|
}
|
||||||
|
|
@ -788,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
|
||||||
|
|
@ -816,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
|
||||||
|
|
@ -832,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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -859,6 +1215,42 @@ func TestTerraformPlanPathIsAbsolute(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTerraformReconcileTargetsStageImageTalconfigAndManagedBridgesOnly(t *testing.T) {
|
||||||
|
if targets := strings.Join(terraformReconcileTargets(config.Config{}), " "); targets != "-target=proxmox_virtual_environment_download_file.talos_iso -target=local_file.talconfig" {
|
||||||
|
t.Fatalf("default reconcile targets = %q", targets)
|
||||||
|
}
|
||||||
|
cfg := config.Config{Talos: config.TalosConfig{Cluster: config.TalosClusterConfig{ManageNetworkBridges: true}}}
|
||||||
|
targets := strings.Join(terraformReconcileTargets(cfg), " ")
|
||||||
|
if !strings.Contains(targets, "-target=proxmox_virtual_environment_download_file.talos_iso") || !strings.Contains(targets, "-target=local_file.talconfig") || !strings.Contains(targets, "-target=proxmox_virtual_environment_network_linux_bridge.cluster_bridge") || strings.Contains(targets, "proxmox_virtual_environment_vm.vm") {
|
||||||
|
t.Fatalf("managed bridge reconcile targets = %q", targets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTerraformStateResourcesAllowsANSINoStateFile(t *testing.T) {
|
||||||
|
original := runTerraformStateList
|
||||||
|
t.Cleanup(func() { runTerraformStateList = original })
|
||||||
|
runTerraformStateList = func(string, []string) ([]byte, []byte, error) {
|
||||||
|
return nil, []byte("\x1b[31mError:\x1b[0m \x1b[31mNO STATE\x1b[0m FILE was found!\n"), errors.New("exit status 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
resources, err := listTerraformStateResources("terraform", nil)
|
||||||
|
if err != nil || len(resources) != 0 {
|
||||||
|
t.Fatalf("no-state Terraform result = %q, %v; want empty resources and nil error", resources, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTerraformStateResourcesRejectsUnexpectedStateError(t *testing.T) {
|
||||||
|
original := runTerraformStateList
|
||||||
|
t.Cleanup(func() { runTerraformStateList = original })
|
||||||
|
runTerraformStateList = func(string, []string) ([]byte, []byte, error) {
|
||||||
|
return nil, []byte("Error: failed to load backend\n"), errors.New("exit status 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := listTerraformStateResources("terraform", nil); err == nil || !strings.Contains(err.Error(), "list Terraform state") {
|
||||||
|
t.Fatalf("unexpected Terraform state error was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVerifyStateTalosVMsRejectsForeignAndMissingVMs(t *testing.T) {
|
func TestVerifyStateTalosVMsRejectsForeignAndMissingVMs(t *testing.T) {
|
||||||
cfg := config.Config{Talos: config.TalosConfig{Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100}, {Name: "worker-01", ProxmoxNode: "pve", VMID: 101}}}}
|
cfg := config.Config{Talos: config.TalosConfig{Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100}, {Name: "worker-01", ProxmoxNode: "pve", VMID: 101}}}}
|
||||||
if err := verifyStateTalosVMs([]string{`proxmox_virtual_environment_vm.vm["cp-01"]`, `proxmox_virtual_environment_vm.vm["foreign"]`}, cfg, false); err == nil || !strings.Contains(err.Error(), "unconfigured") {
|
if err := verifyStateTalosVMs([]string{`proxmox_virtual_environment_vm.vm["cp-01"]`, `proxmox_virtual_environment_vm.vm["foreign"]`}, cfg, false); err == nil || !strings.Contains(err.Error(), "unconfigured") {
|
||||||
|
|
@ -869,6 +1261,221 @@ func TestVerifyStateTalosVMsRejectsForeignAndMissingVMs(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReconcileTerraformImportsConfiguredTalosVMsBeforePlan(t *testing.T) {
|
||||||
|
originalVerify := verifyTalosVMs
|
||||||
|
originalStateList := runTerraformStateList
|
||||||
|
originalRun := runTerraform
|
||||||
|
t.Cleanup(func() {
|
||||||
|
verifyTalosVMs = originalVerify
|
||||||
|
runTerraformStateList = originalStateList
|
||||||
|
runTerraform = originalRun
|
||||||
|
})
|
||||||
|
|
||||||
|
cfg := config.Config{ClusterID: "test-cluster", Talos: config.TalosConfig{
|
||||||
|
Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100, Role: "controlplane"}},
|
||||||
|
}}
|
||||||
|
importedAddress := talosVMImports(cfg)[0].Address
|
||||||
|
var events, state []string
|
||||||
|
verifyTalosVMs = func(got config.Config) error {
|
||||||
|
if len(got.Talos.Nodes) != 1 || got.Talos.Nodes[0].Name != "cp-01" || got.Talos.Nodes[0].ProxmoxNode != "pve" || got.Talos.Nodes[0].VMID != 100 {
|
||||||
|
t.Fatal("Talos VM identity verification used the wrong config")
|
||||||
|
}
|
||||||
|
events = append(events, "verify")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
runTerraformStateList = func(string, []string) ([]byte, []byte, error) {
|
||||||
|
return []byte(strings.Join(state, "\n")), nil, nil
|
||||||
|
}
|
||||||
|
runTerraform = func(_ string, _ []string, name string, args ...string) error {
|
||||||
|
if name != "terraform" {
|
||||||
|
t.Fatalf("unexpected command %q", name)
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "init":
|
||||||
|
events = append(events, "init")
|
||||||
|
case "import":
|
||||||
|
if len(args) != 4 || args[2] != importedAddress || args[3] != "pve/100" {
|
||||||
|
t.Fatalf("unexpected Talos VM import: %q", args)
|
||||||
|
}
|
||||||
|
events = append(events, "import")
|
||||||
|
state = append(state, importedAddress)
|
||||||
|
case "plan":
|
||||||
|
if len(state) != 1 || state[0] != importedAddress {
|
||||||
|
return errors.New("VM create collision")
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.Join(args, " "), "proxmox_virtual_environment_vm.vm") || !strings.Contains(strings.Join(args, " "), "-target=proxmox_virtual_environment_download_file.talos_iso") || !strings.Contains(strings.Join(args, " "), "-target=local_file.talconfig") {
|
||||||
|
t.Fatalf("reconcile plan did not exclude the Talos VM: %q", args)
|
||||||
|
}
|
||||||
|
events = append(events, "plan")
|
||||||
|
case "apply":
|
||||||
|
if strings.Contains(strings.Join(args, " "), "proxmox_virtual_environment_vm.vm") {
|
||||||
|
t.Fatalf("reconcile apply did not exclude the Talos VM: %q", args)
|
||||||
|
}
|
||||||
|
events = append(events, "apply")
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected Terraform operation %q", args[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := (Runner{Config: cfg, Mode: Reconcile}).reconcileTerraform(t.TempDir()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(events, ",") != "init,verify,import,plan,apply" {
|
||||||
|
t.Fatalf("Terraform phase order = %q", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRebuildTerraformRetainsFullTalosVMLifecycle(t *testing.T) {
|
||||||
|
originalVerify := verifyTalosVMs
|
||||||
|
originalStateList := runTerraformStateList
|
||||||
|
originalRun := runTerraform
|
||||||
|
originalDestroy := destroyTalosVMs
|
||||||
|
t.Cleanup(func() {
|
||||||
|
verifyTalosVMs = originalVerify
|
||||||
|
runTerraformStateList = originalStateList
|
||||||
|
runTerraform = originalRun
|
||||||
|
destroyTalosVMs = originalDestroy
|
||||||
|
})
|
||||||
|
|
||||||
|
cfg := config.Config{ClusterID: "test-cluster", Talos: config.TalosConfig{
|
||||||
|
Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100, Role: "controlplane"}},
|
||||||
|
}}
|
||||||
|
importedAddress := talosVMImports(cfg)[0].Address
|
||||||
|
var events, state []string
|
||||||
|
verifyTalosVMs = func(config.Config) error {
|
||||||
|
events = append(events, "verify")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
runTerraformStateList = func(string, []string) ([]byte, []byte, error) {
|
||||||
|
return []byte(strings.Join(state, "\n")), nil, nil
|
||||||
|
}
|
||||||
|
destroyTalosVMs = func(_ string, _ []string) error {
|
||||||
|
events = append(events, "destroy")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
runTerraform = func(_ string, _ []string, name string, args ...string) error {
|
||||||
|
if name != "terraform" {
|
||||||
|
t.Fatalf("unexpected command %q", name)
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "init":
|
||||||
|
events = append(events, "init")
|
||||||
|
case "import":
|
||||||
|
state = append(state, importedAddress)
|
||||||
|
events = append(events, "import")
|
||||||
|
case "plan":
|
||||||
|
if strings.Contains(strings.Join(args, " "), "-target=") {
|
||||||
|
t.Fatalf("rebuild plan must retain the full VM lifecycle: %q", args)
|
||||||
|
}
|
||||||
|
events = append(events, "plan")
|
||||||
|
case "apply":
|
||||||
|
events = append(events, "apply")
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected Terraform operation %q", args[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := (Runner{Config: cfg, Mode: Rebuild}).reconcileTerraform(t.TempDir()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(events, ",") != "init,verify,import,destroy,plan,apply" {
|
||||||
|
t.Fatalf("Terraform phase order = %q", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRebuildTalosVMsRetriesOnlyDeadlineErrors(t *testing.T) {
|
||||||
|
originalDestroy := destroyTalosVMs
|
||||||
|
originalDelay := destroyTalosVMRetryDelay
|
||||||
|
t.Cleanup(func() {
|
||||||
|
destroyTalosVMs = originalDestroy
|
||||||
|
destroyTalosVMRetryDelay = originalDelay
|
||||||
|
})
|
||||||
|
destroyTalosVMRetryDelay = 0
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}{
|
||||||
|
{name: "deadline", err: fmt.Errorf("context deadline exceeded"), calls: 3},
|
||||||
|
{name: "other error", err: fmt.Errorf("permission denied"), calls: 1},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
destroyTalosVMs = func(string, []string) error {
|
||||||
|
calls++
|
||||||
|
return test.err
|
||||||
|
}
|
||||||
|
if err := (Runner{}).rebuildTalosVMs(t.TempDir(), nil); !errors.Is(err, test.err) {
|
||||||
|
t.Fatalf("rebuild Talos VMs error = %v, want %v", err, test.err)
|
||||||
|
}
|
||||||
|
if calls != test.calls {
|
||||||
|
t.Fatalf("destroy calls = %d, want %d", calls, test.calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRebuildTerraformSkipsAlreadyAbsentTalosVM(t *testing.T) {
|
||||||
|
originalVerify := verifyTalosVMs
|
||||||
|
originalRun := runTerraform
|
||||||
|
originalDestroy := destroyTalosVMs
|
||||||
|
t.Cleanup(func() {
|
||||||
|
verifyTalosVMs = originalVerify
|
||||||
|
runTerraform = originalRun
|
||||||
|
destroyTalosVMs = originalDestroy
|
||||||
|
})
|
||||||
|
|
||||||
|
cfg := config.Config{ClusterID: "test-cluster", Talos: config.TalosConfig{
|
||||||
|
Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100, Role: "controlplane"}},
|
||||||
|
}}
|
||||||
|
var events []string
|
||||||
|
for _, apiErr := range []proxmox.APIError{{StatusCode: 404}, {StatusCode: 500, Message: `{"data":null}`, Path: "/nodes/pve/qemu/100/config"}} {
|
||||||
|
verifyTalosVMs = func(config.Config) error {
|
||||||
|
return fmt.Errorf("inspect configured Talos VM: %w", apiErr)
|
||||||
|
}
|
||||||
|
destroyTalosVMs = func(string, []string) error {
|
||||||
|
t.Fatal("destroy ran for an already-absent VM")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
runTerraform = func(_ string, _ []string, name string, args ...string) error {
|
||||||
|
if name != "terraform" {
|
||||||
|
t.Fatalf("unexpected command %q", name)
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "init", "plan", "apply":
|
||||||
|
events = append(events, args[0])
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected Terraform operation %q", args[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := (Runner{Config: cfg, Mode: Rebuild}).reconcileTerraform(t.TempDir()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(events, ",") != "init,plan,apply" {
|
||||||
|
t.Fatalf("Terraform phase order = %q", events)
|
||||||
|
}
|
||||||
|
events = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRebuildTerraformRejectsUnexpectedProxmoxError(t *testing.T) {
|
||||||
|
originalVerify := verifyTalosVMs
|
||||||
|
t.Cleanup(func() { verifyTalosVMs = originalVerify })
|
||||||
|
verifyTalosVMs = func(config.Config) error {
|
||||||
|
return fmt.Errorf("inspect configured Talos VM: %w", proxmox.APIError{StatusCode: 500, Message: "server unavailable", Path: "/nodes/pve/qemu/100/config"})
|
||||||
|
}
|
||||||
|
|
||||||
|
err := (Runner{Config: config.Config{Talos: config.TalosConfig{Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100}}}}, Mode: Rebuild}).rebuildTalosVMs(t.TempDir(), nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "server unavailable") {
|
||||||
|
t.Fatalf("unexpected Proxmox error was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureLifecycleIdentityStoresMetadataUnderGeneratedDirectory(t *testing.T) {
|
func TestEnsureLifecycleIdentityStoresMetadataUnderGeneratedDirectory(t *testing.T) {
|
||||||
repo := t.TempDir()
|
repo := t.TempDir()
|
||||||
terraformDir := filepath.Join(repo, "terraform")
|
terraformDir := filepath.Join(repo, "terraform")
|
||||||
|
|
|
||||||
66
internal/bootstrap/democratic_csi.go
Normal file
66
internal/bootstrap/democratic_csi.go
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var democraticCSIHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
|
||||||
|
func destroyDemocraticCSIStorage(csi config.DemocraticCSIConfig) error {
|
||||||
|
parent := strings.Trim(csi.DatasetParentNFS, "/")
|
||||||
|
if parent == "" || strings.Contains(parent, "..") {
|
||||||
|
return fmt.Errorf("invalid Democratic CSI dataset parent")
|
||||||
|
}
|
||||||
|
base := "http://" + strings.TrimPrefix(strings.TrimPrefix(csi.TrueNASHost, "http://"), "https://") + ":80/api/v2.0/pool/dataset"
|
||||||
|
request, err := http.NewRequest(http.MethodGet, base+"?parent="+url.QueryEscape(parent), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+csi.TrueNASAPIKey)
|
||||||
|
response, err := democraticCSIHTTPClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("list datasets: %s", response.Status)
|
||||||
|
}
|
||||||
|
var datasets []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&datasets); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
prefix := parent + "/"
|
||||||
|
children := make([]string, 0, len(datasets))
|
||||||
|
for _, dataset := range datasets {
|
||||||
|
if strings.HasPrefix(dataset.Name, prefix) && !strings.Contains(strings.TrimPrefix(dataset.Name, prefix), "/") {
|
||||||
|
children = append(children, dataset.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(children)
|
||||||
|
for _, child := range children {
|
||||||
|
request, err := http.NewRequest(http.MethodDelete, base+"/id/"+url.PathEscape(child)+"?recursive=true&force=true", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+csi.TrueNASAPIKey)
|
||||||
|
response, err := democraticCSIHTTPClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusNoContent {
|
||||||
|
return fmt.Errorf("delete dataset %q: %s", child, response.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
33
internal/bootstrap/democratic_csi_test.go
Normal file
33
internal/bootstrap/democratic_csi_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roundTripper func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripper) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
|
||||||
|
|
||||||
|
func TestDestroyDemocraticCSIStorageDeletesOnlyDirectChildren(t *testing.T) {
|
||||||
|
original := democraticCSIHTTPClient
|
||||||
|
t.Cleanup(func() { democraticCSIHTTPClient = original })
|
||||||
|
var deleted []string
|
||||||
|
democraticCSIHTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
|
||||||
|
if request.Method == http.MethodGet {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`[{"name":"pool/cluster/one"},{"name":"pool/cluster/one/child"},{"name":"pool/other"}]`))}, nil
|
||||||
|
}
|
||||||
|
deleted = append(deleted, request.URL.EscapedPath())
|
||||||
|
return &http.Response{StatusCode: http.StatusNoContent, Body: io.NopCloser(strings.NewReader(""))}, nil
|
||||||
|
})}
|
||||||
|
if err := destroyDemocraticCSIStorage(config.DemocraticCSIConfig{TrueNASHost: "truenas.test", TrueNASAPIKey: "token", DatasetParentNFS: "pool/cluster"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(deleted) != 1 || !strings.Contains(deleted[0], "pool%2Fcluster%2Fone") {
|
||||||
|
t.Fatalf("deleted %v", deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
243
internal/bootstrap/fresh.go
Normal file
243
internal/bootstrap/fresh.go
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FreshOrganizationOptions struct {
|
||||||
|
Organization string
|
||||||
|
CreateOrganization bool
|
||||||
|
EnableDelivery bool
|
||||||
|
Mode Mode
|
||||||
|
ConfirmRebuild bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type FreshOrganizationPlan struct {
|
||||||
|
Phases []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var ensureFreshTemplateRevisions = EnsureTemplateRevisions
|
||||||
|
var newFreshRepoManager = forgejo.NewRepoManager
|
||||||
|
var runFreshLifecycle = reconcileFreshOrganization
|
||||||
|
|
||||||
|
// PlanFreshOrganization validates the fresh, reversible setup phases before
|
||||||
|
// any Forgejo or Git boundary is reached.
|
||||||
|
func PlanFreshOrganization(cfg config.Config, options FreshOrganizationOptions) (config.Config, FreshOrganizationPlan, error) {
|
||||||
|
mode, err := resolveLifecycleMode(options.Mode, options.ConfirmRebuild)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, FreshOrganizationPlan{}, err
|
||||||
|
}
|
||||||
|
resolved, err := config.ResolveFreshBootstrap(cfg, options.Organization, options.EnableDelivery)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, FreshOrganizationPlan{}, err
|
||||||
|
}
|
||||||
|
if !options.CreateOrganization {
|
||||||
|
return cfg, FreshOrganizationPlan{}, errors.New("--create-organization is required for fresh organization bootstrap")
|
||||||
|
}
|
||||||
|
if err := validateFreshWorkspace(resolved); err != nil {
|
||||||
|
return cfg, FreshOrganizationPlan{}, err
|
||||||
|
}
|
||||||
|
phases := []string{
|
||||||
|
"validate isolated workspace and configuration",
|
||||||
|
"lock template revisions in the isolated workspace",
|
||||||
|
"ensure the Forgejo organization",
|
||||||
|
"ensure baseline Flux and manifests repositories",
|
||||||
|
}
|
||||||
|
phases = append(phases, "initialize the user-managed Tekton catalog repository")
|
||||||
|
phases = append(phases, fmt.Sprintf("%s the CI/CD cluster", mode))
|
||||||
|
return resolved, FreshOrganizationPlan{Phases: phases}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunFreshOrganization completes a fresh bootstrap through the selected lifecycle.
|
||||||
|
func RunFreshOrganization(cfg config.Config, options FreshOrganizationOptions) (FreshOrganizationPlan, error) {
|
||||||
|
resolved, plan, err := PlanFreshOrganization(cfg, options)
|
||||||
|
if err != nil {
|
||||||
|
return FreshOrganizationPlan{}, err
|
||||||
|
}
|
||||||
|
if err := ensureFreshTemplateRevisions(resolved); err != nil {
|
||||||
|
return plan, fmt.Errorf("lock template revisions: %w", err)
|
||||||
|
}
|
||||||
|
manager := newFreshRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "")
|
||||||
|
if _, err := manager.EnsureOrganization(options.CreateOrganization); err != nil {
|
||||||
|
return plan, fmt.Errorf("ensure Forgejo organization: %w", err)
|
||||||
|
}
|
||||||
|
if err := runFreshLifecycle(resolved, options); err != nil {
|
||||||
|
return plan, fmt.Errorf("run fresh CI/CD bootstrap: %w", err)
|
||||||
|
}
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconcileFreshOrganization(cfg config.Config, options FreshOrganizationOptions) error {
|
||||||
|
return freshLifecycleRunner(cfg, options).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func freshLifecycleRunner(cfg config.Config, options FreshOrganizationOptions) Runner {
|
||||||
|
mode := options.Mode
|
||||||
|
if mode == "" {
|
||||||
|
mode = Reconcile
|
||||||
|
}
|
||||||
|
return Runner{
|
||||||
|
Config: cfg,
|
||||||
|
Mode: mode,
|
||||||
|
ConfirmRebuild: options.ConfirmRebuild,
|
||||||
|
SkipDeliveryScaffolding: true,
|
||||||
|
AutoMergeBootstrapMigration: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFreshWorkspace(cfg config.Config) error {
|
||||||
|
cloneRelative, err := filepath.Rel(cfg.WorkspaceDir, cfg.Git.CloneParent)
|
||||||
|
if err != nil || filepath.Dir(cloneRelative) != "." {
|
||||||
|
return errors.New("git cloneParent must be a direct child of isolated workspaceDir")
|
||||||
|
}
|
||||||
|
secretFiles, secretDirectories, err := freshWorkspaceSecretPaths(cfg, cloneRelative)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, err := os.Lstat(cfg.WorkspaceDir)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect workspaceDir: %w", err)
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||||
|
return errors.New("workspaceDir must be an isolated directory")
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(cfg.WorkspaceDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect workspaceDir: %w", err)
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lockPath := filepath.Join(cfg.WorkspaceDir, "maidn-template-revisions.yaml")
|
||||||
|
if !freshWorkspaceRegularFile(lockPath) {
|
||||||
|
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
|
||||||
|
}
|
||||||
|
lock, err := readTemplateRevisionLock(lockPath)
|
||||||
|
if err != nil || !sameTemplateSource(lock.CICD, templateCheckout{Repository: cfg.Templates.CICDRepoURL, Ref: cfg.Templates.CICDRepoRef}) || !sameTemplateSource(lock.Manifests, templateCheckout{Repository: cfg.Templates.ManifestsRepoURL, Ref: cfg.Templates.ManifestsRepoRef}) || !sameTemplateSource(lock.Talos, templateCheckout{Repository: cfg.Templates.TalosRepoURL, Ref: cfg.Templates.TalosRepoRef}) {
|
||||||
|
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
|
||||||
|
}
|
||||||
|
allowedFiles := map[string]bool{
|
||||||
|
"maidn-template-revisions.yaml": true,
|
||||||
|
"maidn-bootstrap.resolved.yaml": true,
|
||||||
|
}
|
||||||
|
allowedDirectories := map[string]bool{
|
||||||
|
"maidn-cicd-cluster-template": true,
|
||||||
|
"cicd-deployment-manifests-template": true,
|
||||||
|
cloneRelative: true,
|
||||||
|
}
|
||||||
|
for path := range secretFiles {
|
||||||
|
if filepath.Dir(path) == "." {
|
||||||
|
allowedFiles[path] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path := range secretDirectories {
|
||||||
|
if filepath.Dir(path) == "." {
|
||||||
|
allowedDirectories[path] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if allowedFiles[entry.Name()] {
|
||||||
|
if !freshWorkspaceRegularFile(filepath.Join(cfg.WorkspaceDir, entry.Name())) {
|
||||||
|
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !allowedDirectories[entry.Name()] || !freshWorkspaceDirectory(filepath.Join(cfg.WorkspaceDir, entry.Name())) {
|
||||||
|
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validateFreshWorkspaceSecretDirectories(cfg.WorkspaceDir, secretFiles, secretDirectories)
|
||||||
|
}
|
||||||
|
|
||||||
|
func freshWorkspaceSecretPaths(cfg config.Config, cloneRelative string) (map[string]bool, map[string]bool, error) {
|
||||||
|
files := map[string]bool{}
|
||||||
|
directories := map[string]bool{}
|
||||||
|
protected := map[string]bool{
|
||||||
|
"maidn-template-revisions.yaml": true,
|
||||||
|
"maidn-bootstrap.resolved.yaml": true,
|
||||||
|
"maidn-cicd-cluster-template": true,
|
||||||
|
"cicd-deployment-manifests-template": true,
|
||||||
|
cloneRelative: true,
|
||||||
|
}
|
||||||
|
for _, path := range []string{cfg.SOPS.AgeKeyPath, cfg.SOPS.BootstrapSecretsPath, cfg.SOPS.OperationalSecretsPath, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath} {
|
||||||
|
if path == "" || !filepath.IsAbs(path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(cfg.WorkspaceDir, path)
|
||||||
|
if err != nil || relative == "." || relative == ".." || filepath.IsAbs(relative) || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(filepath.ToSlash(relative), "/")
|
||||||
|
if protected[parts[0]] {
|
||||||
|
return nil, nil, errors.New("SOPS and recovery paths must not use protected workspace paths")
|
||||||
|
}
|
||||||
|
files[relative] = true
|
||||||
|
for parent := filepath.Dir(relative); parent != "."; parent = filepath.Dir(parent) {
|
||||||
|
directories[parent] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path := range files {
|
||||||
|
if directories[path] {
|
||||||
|
return nil, nil, errors.New("SOPS and recovery paths must not overlap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files, directories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFreshWorkspaceSecretDirectories(workspace string, files, directories map[string]bool) error {
|
||||||
|
for directory := range directories {
|
||||||
|
if directories[filepath.Dir(directory)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateFreshWorkspaceSecretDirectory(workspace, directory, files, directories); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFreshWorkspaceSecretDirectory(workspace, directory string, files, directories map[string]bool) error {
|
||||||
|
path := filepath.Join(workspace, directory)
|
||||||
|
entries, err := os.ReadDir(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil || !freshWorkspaceDirectory(path) {
|
||||||
|
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
relative := filepath.Join(directory, entry.Name())
|
||||||
|
path := filepath.Join(workspace, relative)
|
||||||
|
if files[relative] && freshWorkspaceRegularFile(path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if directories[relative] && freshWorkspaceDirectory(path) {
|
||||||
|
if err := validateFreshWorkspaceSecretDirectory(workspace, relative, files, directories); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func freshWorkspaceRegularFile(path string) bool {
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
return err == nil && info.Mode()&os.ModeSymlink == 0 && info.Mode().IsRegular()
|
||||||
|
}
|
||||||
|
|
||||||
|
func freshWorkspaceDirectory(path string) bool {
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
return err == nil && info.Mode()&os.ModeSymlink == 0 && info.IsDir()
|
||||||
|
}
|
||||||
288
internal/bootstrap/fresh_test.go
Normal file
288
internal/bootstrap/fresh_test.go
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func freshPlanConfig(t *testing.T) config.Config {
|
||||||
|
t.Helper()
|
||||||
|
workspace := t.TempDir()
|
||||||
|
return config.Config{
|
||||||
|
WorkspaceDir: workspace,
|
||||||
|
Git: config.GitConfig{Provider: "forgejo", BaseURL: "https://git.example.test", Username: "bot", Token: "token", CloneParent: filepath.Join(workspace, "checkouts")},
|
||||||
|
Flux: config.FluxConfig{RepoName: "cluster", Branch: "main", ClusterPath: "./clusters/cluster", ClusterDomain: "cluster.example.test", ManifestsRepo: "manifests", TektonCatalogRepo: "catalog"},
|
||||||
|
Talos: config.TalosConfig{RepoDirName: "talos", GeneratedDir: "generated"},
|
||||||
|
Delivery: config.DeliveryConfig{AppName: "app", AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/new-org/app", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.cluster.example.test", WebhookPath: "/"},
|
||||||
|
Templates: config.TemplateConfig{
|
||||||
|
TalosRepoURL: "https://git.example.test/templates/talos.git", TalosRepoRef: "main",
|
||||||
|
CICDRepoURL: "https://git.example.test/templates/cicd.git", CICDRepoRef: "main",
|
||||||
|
ManifestsRepoURL: "https://git.example.test/templates/manifests.git", ManifestsRepoRef: "main",
|
||||||
|
TektonCatalogRepoURL: "https://git.example.test/templates/catalog.git", TektonCatalogRepoRef: "main",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanFreshOrganizationOrdersOnlyFreshPhases(t *testing.T) {
|
||||||
|
cfg := freshPlanConfig(t)
|
||||||
|
resolved, plan, err := PlanFreshOrganization(cfg, FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true, EnableDelivery: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resolved.Git.Owner != "new-org" {
|
||||||
|
t.Fatal("organization was not bound to the fresh configuration")
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"validate isolated workspace and configuration",
|
||||||
|
"lock template revisions in the isolated workspace",
|
||||||
|
"ensure the Forgejo organization",
|
||||||
|
"ensure baseline Flux and manifests repositories",
|
||||||
|
"initialize the user-managed Tekton catalog repository",
|
||||||
|
"reconcile the CI/CD cluster",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(plan.Phases, want) {
|
||||||
|
t.Fatalf("plan phases = %#v, want %#v", plan.Phases, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanFreshOrganizationUsesSelectedLifecycleMode(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
options FreshOrganizationOptions
|
||||||
|
phase string
|
||||||
|
}{
|
||||||
|
{"default", FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true}, "reconcile the CI/CD cluster"},
|
||||||
|
{"rebuild", FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true, Mode: Rebuild, ConfirmRebuild: true}, "rebuild the CI/CD cluster"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, plan, err := PlanFreshOrganization(freshPlanConfig(t), test.options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := plan.Phases[len(plan.Phases)-1]; got != test.phase {
|
||||||
|
t.Fatalf("lifecycle phase = %q, want %q", got, test.phase)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanFreshOrganizationRejectsUnconfirmedRebuild(t *testing.T) {
|
||||||
|
_, _, err := PlanFreshOrganization(freshPlanConfig(t), FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true, Mode: Rebuild})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "--mode=rebuild --yes") {
|
||||||
|
t.Fatalf("PlanFreshOrganization() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunFreshOrganizationOrdersSourceControlBeforeLifecycle(t *testing.T) {
|
||||||
|
originalLock, originalManager, originalLifecycle := ensureFreshTemplateRevisions, newFreshRepoManager, runFreshLifecycle
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ensureFreshTemplateRevisions, newFreshRepoManager, runFreshLifecycle = originalLock, originalManager, originalLifecycle
|
||||||
|
})
|
||||||
|
var phases []string
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.Method + " " + request.URL.Path {
|
||||||
|
case http.MethodGet + " /api/v1/orgs/new-org":
|
||||||
|
phases = append(phases, "organization lookup")
|
||||||
|
writer.WriteHeader(http.StatusNotFound)
|
||||||
|
case http.MethodPost + " /api/v1/orgs":
|
||||||
|
if !reflect.DeepEqual(phases, []string{"template lock", "organization lookup"}) {
|
||||||
|
t.Fatalf("organization creation phase order = %#v", phases)
|
||||||
|
}
|
||||||
|
phases = append(phases, "organization create")
|
||||||
|
writer.WriteHeader(http.StatusCreated)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected Forgejo request %s %s", request.Method, request.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
ensureFreshTemplateRevisions = func(config.Config) error {
|
||||||
|
phases = append(phases, "template lock")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
newFreshRepoManager = func(_ string, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch string) *forgejo.RepoManager {
|
||||||
|
manager := forgejo.NewRepoManager(server.URL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch)
|
||||||
|
manager.HTTPClient = server.Client()
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
runFreshLifecycle = func(cfg config.Config, options FreshOrganizationOptions) error {
|
||||||
|
if !options.EnableDelivery || cfg.Git.Owner != "new-org" || !reflect.DeepEqual(phases, []string{"template lock", "organization lookup", "organization create"}) {
|
||||||
|
t.Fatal("lifecycle ran before the locked Forgejo source-control preflight")
|
||||||
|
}
|
||||||
|
phases = append(phases, "lifecycle")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := RunFreshOrganization(freshPlanConfig(t), FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true, EnableDelivery: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(phases, []string{"template lock", "organization lookup", "organization create", "lifecycle"}) {
|
||||||
|
t.Fatalf("fresh bootstrap phases = %#v", phases)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunFreshOrganizationStopsBeforeLifecycleOnPreflightFailure(t *testing.T) {
|
||||||
|
originalLock, originalLifecycle := ensureFreshTemplateRevisions, runFreshLifecycle
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ensureFreshTemplateRevisions, runFreshLifecycle = originalLock, originalLifecycle
|
||||||
|
})
|
||||||
|
ensureFreshTemplateRevisions = func(config.Config) error { return errors.New("unavailable") }
|
||||||
|
runFreshLifecycle = func(config.Config, FreshOrganizationOptions) error {
|
||||||
|
t.Fatal("lifecycle ran after template lock failure")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := RunFreshOrganization(freshPlanConfig(t), FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "lock template revisions") {
|
||||||
|
t.Fatalf("RunFreshOrganization() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunFreshOrganizationStopsBeforeLifecycleOnForgejoFailure(t *testing.T) {
|
||||||
|
originalLock, originalManager, originalLifecycle := ensureFreshTemplateRevisions, newFreshRepoManager, runFreshLifecycle
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ensureFreshTemplateRevisions, newFreshRepoManager, runFreshLifecycle = originalLock, originalManager, originalLifecycle
|
||||||
|
})
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
writer.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
ensureFreshTemplateRevisions = func(config.Config) error { return nil }
|
||||||
|
newFreshRepoManager = func(_ string, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch string) *forgejo.RepoManager {
|
||||||
|
manager := forgejo.NewRepoManager(server.URL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch)
|
||||||
|
manager.HTTPClient = server.Client()
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
runFreshLifecycle = func(config.Config, FreshOrganizationOptions) error {
|
||||||
|
t.Fatal("lifecycle ran after Forgejo preflight failure")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := RunFreshOrganization(freshPlanConfig(t), FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "ensure Forgejo organization") {
|
||||||
|
t.Fatalf("RunFreshOrganization() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreshLifecycleUsesSelectedModeWithoutAppDelivery(t *testing.T) {
|
||||||
|
originalPreflight := preflight
|
||||||
|
t.Cleanup(func() { preflight = originalPreflight })
|
||||||
|
cfg := runnerTestConfig(t.TempDir(), "")
|
||||||
|
cfg.Delivery = config.DeliveryConfig{}
|
||||||
|
for _, test := range []struct {
|
||||||
|
options FreshOrganizationOptions
|
||||||
|
mode Mode
|
||||||
|
}{
|
||||||
|
{FreshOrganizationOptions{}, Reconcile},
|
||||||
|
{FreshOrganizationOptions{Mode: Rebuild, ConfirmRebuild: true, EnableDelivery: true}, Rebuild},
|
||||||
|
} {
|
||||||
|
runner := freshLifecycleRunner(cfg, test.options)
|
||||||
|
if runner.Mode != test.mode || runner.ConfirmRebuild != test.options.ConfirmRebuild || runner.EnableDelivery || !runner.SkipDeliveryScaffolding || !runner.AutoMergeBootstrapMigration {
|
||||||
|
t.Fatalf("fresh lifecycle runner = %#v", runner)
|
||||||
|
}
|
||||||
|
preflight = func(got config.Config) error {
|
||||||
|
if got.Delivery.Configured() {
|
||||||
|
t.Fatal("fresh lifecycle resolved app delivery during platform initialization")
|
||||||
|
}
|
||||||
|
return errors.New("stop")
|
||||||
|
}
|
||||||
|
if err := runner.Run(); err == nil || !strings.Contains(err.Error(), "preflight: stop") {
|
||||||
|
t.Fatalf("fresh lifecycle run = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreshPlatformRunnerInitializesOpenBaoWithoutRegisteringWebhookAfterFluxSetup(t *testing.T) {
|
||||||
|
originalInitialize, originalWebhook := initializeOpenBao, ensureForgejoWebhook
|
||||||
|
t.Cleanup(func() {
|
||||||
|
initializeOpenBao, ensureForgejoWebhook = originalInitialize, originalWebhook
|
||||||
|
})
|
||||||
|
runner := freshLifecycleRunner(runnerTestConfig(t.TempDir(), "age-key"), FreshOrganizationOptions{})
|
||||||
|
if !runner.SkipDeliveryScaffolding || runner.RegisterWebhook {
|
||||||
|
t.Fatalf("fresh platform runner = %#v", runner)
|
||||||
|
}
|
||||||
|
initialized := false
|
||||||
|
initializeOpenBao = func(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, operationalSecretsPath string) (map[string]map[string]string, error) {
|
||||||
|
if kubeconfig != filepath.Join("after-flux", "kubeconfig") || ageKeyPath != "age-key" {
|
||||||
|
t.Fatal("platform OpenBao initialization used unexpected paths")
|
||||||
|
}
|
||||||
|
initialized = true
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
ensureForgejoWebhook = func(config.Config, string, string, string) error {
|
||||||
|
t.Fatal("platform initialization registered an application webhook")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := runner.completeFluxBootstrap("after-flux"); err != nil || !initialized {
|
||||||
|
t.Fatalf("post-Flux platform initialization = %v, initialized = %t", err, initialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) {
|
||||||
|
cfg := freshPlanConfig(t)
|
||||||
|
prepareResumableFreshWorkspace(t, &cfg)
|
||||||
|
if err := os.WriteFile(filepath.Join(cfg.WorkspaceDir, ".age", "leftover"), []byte("state"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := PlanFreshOrganization(cfg, FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true}); err == nil {
|
||||||
|
t.Fatal("ambiguous workspace state was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanFreshOrganizationResumesKnownWorkspaceState(t *testing.T) {
|
||||||
|
cfg := freshPlanConfig(t)
|
||||||
|
prepareResumableFreshWorkspace(t, &cfg)
|
||||||
|
if _, _, err := PlanFreshOrganization(cfg, FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true}); err != nil {
|
||||||
|
t.Fatalf("resumable workspace state was rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareResumableFreshWorkspace(t *testing.T, cfg *config.Config) {
|
||||||
|
t.Helper()
|
||||||
|
cfg.SOPS = config.SOPSConfig{
|
||||||
|
AgeKeyPath: filepath.Join(cfg.WorkspaceDir, ".age", "key.txt"),
|
||||||
|
BootstrapSecretsPath: filepath.Join(cfg.WorkspaceDir, "bootstrap-secrets.sops.yaml"),
|
||||||
|
OperationalSecretsPath: filepath.Join(cfg.WorkspaceDir, "operational-secrets.sops.yaml"),
|
||||||
|
RecoveryIdentityPath: filepath.Join(cfg.WorkspaceDir, ".age", "recovery-key.txt"),
|
||||||
|
RecoveryBundlePath: filepath.Join(cfg.WorkspaceDir, ".recovery", "openbao-recovery.age"),
|
||||||
|
}
|
||||||
|
for _, directory := range []string{
|
||||||
|
filepath.Join(cfg.WorkspaceDir, "maidn-cicd-cluster-template"),
|
||||||
|
filepath.Join(cfg.WorkspaceDir, "cicd-deployment-manifests-template"),
|
||||||
|
cfg.Git.CloneParent,
|
||||||
|
filepath.Join(cfg.WorkspaceDir, ".age"),
|
||||||
|
filepath.Join(cfg.WorkspaceDir, ".recovery"),
|
||||||
|
} {
|
||||||
|
if err := os.MkdirAll(directory, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writeTemplateRevisionLock(filepath.Join(cfg.WorkspaceDir, "maidn-template-revisions.yaml"), templateRevisionLock{
|
||||||
|
Version: 1,
|
||||||
|
CICD: templateRevision{Repository: cfg.Templates.CICDRepoURL, Ref: cfg.Templates.CICDRepoRef, Commit: strings.Repeat("a", 40)},
|
||||||
|
Manifests: templateRevision{Repository: cfg.Templates.ManifestsRepoURL, Ref: cfg.Templates.ManifestsRepoRef, Commit: strings.Repeat("b", 40)},
|
||||||
|
Talos: templateRevision{Repository: cfg.Templates.TalosRepoURL, Ref: cfg.Templates.TalosRepoRef, Commit: strings.Repeat("c", 40)},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{
|
||||||
|
filepath.Join(cfg.WorkspaceDir, "maidn-bootstrap.resolved.yaml"),
|
||||||
|
cfg.SOPS.AgeKeyPath,
|
||||||
|
cfg.SOPS.BootstrapSecretsPath,
|
||||||
|
cfg.SOPS.OperationalSecretsPath,
|
||||||
|
cfg.SOPS.RecoveryIdentityPath,
|
||||||
|
cfg.SOPS.RecoveryBundlePath,
|
||||||
|
} {
|
||||||
|
if err := os.WriteFile(path, nil, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
368
internal/bootstrap/onboard.go
Normal file
368
internal/bootstrap/onboard.go
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type onboardingRepoManager interface {
|
||||||
|
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 {
|
||||||
|
resolved, err := config.ResolveAppOnboarding(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := forgejo.EnsureCleanCheckout(sourceDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sourceBranch, err := forgejo.CurrentBranch(sourceDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sourceBranch != resolved.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)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sourceManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, owner, resolved.Git.Username, "", "", resolved.Delivery.AppRepoRef, "")
|
||||||
|
if _, err := sourceManager.EnsureRepository(repository, "Application build input for Maidn CI/CD"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := publishInitialAppBranches(sourceManager, sourceDir, resolved.Delivery.AppRepoURL, sourceRevision, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := sourceManager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil {
|
||||||
|
return fmt.Errorf("protect Forgejo production branch: %w", err)
|
||||||
|
}
|
||||||
|
if err := ensurePlatformBranch(sourceManager, repository, resolved.Delivery.AppRepoURL, resolved.Delivery.AppName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
registrationBranch := registrationBranch(resolved.Delivery.AppName, sourceRevision)
|
||||||
|
clusterManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "")
|
||||||
|
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)
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("register app in cluster repository: %w", err)
|
||||||
|
}
|
||||||
|
secrets, err := readOperationalSecrets(resolved.SOPS.OperationalSecretsPath, resolved.SOPS.AgeKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read encrypted webhook authorization: %w", err)
|
||||||
|
}
|
||||||
|
authorization := secrets["cicd/forgejo-webhook"]["authorization"]
|
||||||
|
if authorization == "" {
|
||||||
|
return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization")
|
||||||
|
}
|
||||||
|
generatedDir := filepath.Join(resolved.Git.CloneParent, resolved.Talos.RepoDirName, resolved.Talos.GeneratedDir)
|
||||||
|
if err := waitForWebhookTargets(generatedDir, resolved); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
webhookURL := "https://tekton." + resolved.Flux.ClusterDomain + "/"
|
||||||
|
if err := sourceManager.EnsureWebhook(repository, webhookURL, authorization); err != nil {
|
||||||
|
return fmt.Errorf("register Forgejo webhook: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func registrationBranch(appName, revision string) string {
|
||||||
|
return "maidn/register-" + appName + "-" + revision[:12]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read target base branch: %w", err)
|
||||||
|
}
|
||||||
|
createdMain := mainRevision == ""
|
||||||
|
if mainRevision == "" {
|
||||||
|
if err := manager.PushRef(sourceDir, targetURL, sourceRevision, targetBranch); err != nil {
|
||||||
|
return fmt.Errorf("publish source base branch: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if productionRevision, err := manager.RemoteBranchRevision(targetURL, productionBranch); err != nil {
|
||||||
|
return fmt.Errorf("read target production branch: %w", err)
|
||||||
|
} else if productionRevision == "" {
|
||||||
|
if err := manager.PushRef(sourceDir, targetURL, sourceRevision, productionBranch); err != nil {
|
||||||
|
return fmt.Errorf("create production from source base branch: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if createdMain {
|
||||||
|
mainRevision, err = manager.RemoteBranchRevision(targetURL, targetBranch)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("verify target base branch: %w", err)
|
||||||
|
}
|
||||||
|
if mainRevision != sourceRevision {
|
||||||
|
return errors.New("target base branch does not match the validated source ref")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if productionRevision, err := manager.RemoteBranchRevision(targetURL, productionBranch); err != nil {
|
||||||
|
return fmt.Errorf("verify target production branch: %w", err)
|
||||||
|
} else if productionRevision == "" {
|
||||||
|
return errors.New("target production branch was not created")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterAppInCluster writes only the managed Flux registration for one app.
|
||||||
|
func RegisterAppInCluster(dir string, cfg config.Config) error {
|
||||||
|
return registerAppInCluster(dir, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAppInCluster(dir string, cfg config.Config) error {
|
||||||
|
content, err := renderAppRegistration(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tektonDir := filepath.Join(dir, "base", "tekton")
|
||||||
|
rootPath := filepath.Join(tektonDir, "kustomization.yaml")
|
||||||
|
root, err := readRegularFile(rootPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read Tekton Kustomization: %w", err)
|
||||||
|
}
|
||||||
|
updatedRoot, err := addKustomizationResource(root, "apps")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Tekton Kustomization: %w", err)
|
||||||
|
}
|
||||||
|
appsDir := filepath.Join(tektonDir, "apps")
|
||||||
|
if info, statErr := os.Lstat(appsDir); statErr == nil && (info.Mode()&os.ModeSymlink != 0 || !info.IsDir()) {
|
||||||
|
return errors.New("Tekton apps path must be a directory")
|
||||||
|
} else if statErr != nil && !os.IsNotExist(statErr) {
|
||||||
|
return statErr
|
||||||
|
}
|
||||||
|
appsPath := filepath.Join(appsDir, "kustomization.yaml")
|
||||||
|
apps, err := os.ReadFile(appsPath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
if info, statErr := os.Lstat(appsPath); statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||||
|
return errors.New("Tekton apps Kustomization must be a regular file")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
apps = []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n")
|
||||||
|
}
|
||||||
|
updatedApps, err := addKustomizationResource(apps, cfg.Delivery.AppName+".yaml")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Tekton apps Kustomization: %w", err)
|
||||||
|
}
|
||||||
|
registrationPath := filepath.Join(appsDir, cfg.Delivery.AppName+".yaml")
|
||||||
|
if existing, readErr := os.ReadFile(registrationPath); readErr == nil {
|
||||||
|
info, statErr := os.Lstat(registrationPath)
|
||||||
|
if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || !bytes.Equal(bytes.ReplaceAll(existing, []byte("\r\n"), []byte("\n")), content) {
|
||||||
|
return errors.New("Tekton app registration conflicts with unmanaged content")
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(readErr) {
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(appsDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(rootPath, updatedRoot, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(appsPath, updatedApps, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(registrationPath, content, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAppRegistration(cfg config.Config) ([]byte, error) {
|
||||||
|
if err := config.ValidateDelivery(cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
secretAccess, err := renderAppSecretAccess(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
delivery, err := renderAppDelivery(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
content := fmt.Sprintf(`apiVersion: source.toolkit.fluxcd.io/v1
|
||||||
|
kind: GitRepository
|
||||||
|
metadata:
|
||||||
|
name: %s
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
interval: 1m
|
||||||
|
url: %s
|
||||||
|
secretRef:
|
||||||
|
name: forgejo-flux-credentials
|
||||||
|
ref:
|
||||||
|
branch: %s
|
||||||
|
`, cfg.Delivery.AppName, cfg.Delivery.AppRepoURL, platformBranch(cfg.Delivery.AppName))
|
||||||
|
if len(secretAccess) != 0 {
|
||||||
|
content += "---\n" + string(secretAccess)
|
||||||
|
}
|
||||||
|
return append([]byte(content+"---\n"), delivery...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderAppSecretAccess renders only central OpenBao references, never secret values.
|
||||||
|
func renderAppSecretAccess(cfg config.Config) ([]byte, error) {
|
||||||
|
if err := config.ValidateSecretGrants(cfg.SecretGrants); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var manifests []string
|
||||||
|
for _, grant := range cfg.SecretGrants {
|
||||||
|
if grant.Application != cfg.Delivery.AppName || grant.Consumer != "runtime" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := cfg.Delivery.AppName + "-runtime-" + grant.Environment
|
||||||
|
manifest := fmt.Sprintf(`apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: maidn-%s
|
||||||
|
namespace: %s
|
||||||
|
---
|
||||||
|
apiVersion: external-secrets.io/v1
|
||||||
|
kind: SecretStore
|
||||||
|
metadata:
|
||||||
|
name: openbao-%s
|
||||||
|
namespace: %s
|
||||||
|
spec:
|
||||||
|
provider:
|
||||||
|
vault:
|
||||||
|
server: http://openbao.openbao.svc:8200
|
||||||
|
path: secret
|
||||||
|
version: v2
|
||||||
|
auth:
|
||||||
|
kubernetes:
|
||||||
|
mountPath: kubernetes
|
||||||
|
role: maidn-%s
|
||||||
|
serviceAccountRef:
|
||||||
|
name: maidn-%s
|
||||||
|
---
|
||||||
|
apiVersion: external-secrets.io/v1
|
||||||
|
kind: ExternalSecret
|
||||||
|
metadata:
|
||||||
|
name: %s
|
||||||
|
namespace: %s
|
||||||
|
spec:
|
||||||
|
refreshInterval: 1h
|
||||||
|
secretStoreRef:
|
||||||
|
kind: SecretStore
|
||||||
|
name: openbao-%s
|
||||||
|
target:
|
||||||
|
name: %s
|
||||||
|
creationPolicy: Owner
|
||||||
|
data:
|
||||||
|
`, name, grant.Environment, name, grant.Environment, name, name, name, grant.Environment, name, name)
|
||||||
|
for _, secret := range grant.Secrets {
|
||||||
|
manifest += fmt.Sprintf(" - secretKey: %s\n remoteRef:\n key: apps/%s/%s\n property: value\n", secret, grant.Application, secret)
|
||||||
|
}
|
||||||
|
manifests = append(manifests, manifest)
|
||||||
|
}
|
||||||
|
return []byte(strings.Join(manifests, "---\n")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readRegularFile(path string) ([]byte, error) {
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||||
|
return nil, errors.New("must be a regular file")
|
||||||
|
}
|
||||||
|
return os.ReadFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addKustomizationResource(content []byte, resource string) ([]byte, error) {
|
||||||
|
decoder := yaml.NewDecoder(bytes.NewReader(content))
|
||||||
|
var document yaml.Node
|
||||||
|
if err := decoder.Decode(&document); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&yaml.Node{}); !errors.Is(err, io.EOF) {
|
||||||
|
return nil, errors.New("must contain one YAML document")
|
||||||
|
}
|
||||||
|
if document.Kind != yaml.DocumentNode || len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
|
||||||
|
return nil, errors.New("must be a Kustomization mapping")
|
||||||
|
}
|
||||||
|
root := document.Content[0]
|
||||||
|
apiVersion, err := yamlMappingValue(root, "apiVersion")
|
||||||
|
if err != nil || apiVersion == nil || apiVersion.Value != "kustomize.config.k8s.io/v1beta1" {
|
||||||
|
return nil, errors.New("must be a Kustomization")
|
||||||
|
}
|
||||||
|
kind, err := yamlMappingValue(root, "kind")
|
||||||
|
if err != nil || kind == nil || kind.Value != "Kustomization" {
|
||||||
|
return nil, errors.New("must be a Kustomization")
|
||||||
|
}
|
||||||
|
resources, err := yamlMappingValue(root, "resources")
|
||||||
|
if err != nil || resources == nil {
|
||||||
|
return nil, errors.New("must define a resources list")
|
||||||
|
}
|
||||||
|
if resources.Kind == yaml.ScalarNode && resources.Tag == "!!null" {
|
||||||
|
resources.Kind, resources.Tag, resources.Value = yaml.SequenceNode, "!!seq", ""
|
||||||
|
}
|
||||||
|
if resources.Kind != yaml.SequenceNode {
|
||||||
|
return nil, errors.New("must define a resources list")
|
||||||
|
}
|
||||||
|
for _, item := range resources.Content {
|
||||||
|
if item.Kind != yaml.ScalarNode || item.Value == "" {
|
||||||
|
return nil, errors.New("resources must contain non-empty scalar values")
|
||||||
|
}
|
||||||
|
if item.Value == resource {
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
encoder := yaml.NewEncoder(&rendered)
|
||||||
|
encoder.SetIndent(2)
|
||||||
|
if err := encoder.Encode(&document); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rendered.Bytes(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resources.Content = append(resources.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: resource})
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
encoder := yaml.NewEncoder(&rendered)
|
||||||
|
encoder.SetIndent(2)
|
||||||
|
if err := encoder.Encode(&document); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rendered.Bytes(), nil
|
||||||
|
}
|
||||||
287
internal/bootstrap/onboard_test.go
Normal file
287
internal/bootstrap/onboard_test.go
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func onboardingGit(t *testing.T, dir string, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
command := exec.Command("git", args...)
|
||||||
|
command.Dir = dir
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, output)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
func onboardingConfig() config.Config {
|
||||||
|
return config.Config{
|
||||||
|
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "test-org-2"},
|
||||||
|
Flux: config.FluxConfig{Branch: "main", RepoName: "cluster", ManifestsRepo: "manifests", ClusterDomain: "example.test"},
|
||||||
|
Delivery: config.DeliveryConfig{
|
||||||
|
AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org-2/web-ui.git", AppRepoRef: "main", ProductionBranch: "production",
|
||||||
|
ImageRepository: "registry.example.test/test-org-2/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production",
|
||||||
|
WebhookHostname: "tekton.example.test", WebhookPath: "/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterAppInClusterRendersCentralDeliveryResources(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
tektonDir := filepath.Join(dir, "base", "tekton")
|
||||||
|
if err := os.MkdirAll(tektonDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tektonDir, "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := RegisterAppInCluster(dir, onboardingConfig()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
registration, err := os.ReadFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"))
|
||||||
|
if err != nil || !strings.Contains(string(registration), "branch: maidn/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)
|
||||||
|
}
|
||||||
|
for path, resource := range map[string]string{filepath.Join(tektonDir, "kustomization.yaml"): "apps", filepath.Join(tektonDir, "apps", "kustomization.yaml"): "web-ui.yaml"} {
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil || !strings.Contains(string(content), resource) {
|
||||||
|
t.Fatalf("Kustomization %s does not include %s: %q, %v", path, resource, content, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := RegisterAppInCluster(dir, onboardingConfig()); err != nil {
|
||||||
|
t.Fatalf("CRLF registration was rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"), []byte("custom: true\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := RegisterAppInCluster(dir, onboardingConfig()); err == nil || !strings.Contains(err.Error(), "conflicts") {
|
||||||
|
t.Fatalf("unmanaged app registration was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderAppRegistrationIncludesOnlyDeclaredRuntimeSecretAccess(t *testing.T) {
|
||||||
|
cfg := onboardingConfig()
|
||||||
|
cfg.SecretGrants = []config.SecretGrant{{Application: "web-ui", Consumer: "runtime", Environment: "staging", Secrets: []string{"api-key"}, Shared: []string{"payments"}}}
|
||||||
|
registration, err := renderAppRegistration(cfg)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
|
||||||
|
source := filepath.Join(t.TempDir(), "source")
|
||||||
|
target := filepath.Join(t.TempDir(), "target.git")
|
||||||
|
if err := os.Mkdir(source, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
onboardingGit(t, source, "init", "-b", "source")
|
||||||
|
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")
|
||||||
|
sourceRevision := onboardingGit(t, source, "rev-parse", "source")
|
||||||
|
onboardingGit(t, "", "init", "--bare", target)
|
||||||
|
|
||||||
|
manager := forgejo.NewRepoManager("https://git.example.test", "", "owner", "", "", "", "main", "")
|
||||||
|
if err := publishInitialAppBranches(manager, source, target, sourceRevision, "main", "production"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, branch := range []string{"main", "production"} {
|
||||||
|
if got := onboardingGit(t, "", "--git-dir", target, "rev-parse", "refs/heads/"+branch); got != sourceRevision {
|
||||||
|
t.Fatalf("%s = %s, want source %s", branch, got, sourceRevision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preservedTarget := filepath.Join(t.TempDir(), "preserved-target.git")
|
||||||
|
onboardingGit(t, "", "init", "--bare", preservedTarget)
|
||||||
|
onboardingGit(t, source, "push", preservedTarget, "source:main")
|
||||||
|
production := filepath.Join(t.TempDir(), "production")
|
||||||
|
if err := os.Mkdir(production, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
onboardingGit(t, production, "init", "-b", "production")
|
||||||
|
onboardingGit(t, production, "config", "user.name", "Test")
|
||||||
|
onboardingGit(t, production, "config", "user.email", "test@example.test")
|
||||||
|
if err := os.WriteFile(filepath.Join(production, "README.md"), []byte("existing production\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
onboardingGit(t, production, "add", "README.md")
|
||||||
|
onboardingGit(t, production, "commit", "-m", "existing production")
|
||||||
|
existingProduction := onboardingGit(t, production, "rev-parse", "production")
|
||||||
|
onboardingGit(t, production, "push", preservedTarget, "production:production")
|
||||||
|
|
||||||
|
if err := publishInitialAppBranches(manager, source, preservedTarget, sourceRevision, "main", "production"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/production"); got != existingProduction {
|
||||||
|
t.Fatalf("production = %s, want existing %s", got, existingProduction)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(source, "README.md"), []byte("updated source\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
onboardingGit(t, source, "commit", "-am", "updated source")
|
||||||
|
if err := publishInitialAppBranches(manager, source, preservedTarget, sourceRevision, "main", "production"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/main"); 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)
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ func WriteRedacted(path string, cfg Config) error {
|
||||||
redacted.Templates.TektonCatalogRepoURL = RedactURL(redacted.Templates.TektonCatalogRepoURL)
|
redacted.Templates.TektonCatalogRepoURL = RedactURL(redacted.Templates.TektonCatalogRepoURL)
|
||||||
redacted.Delivery.AppRepoURL = RedactURL(redacted.Delivery.AppRepoURL)
|
redacted.Delivery.AppRepoURL = RedactURL(redacted.Delivery.AppRepoURL)
|
||||||
redacted.Delivery.ImageRepository = RedactURL(redacted.Delivery.ImageRepository)
|
redacted.Delivery.ImageRepository = RedactURL(redacted.Delivery.ImageRepository)
|
||||||
|
redacted.Delivery.TektonDashboardURL = RedactURL(redacted.Delivery.TektonDashboardURL)
|
||||||
data, err := yaml.Marshal(redacted)
|
data, err := yaml.Marshal(redacted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -100,7 +101,123 @@ func Resolve(cfg Config) (Config, error) {
|
||||||
return cfg, Validate(cfg)
|
return cfg, Validate(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveDelivery applies delivery defaults for an explicitly requested app operation.
|
||||||
|
func ResolveDelivery(cfg Config) (Config, error) {
|
||||||
|
applyDeliveryDefaults(&cfg)
|
||||||
|
return cfg, ValidateDelivery(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFreshBootstrap validates the scoped contract needed before a new
|
||||||
|
// Forgejo organization can be scaffolded. It deliberately does not validate
|
||||||
|
// recovery, SOPS, Proxmox, or Talos state.
|
||||||
|
func ResolveFreshBootstrap(cfg Config, organization string, enableDelivery bool) (Config, error) {
|
||||||
|
if !validRepositoryPart(organization) {
|
||||||
|
return cfg, errors.New("organization must be a Forgejo owner name")
|
||||||
|
}
|
||||||
|
if cfg.Git.Owner != "" && cfg.Git.Owner != organization {
|
||||||
|
return cfg, errors.New("git owner must match --organization")
|
||||||
|
}
|
||||||
|
cfg.Git.Owner = organization
|
||||||
|
if cfg.WorkspaceDir == "" || !filepath.IsAbs(cfg.WorkspaceDir) {
|
||||||
|
return cfg, errors.New("workspaceDir must be an absolute isolated workspace path")
|
||||||
|
}
|
||||||
|
if cfg.Git.CloneParent == "" || !filepath.IsAbs(cfg.Git.CloneParent) || !isChildPath(cfg.WorkspaceDir, cfg.Git.CloneParent) {
|
||||||
|
return cfg, errors.New("git cloneParent must be an absolute child of workspaceDir")
|
||||||
|
}
|
||||||
|
if cfg.Git.Provider != "forgejo" || cfg.Git.BaseURL == "" || cfg.Git.Username == "" || cfg.Git.Token == "" {
|
||||||
|
return cfg, errors.New("git provider, baseUrl, username, and token are required for fresh bootstrap")
|
||||||
|
}
|
||||||
|
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
if !validRepositoryPart(cfg.Flux.RepoName) || !validRepositoryPart(cfg.Flux.ManifestsRepo) || !validRepositoryPart(cfg.Flux.TektonCatalogRepo) || cfg.Flux.Branch == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ClusterDomain == "" {
|
||||||
|
return cfg, errors.New("flux repoName, manifestsRepo, tektonCatalogRepo, branch, clusterPath, and clusterDomain are required")
|
||||||
|
}
|
||||||
|
if cfg.Talos.RepoDirName == "" || filepath.Base(cfg.Talos.RepoDirName) != cfg.Talos.RepoDirName || cfg.Talos.GeneratedDir == "" {
|
||||||
|
return cfg, errors.New("talos repoDirName and generatedDir are required for template locking")
|
||||||
|
}
|
||||||
|
for _, source := range []struct{ URL, Ref string }{
|
||||||
|
{cfg.Templates.TalosRepoURL, cfg.Templates.TalosRepoRef},
|
||||||
|
{cfg.Templates.CICDRepoURL, cfg.Templates.CICDRepoRef},
|
||||||
|
{cfg.Templates.ManifestsRepoURL, cfg.Templates.ManifestsRepoRef},
|
||||||
|
{cfg.Templates.TektonCatalogRepoURL, cfg.Templates.TektonCatalogRepoRef},
|
||||||
|
} {
|
||||||
|
if source.URL == "" || source.Ref == "" || RedactURL(source.URL) != source.URL {
|
||||||
|
return cfg, errors.New("template repository URLs and refs must be explicit and credential-free")
|
||||||
|
}
|
||||||
|
if err := validateRepositoryURL(source.URL); err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveAppOnboarding validates only the source-owned delivery contract.
|
||||||
|
func ResolveAppOnboarding(cfg Config) (Config, error) {
|
||||||
|
if cfg.Git.Owner == "" || cfg.Git.Username == "" || cfg.Git.Token == "" || cfg.Flux.RepoName == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.Branch == "" || cfg.Flux.ClusterDomain == "" {
|
||||||
|
return cfg, errors.New("git owner, username, token, flux repoName, manifestsRepo, branch, and clusterDomain are required for app onboarding")
|
||||||
|
}
|
||||||
|
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
resolved, err := ResolveDelivery(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
owner, repository, err := deliveryRepositoryOwner(resolved.Delivery.AppRepoURL)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
if owner != resolved.Git.Owner && (owner != "Maidn" || !strings.HasPrefix(repository, "maidn-e2e-")) {
|
||||||
|
return cfg, errors.New("delivery appRepoUrl owner must match git owner or identify a canonical Maidn E2E fixture")
|
||||||
|
}
|
||||||
|
if resolved.Delivery.ProductionBranch != "production" {
|
||||||
|
return cfg, errors.New("delivery productionBranch must be literal production for app onboarding")
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliveryRepositoryOwner(value string) (string, string, error) {
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||||
|
if len(parts) != 2 || !strings.HasSuffix(parts[1], ".git") || !validRepositoryPart(parts[0]) || !validRepositoryPart(strings.TrimSuffix(parts[1], ".git")) {
|
||||||
|
return "", "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
|
||||||
|
}
|
||||||
|
return parts[0], strings.TrimSuffix(parts[1], ".git"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validRepositoryPart(value string) bool {
|
||||||
|
return value != "" && !strings.Contains(value, "..") && regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`).MatchString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isChildPath(parent, child string) bool {
|
||||||
|
relative, err := filepath.Rel(filepath.Clean(parent), filepath.Clean(child))
|
||||||
|
return err == nil && relative != "." && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateForgejoOrigin(value string) error {
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") != "" {
|
||||||
|
return errors.New("git baseUrl must be a credential-free HTTPS origin")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRepositoryURL(value string) error {
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") == "" {
|
||||||
|
return errors.New("template repository URL must be a credential-free HTTPS repository URL")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func applyDefaults(cfg *Config) {
|
func applyDefaults(cfg *Config) {
|
||||||
|
if cfg.Delivery.BuildStrategy == "" {
|
||||||
|
cfg.Delivery.BuildStrategy = "static"
|
||||||
|
}
|
||||||
if cfg.ClusterID == "" {
|
if cfg.ClusterID == "" {
|
||||||
cfg.ClusterID = cfg.Talos.Cluster.Name
|
cfg.ClusterID = cfg.Talos.Cluster.Name
|
||||||
}
|
}
|
||||||
|
|
@ -158,23 +275,6 @@ func applyDefaults(cfg *Config) {
|
||||||
if cfg.DemocraticCSI.InitiatorGroup == "" {
|
if cfg.DemocraticCSI.InitiatorGroup == "" {
|
||||||
cfg.DemocraticCSI.InitiatorGroup = "1"
|
cfg.DemocraticCSI.InitiatorGroup = "1"
|
||||||
}
|
}
|
||||||
if cfg.Delivery.Configured() {
|
|
||||||
if cfg.Delivery.AppRepoRef == "" {
|
|
||||||
cfg.Delivery.AppRepoRef = cfg.Flux.Branch
|
|
||||||
}
|
|
||||||
if cfg.Delivery.BuildOutputDirectory == "" {
|
|
||||||
cfg.Delivery.BuildOutputDirectory = "dist"
|
|
||||||
}
|
|
||||||
if cfg.Delivery.BuildConfiguration == "" {
|
|
||||||
cfg.Delivery.BuildConfiguration = "production"
|
|
||||||
}
|
|
||||||
if cfg.Delivery.WebhookHostname == "" && cfg.Flux.ClusterDomain != "" {
|
|
||||||
cfg.Delivery.WebhookHostname = "tekton." + cfg.Flux.ClusterDomain
|
|
||||||
}
|
|
||||||
if cfg.Delivery.WebhookPath == "" {
|
|
||||||
cfg.Delivery.WebhookPath = "/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cfg.Talos.RepoDirName == "" {
|
if cfg.Talos.RepoDirName == "" {
|
||||||
cfg.Talos.RepoDirName = "maidn-talos-proxmox"
|
cfg.Talos.RepoDirName = "maidn-talos-proxmox"
|
||||||
}
|
}
|
||||||
|
|
@ -219,6 +319,27 @@ func applyDefaults(cfg *Config) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyDeliveryDefaults(cfg *Config) {
|
||||||
|
if cfg.Delivery.BuildStrategy == "" {
|
||||||
|
cfg.Delivery.BuildStrategy = "static"
|
||||||
|
}
|
||||||
|
if cfg.Delivery.AppRepoRef == "" {
|
||||||
|
cfg.Delivery.AppRepoRef = cfg.Flux.Branch
|
||||||
|
}
|
||||||
|
if cfg.Delivery.BuildOutputDirectory == "" {
|
||||||
|
cfg.Delivery.BuildOutputDirectory = "dist"
|
||||||
|
}
|
||||||
|
if cfg.Delivery.BuildConfiguration == "" {
|
||||||
|
cfg.Delivery.BuildConfiguration = "production"
|
||||||
|
}
|
||||||
|
if cfg.Delivery.WebhookHostname == "" && cfg.Flux.ClusterDomain != "" {
|
||||||
|
cfg.Delivery.WebhookHostname = "tekton." + cfg.Flux.ClusterDomain
|
||||||
|
}
|
||||||
|
if cfg.Delivery.WebhookPath == "" {
|
||||||
|
cfg.Delivery.WebhookPath = "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Validate(cfg Config) error {
|
func Validate(cfg Config) error {
|
||||||
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`).MatchString(cfg.ClusterID) {
|
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`).MatchString(cfg.ClusterID) {
|
||||||
return errors.New("clusterId must be a lowercase DNS label")
|
return errors.New("clusterId must be a lowercase DNS label")
|
||||||
|
|
@ -252,6 +373,9 @@ func Validate(cfg Config) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := ValidateSecretGrants(cfg.SecretGrants); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if cfg.Templates.TalosRepoURL == "" || cfg.Templates.TalosRepoRef == "" || cfg.Templates.CICDRepoURL == "" || cfg.Templates.CICDRepoRef == "" || cfg.Templates.ManifestsRepoURL == "" || cfg.Templates.ManifestsRepoRef == "" || cfg.Templates.TektonCatalogRepoURL == "" || cfg.Templates.TektonCatalogRepoRef == "" {
|
if cfg.Templates.TalosRepoURL == "" || cfg.Templates.TalosRepoRef == "" || cfg.Templates.CICDRepoURL == "" || cfg.Templates.CICDRepoRef == "" || cfg.Templates.ManifestsRepoURL == "" || cfg.Templates.ManifestsRepoRef == "" || cfg.Templates.TektonCatalogRepoURL == "" || cfg.Templates.TektonCatalogRepoRef == "" {
|
||||||
return errors.New("all template repository URLs and refs are required")
|
return errors.New("all template repository URLs and refs are required")
|
||||||
}
|
}
|
||||||
|
|
@ -373,13 +497,59 @@ func Validate(cfg Config) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateSecretGrants prevents a configuration change from widening an
|
||||||
|
// application's OpenBao policy outside its own path or named shared grants.
|
||||||
|
func ValidateSecretGrants(grants []SecretGrant) error {
|
||||||
|
name := regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, grant := range grants {
|
||||||
|
if !name.MatchString(grant.Application) {
|
||||||
|
return errors.New("secret grant application must be a lowercase DNS label")
|
||||||
|
}
|
||||||
|
if grant.Consumer != "build" && grant.Consumer != "publish" && grant.Consumer != "runtime" {
|
||||||
|
return errors.New("secret grant consumer must be build, publish, or runtime")
|
||||||
|
}
|
||||||
|
if grant.Consumer == "runtime" {
|
||||||
|
if grant.Environment != "staging" && grant.Environment != "production" {
|
||||||
|
return errors.New("runtime secret grant environment must be staging or production")
|
||||||
|
}
|
||||||
|
} else if grant.Environment != "" {
|
||||||
|
return errors.New("build and publish secret grants must not set environment")
|
||||||
|
}
|
||||||
|
key := grant.Application + "/" + grant.Consumer + "/" + grant.Environment
|
||||||
|
if seen[key] {
|
||||||
|
return errors.New("duplicate secret grant consumer")
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
if len(grant.Secrets) == 0 {
|
||||||
|
return errors.New("secret grant requires at least one application secret")
|
||||||
|
}
|
||||||
|
secretNames := map[string]bool{}
|
||||||
|
for _, secret := range grant.Secrets {
|
||||||
|
if !name.MatchString(secret) || secretNames[secret] {
|
||||||
|
return errors.New("secret grant application secret names must be unique lowercase DNS labels")
|
||||||
|
}
|
||||||
|
secretNames[secret] = true
|
||||||
|
}
|
||||||
|
for _, shared := range grant.Shared {
|
||||||
|
if !name.MatchString(shared) {
|
||||||
|
return errors.New("shared secret grant name must be a lowercase DNS label")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateDelivery requires the complete app-delivery contract before rendering or publishing it.
|
// ValidateDelivery requires the complete app-delivery contract before rendering or publishing it.
|
||||||
func ValidateDelivery(cfg Config) error {
|
func ValidateDelivery(cfg Config) error {
|
||||||
if cfg.Delivery.AppName == "" || cfg.Delivery.AppRepoURL == "" || cfg.Delivery.AppRepoRef == "" || cfg.Delivery.ProductionBranch == "" || cfg.Delivery.ImageRepository == "" || cfg.Delivery.BuildOutputDirectory == "" || cfg.Delivery.BuildConfiguration == "" || cfg.Delivery.WebhookHostname == "" || cfg.Delivery.WebhookPath == "" {
|
if cfg.Delivery.AppName == "" || cfg.Delivery.AppRepoURL == "" || cfg.Delivery.AppRepoRef == "" || cfg.Delivery.ProductionBranch == "" || cfg.Delivery.ImageRepository == "" || cfg.Delivery.BuildOutputDirectory == "" || cfg.Delivery.BuildConfiguration == "" || cfg.Delivery.WebhookHostname == "" || cfg.Delivery.WebhookPath == "" {
|
||||||
return errors.New("delivery appName, appRepoUrl, appRepoRef, productionBranch, imageRepository, buildOutputDirectory, buildConfiguration, webhookHostname, and webhookPath are required")
|
return errors.New("delivery appName, appRepoUrl, appRepoRef, productionBranch, imageRepository, buildOutputDirectory, buildConfiguration, webhookHostname, and webhookPath are required")
|
||||||
}
|
}
|
||||||
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[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")
|
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")
|
||||||
|
|
@ -390,6 +560,11 @@ func ValidateDelivery(cfg Config) error {
|
||||||
if err := validateDeliveryRepositoryOrigin(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL); err != nil {
|
if err := validateDeliveryRepositoryOrigin(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if cfg.Delivery.TektonDashboardURL != "" {
|
||||||
|
if err := validateForgejoOrigin(cfg.Delivery.TektonDashboardURL); err != nil {
|
||||||
|
return errors.New("delivery tektonDashboardUrl must be a credential-free HTTPS origin")
|
||||||
|
}
|
||||||
|
}
|
||||||
if strings.ContainsAny(cfg.Delivery.WebhookHostname, "/:@?#") || !strings.HasPrefix(cfg.Delivery.WebhookPath, "/") || strings.ContainsAny(cfg.Delivery.WebhookPath, "?#") {
|
if strings.ContainsAny(cfg.Delivery.WebhookHostname, "/:@?#") || !strings.HasPrefix(cfg.Delivery.WebhookPath, "/") || strings.ContainsAny(cfg.Delivery.WebhookPath, "?#") {
|
||||||
return errors.New("delivery webhookHostname must be a hostname and webhookPath must be an absolute path")
|
return errors.New("delivery webhookHostname must be a hostname and webhookPath must be an absolute path")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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", 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"},
|
||||||
|
|
@ -28,23 +28,73 @@ func validConfig(t *testing.T) Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveAcceptsPlatformOnlyConfig(t *testing.T) {
|
func TestResolveDeliveryStates(t *testing.T) {
|
||||||
|
platformOnly := validConfig(t)
|
||||||
|
platformOnly.Delivery = DeliveryConfig{}
|
||||||
|
partialLegacy := platformOnly
|
||||||
|
partialLegacy.Delivery = DeliveryConfig{AppName: "legacy-app", AppRepoURL: "https://git.example.test/test-org/legacy-app.git", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/legacy-app"}
|
||||||
|
partialLegacy.Talos.AutoBootstrapFlux = true
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
cfg Config
|
||||||
|
configured bool
|
||||||
|
}{
|
||||||
|
{name: "absent", cfg: platformOnly},
|
||||||
|
{name: "partial legacy", cfg: partialLegacy},
|
||||||
|
{name: "complete", cfg: validConfig(t), configured: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
resolved, err := Resolve(test.cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if resolved.Delivery.Configured() != test.configured {
|
||||||
|
t.Fatalf("Configured() = %t, want %t", resolved.Delivery.Configured(), test.configured)
|
||||||
|
}
|
||||||
|
if !test.configured && resolved.Delivery.AppRepoRef != "" {
|
||||||
|
t.Fatal("Resolve() defaulted delivery configuration treated as absent")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDeliveryAppliesDefaultsOnlyForExplicitAppOperations(t *testing.T) {
|
||||||
cfg := validConfig(t)
|
cfg := validConfig(t)
|
||||||
cfg.Delivery.AppName = ""
|
|
||||||
cfg.Delivery.AppRepoURL = ""
|
|
||||||
cfg.Delivery.AppRepoRef = ""
|
cfg.Delivery.AppRepoRef = ""
|
||||||
cfg.Delivery.ProductionBranch = ""
|
|
||||||
cfg.Delivery.ImageRepository = ""
|
|
||||||
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 = ""
|
||||||
resolved, err := Resolve(cfg)
|
|
||||||
|
platform, err := Resolve(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve() rejected platform-only config: %v", err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if resolved.Delivery.Configured() {
|
if platform.Delivery.Configured() {
|
||||||
t.Fatal("Resolve() invented delivery settings for a platform-only config")
|
t.Fatal("platform resolution treated incomplete delivery as configured")
|
||||||
|
}
|
||||||
|
delivery, err := ResolveDelivery(platform)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,6 +106,44 @@ func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateDeliveryRejectsUnsafeTektonDashboardURL(t *testing.T) {
|
||||||
|
cfg := validConfig(t)
|
||||||
|
cfg.Delivery.TektonDashboardURL = "https://token@example.test"
|
||||||
|
if err := ValidateDelivery(cfg); err == nil || !strings.Contains(err.Error(), "tektonDashboardUrl") {
|
||||||
|
t.Fatalf("ValidateDelivery() accepted credential-bearing dashboard URL: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAppOnboardingAllowsOnlyCanonicalCrossOwnerSource(t *testing.T) {
|
||||||
|
cfg := validConfig(t)
|
||||||
|
cfg.Git.Owner = "test-org-2"
|
||||||
|
cfg.Delivery.AppRepoURL = "https://git.example.test/Maidn/maidn-e2e-web.git"
|
||||||
|
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"
|
||||||
|
if resolved, err := ResolveAppOnboarding(cfg); err != nil || resolved.Delivery.WebhookURL() != "https://tekton.example.test/" {
|
||||||
|
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) {
|
||||||
|
cfg := validConfig(t)
|
||||||
|
cfg.Delivery.ProductionBranch = "release"
|
||||||
|
if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "literal production") {
|
||||||
|
t.Fatalf("ResolveAppOnboarding() accepted nonstandard production branch: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateRejectsCredentialBearingDeliveryURLs(t *testing.T) {
|
func TestValidateRejectsCredentialBearingDeliveryURLs(t *testing.T) {
|
||||||
cfg := validConfig(t)
|
cfg := validConfig(t)
|
||||||
cfg.Delivery.AppRepoURL = "https://reader:token@git.example.test/test-org/web-ui.git"
|
cfg.Delivery.AppRepoURL = "https://reader:token@git.example.test/test-org/web-ui.git"
|
||||||
|
|
@ -64,6 +152,23 @@ func TestValidateRejectsCredentialBearingDeliveryURLs(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateSecretGrants(t *testing.T) {
|
||||||
|
if err := ValidateSecretGrants([]SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"rabbitmq"}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, grant := range []SecretGrant{
|
||||||
|
{Application: "orders-api", Consumer: "runtime", Environment: "preview"},
|
||||||
|
{Application: "orders-api", Consumer: "build", Environment: "staging"},
|
||||||
|
{Application: "orders-api", Consumer: "publish"},
|
||||||
|
{Application: "orders-api", Consumer: "publish", Shared: []string{"../platform"}},
|
||||||
|
{Application: "orders-api", Consumer: "publish", Secrets: []string{"database", "database"}},
|
||||||
|
} {
|
||||||
|
if err := ValidateSecretGrants([]SecretGrant{grant}); err == nil {
|
||||||
|
t.Fatalf("invalid secret grant accepted: %#v", grant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateRequiresDeliveryRepositoryOnForgejoOrigin(t *testing.T) {
|
func TestValidateRequiresDeliveryRepositoryOnForgejoOrigin(t *testing.T) {
|
||||||
cfg := validConfig(t)
|
cfg := validConfig(t)
|
||||||
cfg.Delivery.AppRepoURL = "https://attacker.example.test/test-org/web-ui.git"
|
cfg.Delivery.AppRepoURL = "https://attacker.example.test/test-org/web-ui.git"
|
||||||
|
|
@ -89,7 +194,14 @@ func TestValidateRequiresDistinctValidProductionBranch(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveDefaultsWebhookEndpoint(t *testing.T) {
|
func TestResolveDefaultsWebhookEndpoint(t *testing.T) {
|
||||||
cfg, err := Resolve(validConfig(t))
|
input := validConfig(t)
|
||||||
|
input.Delivery.WebhookHostname = ""
|
||||||
|
input.Delivery.WebhookPath = ""
|
||||||
|
platform, err := Resolve(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := ResolveDelivery(platform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -101,6 +213,32 @@ func TestResolveDefaultsWebhookEndpoint(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveFreshBootstrapRequiresAnIsolatedExplicitWorkspace(t *testing.T) {
|
||||||
|
cfg := validConfig(t)
|
||||||
|
cfg.Git.Owner = ""
|
||||||
|
cfg.Git.CloneParent = filepath.Join(cfg.WorkspaceDir, "checkouts")
|
||||||
|
cfg.Flux.TektonCatalogRepo = "catalog"
|
||||||
|
cfg.Templates.TektonCatalogRepoURL = "https://git.example.test/templates/catalog.git"
|
||||||
|
cfg.Templates.TektonCatalogRepoRef = "main"
|
||||||
|
|
||||||
|
resolved, err := ResolveFreshBootstrap(cfg, "new-org", false)
|
||||||
|
if err != nil || resolved.Git.Owner != "new-org" {
|
||||||
|
t.Fatalf("ResolveFreshBootstrap() = (%#v, %v)", resolved.Git.Owner, err)
|
||||||
|
}
|
||||||
|
cfg.Git.CloneParent = cfg.WorkspaceDir
|
||||||
|
if _, err := ResolveFreshBootstrap(cfg, "new-org", false); err == nil || !strings.Contains(err.Error(), "cloneParent") {
|
||||||
|
t.Fatalf("ResolveFreshBootstrap() error = %v, want isolated clone parent error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveFreshBootstrapRejectsConflictingOwner(t *testing.T) {
|
||||||
|
cfg := validConfig(t)
|
||||||
|
cfg.Git.CloneParent = filepath.Join(cfg.WorkspaceDir, "checkouts")
|
||||||
|
if _, err := ResolveFreshBootstrap(cfg, "other-org", false); err == nil || !strings.Contains(err.Error(), "owner") {
|
||||||
|
t.Fatalf("ResolveFreshBootstrap() error = %v, want owner mismatch", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadRejectsUnknownFields(t *testing.T) {
|
func TestLoadRejectsUnknownFields(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
if err := os.WriteFile(path, []byte("workspaceDir: test\nunknown: value\n"), 0600); err != nil {
|
if err := os.WriteFile(path, []byte("workspaceDir: test\nunknown: value\n"), 0600); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,20 @@ type Config struct {
|
||||||
Cilium CiliumConfig `yaml:"cilium"`
|
Cilium CiliumConfig `yaml:"cilium"`
|
||||||
DemocraticCSI DemocraticCSIConfig `yaml:"democraticCsi"`
|
DemocraticCSI DemocraticCSIConfig `yaml:"democraticCsi"`
|
||||||
Delivery DeliveryConfig `yaml:"delivery"`
|
Delivery DeliveryConfig `yaml:"delivery"`
|
||||||
|
SecretGrants []SecretGrant `yaml:"secretGrants,omitempty"`
|
||||||
SOPS SOPSConfig `yaml:"sops"`
|
SOPS SOPSConfig `yaml:"sops"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SecretGrant gives one application consumer access to named application and
|
||||||
|
// shared OpenBao paths. It contains references, never values.
|
||||||
|
type SecretGrant struct {
|
||||||
|
Application string `yaml:"application"`
|
||||||
|
Consumer string `yaml:"consumer"`
|
||||||
|
Environment string `yaml:"environment,omitempty"`
|
||||||
|
Secrets []string `yaml:"secrets"`
|
||||||
|
Shared []string `yaml:"shared,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type DemocraticCSIConfig struct {
|
type DemocraticCSIConfig struct {
|
||||||
TrueNASAPIKey string `yaml:"truenasApiKey"`
|
TrueNASAPIKey string `yaml:"truenasApiKey"`
|
||||||
TrueNASHost string `yaml:"truenasHost"`
|
TrueNASHost string `yaml:"truenasHost"`
|
||||||
|
|
@ -34,15 +45,17 @@ 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"`
|
||||||
WebhookHostname string `yaml:"webhookHostname"`
|
WebhookHostname string `yaml:"webhookHostname"`
|
||||||
WebhookPath string `yaml:"webhookPath"`
|
WebhookPath string `yaml:"webhookPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configured reports whether the configuration contains any app-delivery setting.
|
// Configured reports whether the app-delivery rendering contract is complete.
|
||||||
func (c DeliveryConfig) Configured() bool {
|
func (c DeliveryConfig) Configured() bool {
|
||||||
return c.AppName != "" || c.AppRepoURL != "" || c.AppRepoRef != "" || c.ProductionBranch != "" || c.ImageRepository != "" || c.BuildOutputDirectory != "" || c.BuildConfiguration != "" || c.WebhookHostname != "" || c.WebhookPath != ""
|
return c.AppName != "" && c.AppRepoURL != "" && c.AppRepoRef != "" && c.ProductionBranch != "" && c.ImageRepository != "" && c.BuildOutputDirectory != "" && c.BuildConfiguration != "" && c.WebhookHostname != "" && c.WebhookPath != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c DeliveryConfig) WebhookURL() string {
|
func (c DeliveryConfig) WebhookURL() string {
|
||||||
|
|
|
||||||
360
internal/e2e/e2e.go
Normal file
360
internal/e2e/e2e.go
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
// Package e2e contains read-only delivery verification primitives.
|
||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxResponseBytes = 1 << 20
|
||||||
|
|
||||||
|
var (
|
||||||
|
dnsLabel = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$`)
|
||||||
|
forgejoPart = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||||
|
branchPattern = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Kubeconfig string
|
||||||
|
Context string
|
||||||
|
FluxKustomizations []string
|
||||||
|
ExternalSecret string
|
||||||
|
PipelineRun string
|
||||||
|
PreviewURL string
|
||||||
|
PreviewSentinel string
|
||||||
|
PromotionPullsURL string
|
||||||
|
PromotionOwner string
|
||||||
|
PromotionHead string
|
||||||
|
PromotionToken string
|
||||||
|
Timeout time.Duration
|
||||||
|
Interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Check struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
Checks []Check `json:"checks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command is deliberately small so command boundaries can be faked in tests.
|
||||||
|
type Command interface {
|
||||||
|
Output(context.Context, string, ...string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPDoer interface {
|
||||||
|
Do(*http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Runner struct {
|
||||||
|
Kubectl Command
|
||||||
|
HTTP HTTPDoer
|
||||||
|
}
|
||||||
|
|
||||||
|
type execCommand struct{}
|
||||||
|
|
||||||
|
func (execCommand) Output(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
return exec.CommandContext(ctx, name, args...).Output()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadToken accepts only a reference to a token, never a token flag.
|
||||||
|
func ReadToken(environment, path string) (string, error) {
|
||||||
|
if environment != "" && path != "" {
|
||||||
|
return "", errors.New("use only one promotion token reference")
|
||||||
|
}
|
||||||
|
var token string
|
||||||
|
if environment != "" {
|
||||||
|
var present bool
|
||||||
|
token, present = os.LookupEnv(environment)
|
||||||
|
if !present {
|
||||||
|
return "", errors.New("promotion token environment variable is not set")
|
||||||
|
}
|
||||||
|
} else if path != "" {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("read promotion token file")
|
||||||
|
}
|
||||||
|
token = string(data)
|
||||||
|
} else {
|
||||||
|
return "", errors.New("a promotion token environment or file reference is required")
|
||||||
|
}
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" || strings.ContainsAny(token, "\r\n") {
|
||||||
|
return "", errors.New("promotion token reference is empty or invalid")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Options) Validate() error {
|
||||||
|
if o.Kubeconfig == "" {
|
||||||
|
return errors.New("kubeconfig path is required")
|
||||||
|
}
|
||||||
|
if len(o.FluxKustomizations) == 0 {
|
||||||
|
return errors.New("at least one Flux Kustomization is required")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, resource := range o.FluxKustomizations {
|
||||||
|
if !validNamespacedName(resource) || seen[resource] {
|
||||||
|
return errors.New("Flux Kustomizations must be unique namespace/name identifiers")
|
||||||
|
}
|
||||||
|
seen[resource] = true
|
||||||
|
}
|
||||||
|
for _, resource := range []string{o.ExternalSecret, o.PipelineRun} {
|
||||||
|
if !validNamespacedName(resource) {
|
||||||
|
return errors.New("ExternalSecret and PipelineRun must be namespace/name identifiers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validURL(o.PreviewURL); err != nil {
|
||||||
|
return fmt.Errorf("preview URL: %w", err)
|
||||||
|
}
|
||||||
|
if o.PreviewSentinel == "" {
|
||||||
|
return errors.New("preview sentinel is required")
|
||||||
|
}
|
||||||
|
if err := validURL(o.PromotionPullsURL); err != nil {
|
||||||
|
return fmt.Errorf("promotion pulls URL: %w", err)
|
||||||
|
}
|
||||||
|
if !forgejoPart.MatchString(o.PromotionOwner) || strings.Contains(o.PromotionOwner, "..") {
|
||||||
|
return errors.New("promotion owner is invalid")
|
||||||
|
}
|
||||||
|
if !branchPattern.MatchString(o.PromotionHead) || strings.Contains(o.PromotionHead, "..") || strings.HasPrefix(o.PromotionHead, "/") || strings.HasSuffix(o.PromotionHead, "/") || strings.Contains(o.PromotionHead, "//") {
|
||||||
|
return errors.New("promotion head is invalid")
|
||||||
|
}
|
||||||
|
if o.PromotionToken == "" || strings.ContainsAny(o.PromotionToken, "\r\n") {
|
||||||
|
return errors.New("promotion token is required")
|
||||||
|
}
|
||||||
|
if o.Timeout <= 0 || o.Timeout > 10*time.Minute {
|
||||||
|
return errors.New("timeout must be between zero and ten minutes")
|
||||||
|
}
|
||||||
|
if o.Interval <= 0 || o.Interval > o.Timeout {
|
||||||
|
return errors.New("interval must be positive and no longer than timeout")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validNamespacedName(value string) bool {
|
||||||
|
parts := strings.Split(value, "/")
|
||||||
|
return len(parts) == 2 && dnsLabel.MatchString(parts[0]) && dnsLabel.MatchString(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func validURL(value string) error {
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||||
|
return errors.New("must be a credential-free HTTP(S) URL without query or fragment")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the fixed read-only verification order. It never applies,
|
||||||
|
// reconciles, bootstraps, or writes cluster state.
|
||||||
|
func (r Runner) Run(ctx context.Context, options Options) (Result, error) {
|
||||||
|
if err := options.Validate(); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
if r.Kubectl == nil || r.HTTP == nil {
|
||||||
|
return Result{}, errors.New("e2e runner dependencies are required")
|
||||||
|
}
|
||||||
|
result := Result{Passed: true}
|
||||||
|
result.add("flux_ready", r.wait(ctx, options, func(ctx context.Context) state {
|
||||||
|
for _, resource := range options.FluxKustomizations {
|
||||||
|
if !r.readyCondition(ctx, options, "kustomizations.kustomize.toolkit.fluxcd.io", resource) {
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ready
|
||||||
|
}))
|
||||||
|
result.add("external_secret_ready", r.wait(ctx, options, func(ctx context.Context) state {
|
||||||
|
if r.readyCondition(ctx, options, "externalsecrets.external-secrets.io", options.ExternalSecret) {
|
||||||
|
return ready
|
||||||
|
}
|
||||||
|
return pending
|
||||||
|
}))
|
||||||
|
result.add("pipeline_run_terminal", r.wait(ctx, options, func(ctx context.Context) state {
|
||||||
|
return r.pipelineState(ctx, options)
|
||||||
|
}))
|
||||||
|
result.add("preview_sentinel", r.wait(ctx, options, func(ctx context.Context) state {
|
||||||
|
return r.previewState(ctx, options)
|
||||||
|
}))
|
||||||
|
result.add("promotion_pr_open", r.wait(ctx, options, func(ctx context.Context) state {
|
||||||
|
return r.promotionState(ctx, options)
|
||||||
|
}))
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Result) add(name string, status state) {
|
||||||
|
check := Check{Name: name, Status: "pass", Detail: "ready"}
|
||||||
|
if status == failed {
|
||||||
|
check.Status, check.Detail, r.Passed = "fail", "failed", false
|
||||||
|
}
|
||||||
|
if status == timedOut {
|
||||||
|
check.Status, check.Detail, r.Passed = "fail", "timed_out", false
|
||||||
|
}
|
||||||
|
r.Checks = append(r.Checks, check)
|
||||||
|
}
|
||||||
|
|
||||||
|
type state int
|
||||||
|
|
||||||
|
const (
|
||||||
|
pending state = iota
|
||||||
|
ready
|
||||||
|
failed
|
||||||
|
timedOut
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r Runner) wait(parent context.Context, options Options, probe func(context.Context) state) state {
|
||||||
|
ctx, cancel := context.WithTimeout(parent, options.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
for {
|
||||||
|
if current := probe(ctx); current != pending {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(options.Interval)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if !timer.Stop() {
|
||||||
|
<-timer.C
|
||||||
|
}
|
||||||
|
return timedOut
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Runner) readyCondition(ctx context.Context, options Options, kind, resource string) bool {
|
||||||
|
output, err := r.kubectl(ctx, options, kind, resource)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var value struct {
|
||||||
|
Status struct {
|
||||||
|
Conditions []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"conditions"`
|
||||||
|
} `json:"status"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(output, &value) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, condition := range value.Status.Conditions {
|
||||||
|
if condition.Type == "Ready" && condition.Status == "True" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Runner) pipelineState(ctx context.Context, options Options) state {
|
||||||
|
output, err := r.kubectl(ctx, options, "pipelineruns.tekton.dev", options.PipelineRun)
|
||||||
|
if err != nil {
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
var value struct {
|
||||||
|
Status struct {
|
||||||
|
Conditions []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"conditions"`
|
||||||
|
} `json:"status"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(output, &value) != nil {
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
for _, condition := range value.Status.Conditions {
|
||||||
|
if condition.Type != "Succeeded" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch condition.Status {
|
||||||
|
case "True":
|
||||||
|
return ready
|
||||||
|
case "False":
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Runner) previewState(ctx context.Context, options Options) state {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, options.PreviewURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
response, err := r.HTTP.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes))
|
||||||
|
if err != nil {
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
if strings.Contains(string(body), options.PreviewSentinel) {
|
||||||
|
return ready
|
||||||
|
}
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Runner) promotionState(ctx context.Context, options Options) state {
|
||||||
|
endpoint, err := url.Parse(options.PromotionPullsURL)
|
||||||
|
if err != nil {
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
query := url.Values{"state": {"open"}, "head": {options.PromotionOwner + ":" + options.PromotionHead}}
|
||||||
|
endpoint.RawQuery = query.Encode()
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "token "+options.PromotionToken)
|
||||||
|
response, err := r.HTTP.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
var pulls []struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, maxResponseBytes)).Decode(&pulls); err != nil {
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
if len(pulls) == 0 {
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
if len(pulls) != 1 || pulls[0].State != "open" {
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
return ready
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Runner) kubectl(ctx context.Context, options Options, kind, resource string) ([]byte, error) {
|
||||||
|
namespace, name, _ := strings.Cut(resource, "/")
|
||||||
|
args := []string{"--kubeconfig=" + options.Kubeconfig}
|
||||||
|
if options.Context != "" {
|
||||||
|
args = append(args, "--context="+options.Context)
|
||||||
|
}
|
||||||
|
args = append(args, "--namespace="+namespace, "get", kind, name, "-o=json")
|
||||||
|
return r.Kubectl.Output(ctx, "kubectl", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultRunner() Runner {
|
||||||
|
return Runner{Kubectl: execCommand{}, HTTP: &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||||
|
}
|
||||||
137
internal/e2e/e2e_test.go
Normal file
137
internal/e2e/e2e_test.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeCommand struct {
|
||||||
|
output func(string, []string) ([]byte, error)
|
||||||
|
calls [][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCommand) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
f.calls = append(f.calls, append([]string{name}, args...))
|
||||||
|
return f.output(name, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeHTTP struct {
|
||||||
|
do func(*http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeHTTP) Do(request *http.Request) (*http.Response, error) { return f.do(request) }
|
||||||
|
|
||||||
|
func response(status int, body string) *http.Response {
|
||||||
|
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptions() Options {
|
||||||
|
return Options{
|
||||||
|
Kubeconfig: "/run/secrets/kubeconfig",
|
||||||
|
FluxKustomizations: []string{"flux-system/tekton"},
|
||||||
|
ExternalSecret: "tekton-pipelines/forgejo-webhook",
|
||||||
|
PipelineRun: "tekton-pipelines/delivery-1",
|
||||||
|
PreviewURL: "https://preview.example.test/",
|
||||||
|
PreviewSentinel: "maidn-e2e-ok",
|
||||||
|
PromotionPullsURL: "https://git.example.test/api/v1/repos/Maidn/manifests/pulls",
|
||||||
|
PromotionOwner: "Maidn",
|
||||||
|
PromotionHead: "maidn/promotion-app-0123456789abcdef0123456789abcdef01234567",
|
||||||
|
PromotionToken: "test-token",
|
||||||
|
Timeout: time.Second,
|
||||||
|
Interval: time.Millisecond,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUsesReadOnlyBoundariesAndRedactsResponses(t *testing.T) {
|
||||||
|
kubectl := &fakeCommand{output: func(_ string, args []string) ([]byte, error) {
|
||||||
|
if strings.Contains(strings.Join(args, " "), "pipelineruns.tekton.dev") {
|
||||||
|
return []byte(`{"status":{"conditions":[{"type":"Succeeded","status":"True"}]}}`), nil
|
||||||
|
}
|
||||||
|
return []byte(`{"status":{"conditions":[{"type":"Ready","status":"True"}]},"data":"secret-value"}`), nil
|
||||||
|
}}
|
||||||
|
http := fakeHTTP{do: func(request *http.Request) (*http.Response, error) {
|
||||||
|
if strings.Contains(request.URL.Path, "/pulls") {
|
||||||
|
if request.Header.Get("Authorization") != "token test-token" {
|
||||||
|
t.Fatal("promotion request did not use the supplied token")
|
||||||
|
}
|
||||||
|
if got := request.URL.Query().Get("head"); got != "Maidn:maidn/promotion-app-0123456789abcdef0123456789abcdef01234567" {
|
||||||
|
t.Fatalf("promotion head = %q", got)
|
||||||
|
}
|
||||||
|
return response(http.StatusOK, `[{"state":"open","body":"secret-value"}]`), nil
|
||||||
|
}
|
||||||
|
return response(http.StatusOK, "maidn-e2e-ok secret-value"), nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
result, err := (Runner{Kubectl: kubectl, HTTP: http}).Run(context.Background(), testOptions())
|
||||||
|
if err != nil || !result.Passed || len(result.Checks) != 5 {
|
||||||
|
t.Fatalf("Run() = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(encoded), "secret-value") || strings.Contains(string(encoded), "test-token") {
|
||||||
|
t.Fatalf("result exposed response data: %s", encoded)
|
||||||
|
}
|
||||||
|
for _, call := range kubectl.calls {
|
||||||
|
joined := strings.Join(call, " ")
|
||||||
|
if !strings.Contains(joined, " get ") || strings.Contains(joined, "apply") || strings.Contains(joined, "reconcile") {
|
||||||
|
t.Fatalf("unexpected kubectl invocation: %q", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunReportsTerminalPipelineFailureWithoutWaiting(t *testing.T) {
|
||||||
|
kubectl := &fakeCommand{output: func(_ string, args []string) ([]byte, error) {
|
||||||
|
if strings.Contains(strings.Join(args, " "), "pipelineruns.tekton.dev") {
|
||||||
|
return []byte(`{"status":{"conditions":[{"type":"Succeeded","status":"False"}]}}`), nil
|
||||||
|
}
|
||||||
|
return []byte(`{"status":{"conditions":[{"type":"Ready","status":"True"}]}}`), nil
|
||||||
|
}}
|
||||||
|
http := fakeHTTP{do: func(request *http.Request) (*http.Response, error) {
|
||||||
|
if strings.Contains(request.URL.Path, "/pulls") {
|
||||||
|
return response(http.StatusOK, `[{"state":"open"}]`), nil
|
||||||
|
}
|
||||||
|
return response(http.StatusOK, "maidn-e2e-ok"), nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
result, err := (Runner{Kubectl: kubectl, HTTP: http}).Run(context.Background(), testOptions())
|
||||||
|
if err != nil || result.Passed || result.Checks[2].Detail != "failed" {
|
||||||
|
t.Fatalf("Run() = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTimesOutWhenAReadinessConditionNeverArrives(t *testing.T) {
|
||||||
|
kubectl := &fakeCommand{output: func(_ string, args []string) ([]byte, error) {
|
||||||
|
if strings.Contains(strings.Join(args, " "), "externalsecrets.external-secrets.io") {
|
||||||
|
return []byte(`{"status":{"conditions":[{"type":"Ready","status":"False"}]}}`), nil
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.Join(args, " "), "pipelineruns.tekton.dev") {
|
||||||
|
return []byte(`{"status":{"conditions":[{"type":"Succeeded","status":"True"}]}}`), nil
|
||||||
|
}
|
||||||
|
return []byte(`{"status":{"conditions":[{"type":"Ready","status":"True"}]}}`), nil
|
||||||
|
}}
|
||||||
|
http := fakeHTTP{do: func(request *http.Request) (*http.Response, error) {
|
||||||
|
if strings.Contains(request.URL.Path, "/pulls") {
|
||||||
|
return response(http.StatusOK, `[{"state":"open"}]`), nil
|
||||||
|
}
|
||||||
|
return response(http.StatusOK, "maidn-e2e-ok"), nil
|
||||||
|
}}
|
||||||
|
options := testOptions()
|
||||||
|
options.Timeout, options.Interval = 5*time.Millisecond, time.Millisecond
|
||||||
|
result, err := (Runner{Kubectl: kubectl, HTTP: http}).Run(context.Background(), options)
|
||||||
|
if err != nil || result.Passed || result.Checks[1].Detail != "timed_out" {
|
||||||
|
t.Fatalf("Run() = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadTokenRejectsAmbiguousReferences(t *testing.T) {
|
||||||
|
if _, err := ReadToken("PROMOTION_TOKEN", "token.txt"); err == nil {
|
||||||
|
t.Fatal("ReadToken accepted two token references")
|
||||||
|
}
|
||||||
|
}
|
||||||
232
internal/e2emutate/mutate.go
Normal file
232
internal/e2emutate/mutate.go
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
// Package e2emutate contains narrowly scoped Forgejo fixture mutations.
|
||||||
|
package e2emutate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fixtureOwner = "Maidn"
|
||||||
|
fixturePrefix = "maidn-e2e-"
|
||||||
|
maxBodyBytes = 1 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
forgejoName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||||
|
gitSHA = regexp.MustCompile(`^[0-9a-fA-F]{40}([0-9a-fA-F]{24})?$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options identifies the only Forgejo resources this command may mutate.
|
||||||
|
type Options struct {
|
||||||
|
ForgejoURL string
|
||||||
|
Owner string
|
||||||
|
Repo string
|
||||||
|
Branch string
|
||||||
|
SHA string
|
||||||
|
Token string
|
||||||
|
OpenPR bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPDoer is the Forgejo API boundary and can be faked in tests.
|
||||||
|
type HTTPDoer interface {
|
||||||
|
Do(*http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutator struct {
|
||||||
|
HTTP HTTPDoer
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadToken(environment, path string) (string, error) {
|
||||||
|
if environment != "" && path != "" {
|
||||||
|
return "", errors.New("use only one Forgejo token reference")
|
||||||
|
}
|
||||||
|
var token string
|
||||||
|
if environment != "" {
|
||||||
|
var present bool
|
||||||
|
token, present = os.LookupEnv(environment)
|
||||||
|
if !present {
|
||||||
|
return "", errors.New("Forgejo token environment variable is not set")
|
||||||
|
}
|
||||||
|
} else if path != "" {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("read Forgejo token file")
|
||||||
|
}
|
||||||
|
token = string(data)
|
||||||
|
} else {
|
||||||
|
return "", errors.New("a Forgejo token environment or file reference is required")
|
||||||
|
}
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" || strings.ContainsAny(token, "\r\n") {
|
||||||
|
return "", errors.New("Forgejo token reference is empty or invalid")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Options) Validate() error {
|
||||||
|
if err := validForgejoURL(o.ForgejoURL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if o.Owner != fixtureOwner {
|
||||||
|
return errors.New("Forgejo mutation owner must be Maidn")
|
||||||
|
}
|
||||||
|
for _, value := range []string{o.Repo, o.Branch} {
|
||||||
|
if !fixtureName(value) {
|
||||||
|
return errors.New("Forgejo mutation repository and branch must be maidn-e2e fixture identifiers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gitSHA.MatchString(o.SHA) {
|
||||||
|
return errors.New("Forgejo mutation SHA must be a full Git object ID")
|
||||||
|
}
|
||||||
|
if o.Token == "" || strings.ContainsAny(o.Token, "\r\n") {
|
||||||
|
return errors.New("Forgejo token is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixtureName(value string) bool {
|
||||||
|
return strings.HasPrefix(value, fixturePrefix) && forgejoName.MatchString(value) && !strings.Contains(value, "..") && !strings.HasSuffix(value, ".") && !strings.HasSuffix(value, ".lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validForgejoURL(value string) error {
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||||
|
return errors.New("Forgejo URL must be a credential-free HTTP(S) URL without query or fragment")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run updates one fixture branch and may ensure its single PR to main.
|
||||||
|
func (m Mutator) Run(ctx context.Context, options Options) error {
|
||||||
|
if err := options.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if m.HTTP == nil {
|
||||||
|
return errors.New("Forgejo mutation API is required")
|
||||||
|
}
|
||||||
|
if err := m.updateRef(ctx, options); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if options.OpenPR {
|
||||||
|
return m.ensurePR(ctx, options)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Mutator) updateRef(ctx context.Context, options Options) error {
|
||||||
|
endpoint := options.apiURL("git", "refs", "heads", options.Branch)
|
||||||
|
status, err := m.request(ctx, options, http.MethodPatch, endpoint, struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Force bool `json:"force"`
|
||||||
|
}{SHA: options.SHA})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status >= http.StatusOK && status < http.StatusMultipleChoices {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if status != http.StatusNotFound {
|
||||||
|
return errors.New("Forgejo branch update failed")
|
||||||
|
}
|
||||||
|
status, err = m.request(ctx, options, http.MethodPost, options.apiURL("git", "refs"), struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
}{Ref: "refs/heads/" + options.Branch, SHA: options.SHA})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status < http.StatusOK || status >= http.StatusMultipleChoices {
|
||||||
|
return errors.New("Forgejo branch creation failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Mutator) ensurePR(ctx context.Context, options Options) error {
|
||||||
|
endpoint, _ := url.Parse(options.apiURL("pulls"))
|
||||||
|
endpoint.RawQuery = url.Values{"state": {"open"}, "head": {options.Owner + ":" + options.Branch}}.Encode()
|
||||||
|
request, err := m.newRequest(ctx, options, http.MethodGet, endpoint.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
response, err := m.HTTP.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("Forgejo mutation request failed")
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return errors.New("Forgejo pull request lookup failed")
|
||||||
|
}
|
||||||
|
var pulls []json.RawMessage
|
||||||
|
if json.NewDecoder(io.LimitReader(response.Body, maxBodyBytes)).Decode(&pulls) != nil {
|
||||||
|
return errors.New("Forgejo pull request lookup returned invalid data")
|
||||||
|
}
|
||||||
|
if len(pulls) > 1 {
|
||||||
|
return errors.New("multiple open Forgejo pull requests exist for the fixture branch")
|
||||||
|
}
|
||||||
|
if len(pulls) == 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
status, err := m.request(ctx, options, http.MethodPost, options.apiURL("pulls"), struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Head string `json:"head"`
|
||||||
|
Base string `json:"base"`
|
||||||
|
}{Title: "maidn e2e mutation", Head: options.Branch, Base: "main"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status < http.StatusOK || status >= http.StatusMultipleChoices {
|
||||||
|
return errors.New("Forgejo pull request creation failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Mutator) request(ctx context.Context, options Options, method, endpoint string, body any) (int, error) {
|
||||||
|
request, err := m.newRequest(ctx, options, method, endpoint, body)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
response, err := m.HTTP.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.New("Forgejo mutation request failed")
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
return response.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Mutator) newRequest(ctx context.Context, options Options, method, endpoint string, body any) (*http.Request, error) {
|
||||||
|
var reader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("encode Forgejo mutation request")
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("create Forgejo mutation request")
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "token "+options.Token)
|
||||||
|
if body != nil {
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Options) apiURL(parts ...string) string {
|
||||||
|
return strings.TrimRight(o.ForgejoURL, "/") + "/api/v1/repos/" + o.Owner + "/" + o.Repo + "/" + strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultMutator() Mutator {
|
||||||
|
return Mutator{HTTP: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||||
|
}
|
||||||
138
internal/e2emutate/mutate_test.go
Normal file
138
internal/e2emutate/mutate_test.go
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
package e2emutate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeHTTP struct {
|
||||||
|
do func(*http.Request) (*http.Response, error)
|
||||||
|
calls []*http.Request
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeHTTP) Do(request *http.Request) (*http.Response, error) {
|
||||||
|
f.calls = append(f.calls, request)
|
||||||
|
return f.do(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mutationResponse(status int, body string) *http.Response {
|
||||||
|
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptions() Options {
|
||||||
|
return Options{
|
||||||
|
ForgejoURL: "https://git.example.test",
|
||||||
|
Owner: "Maidn",
|
||||||
|
Repo: "maidn-e2e-repo",
|
||||||
|
Branch: "maidn-e2e-branch",
|
||||||
|
SHA: "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
Token: "test-token",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptionsValidateAcceptsOnlyFixtureTargets(t *testing.T) {
|
||||||
|
if err := testOptions().Validate(); err != nil {
|
||||||
|
t.Fatalf("valid fixture options: %v", err)
|
||||||
|
}
|
||||||
|
for _, update := range []func(*Options){
|
||||||
|
func(o *Options) { o.Owner = "other-org" },
|
||||||
|
func(o *Options) { o.Repo = "production" },
|
||||||
|
func(o *Options) { o.Branch = "feature/maidn-e2e-branch" },
|
||||||
|
func(o *Options) { o.Branch = "maidn-e2e-branch..unsafe" },
|
||||||
|
func(o *Options) { o.Repo = "maidn-e2e-repo.lock" },
|
||||||
|
} {
|
||||||
|
options := testOptions()
|
||||||
|
update(&options)
|
||||||
|
if err := options.Validate(); err == nil {
|
||||||
|
t.Fatalf("Validate accepted %#v", options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMutatorUpdatesFixtureRefAndEnsuresOnePR(t *testing.T) {
|
||||||
|
fake := &fakeHTTP{do: func(request *http.Request) (*http.Response, error) {
|
||||||
|
if request.Header.Get("Authorization") != "token test-token" {
|
||||||
|
t.Fatal("mutation request did not authenticate at the API boundary")
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case request.Method == http.MethodPatch && request.URL.Path == "/api/v1/repos/Maidn/maidn-e2e-repo/git/refs/heads/maidn-e2e-branch":
|
||||||
|
var body struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Force bool `json:"force"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.SHA != testOptions().SHA || body.Force {
|
||||||
|
t.Fatalf("unexpected branch update: %#v, %v", body, err)
|
||||||
|
}
|
||||||
|
return mutationResponse(http.StatusOK, ""), nil
|
||||||
|
case request.Method == http.MethodGet && request.URL.Path == "/api/v1/repos/Maidn/maidn-e2e-repo/pulls":
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
return mutationResponse(http.StatusOK, "[]"), nil
|
||||||
|
case request.Method == http.MethodPost && request.URL.Path == "/api/v1/repos/Maidn/maidn-e2e-repo/pulls":
|
||||||
|
var body struct {
|
||||||
|
Head string `json:"head"`
|
||||||
|
Base string `json:"base"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Head != "maidn-e2e-branch" || body.Base != "main" {
|
||||||
|
t.Fatalf("unexpected pull request creation: %#v, %v", body, err)
|
||||||
|
}
|
||||||
|
return mutationResponse(http.StatusCreated, ""), nil
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected Forgejo request: %s %s", request.Method, request.URL)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options := testOptions()
|
||||||
|
options.OpenPR = true
|
||||||
|
if err := (Mutator{HTTP: fake}).Run(context.Background(), options); err != nil || len(fake.calls) != 3 {
|
||||||
|
t.Fatalf("Run() = %v, calls = %d", err, len(fake.calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMutatorCreatesFixtureRefWhenAbsent(t *testing.T) {
|
||||||
|
fake := &fakeHTTP{do: func(request *http.Request) (*http.Response, error) {
|
||||||
|
switch request.Method {
|
||||||
|
case http.MethodPatch:
|
||||||
|
return mutationResponse(http.StatusNotFound, ""), nil
|
||||||
|
case http.MethodPost:
|
||||||
|
if request.URL.Path != "/api/v1/repos/Maidn/maidn-e2e-repo/git/refs" {
|
||||||
|
t.Fatalf("branch creation targeted %q", request.URL.Path)
|
||||||
|
}
|
||||||
|
return mutationResponse(http.StatusCreated, ""), nil
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected Forgejo request: %s %s", request.Method, request.URL)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
if err := (Mutator{HTTP: fake}).Run(context.Background(), testOptions()); err != nil || len(fake.calls) != 2 {
|
||||||
|
t.Fatalf("Run() = %v, calls = %d", err, len(fake.calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMutatorRejectsUnsafeTargetsBeforeAPIAndDoesNotExposeToken(t *testing.T) {
|
||||||
|
fake := &fakeHTTP{do: func(*http.Request) (*http.Response, error) {
|
||||||
|
t.Fatal("unsafe target reached the Forgejo API")
|
||||||
|
return nil, nil
|
||||||
|
}}
|
||||||
|
options := testOptions()
|
||||||
|
options.Owner = "production"
|
||||||
|
options.Token = "secret-token"
|
||||||
|
err := (Mutator{HTTP: fake}).Run(context.Background(), options)
|
||||||
|
if err == nil || strings.Contains(err.Error(), options.Token) {
|
||||||
|
t.Fatalf("Run() returned unsafe error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fake.do = func(*http.Request) (*http.Response, error) { return nil, errors.New(options.Token) }
|
||||||
|
options = testOptions()
|
||||||
|
options.Token = "secret-token"
|
||||||
|
err = (Mutator{HTTP: fake}).Run(context.Background(), options)
|
||||||
|
if err == nil || strings.Contains(err.Error(), options.Token) {
|
||||||
|
t.Fatalf("Run() exposed token: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,7 @@ type RepoManager struct {
|
||||||
FluxRepoName string
|
FluxRepoName string
|
||||||
Branch string
|
Branch string
|
||||||
MigrationBranch string
|
MigrationBranch string
|
||||||
MigrationPending bool
|
MigrationRepositories []string
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +36,10 @@ type createRepoRequest struct {
|
||||||
DefaultBranch string `json:"default_branch"`
|
DefaultBranch string `json:"default_branch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type createOrganizationRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
type pullRequestRequest struct {
|
type pullRequestRequest struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Head string `json:"head"`
|
Head string `json:"head"`
|
||||||
|
|
@ -53,6 +57,7 @@ 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 {
|
||||||
|
|
@ -92,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)
|
||||||
|
|
@ -117,10 +123,24 @@ func CreateRegistryToken(baseURL, username, password, otp, name string) (string,
|
||||||
}
|
}
|
||||||
|
|
||||||
func createRegistryToken(client *http.Client, baseURL, username, password, otp, name string) (string, error) {
|
func createRegistryToken(client *http.Client, baseURL, username, password, otp, name string) (string, error) {
|
||||||
|
return createToken(client, baseURL, username, password, otp, name, []string{"read:package", "write:package"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDeliveryStatusToken creates the dedicated token used only to publish
|
||||||
|
// commit statuses and pull-request comments.
|
||||||
|
func CreateDeliveryStatusToken(baseURL, username, password, otp string) (string, error) {
|
||||||
|
return createDeliveryStatusToken(&http.Client{Timeout: 15 * time.Second}, baseURL, username, password, otp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDeliveryStatusToken(client *http.Client, baseURL, username, password, otp string) (string, error) {
|
||||||
|
return createToken(client, baseURL, username, password, otp, "maidn-delivery-status", []string{"write:issue", "write:repository"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func createToken(client *http.Client, baseURL, username, password, otp, name string, scopes []string) (string, error) {
|
||||||
if strings.TrimSpace(baseURL) == "" || username == "" || password == "" || strings.TrimSpace(name) == "" {
|
if strings.TrimSpace(baseURL) == "" || username == "" || password == "" || strings.TrimSpace(name) == "" {
|
||||||
return "", fmt.Errorf("Forgejo base URL, username, password, and token name are required")
|
return "", fmt.Errorf("Forgejo base URL, username, password, and token name are required")
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(createTokenRequest{Name: name, Scopes: []string{"read:package", "write:package"}})
|
body, err := json.Marshal(createTokenRequest{Name: name, Scopes: scopes})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -144,10 +164,10 @@ func createRegistryToken(client *http.Client, baseURL, username, password, otp,
|
||||||
}
|
}
|
||||||
var token accessToken
|
var token accessToken
|
||||||
if err := json.NewDecoder(response.Body).Decode(&token); err != nil {
|
if err := json.NewDecoder(response.Body).Decode(&token); err != nil {
|
||||||
return "", fmt.Errorf("parse Forgejo registry token response: %w", err)
|
return "", fmt.Errorf("parse Forgejo token response: %w", err)
|
||||||
}
|
}
|
||||||
if token.SHA1 == "" {
|
if token.SHA1 == "" {
|
||||||
return "", fmt.Errorf("Forgejo did not return a registry token")
|
return "", fmt.Errorf("Forgejo did not return a token")
|
||||||
}
|
}
|
||||||
return token.SHA1, nil
|
return token.SHA1, nil
|
||||||
}
|
}
|
||||||
|
|
@ -159,6 +179,53 @@ func (rm *RepoManager) InitializeAll(createRepo func(string) error, createFlux f
|
||||||
return rm.ensureRepo(rm.FluxRepoName, "Flux CD cluster configurations", createFlux)
|
return rm.ensureRepo(rm.FluxRepoName, "Flux CD cluster configurations", createFlux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureOrganization creates the configured owner only when explicitly allowed.
|
||||||
|
func (rm *RepoManager) EnsureOrganization(create bool) (bool, error) {
|
||||||
|
exists, err := rm.organizationExists()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if !create {
|
||||||
|
return false, errors.New("Forgejo organization does not exist; rerun with --create-organization")
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(createOrganizationRequest{Username: rm.Owner})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/orgs", rm.BaseURL), body)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if status == http.StatusCreated {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if status == http.StatusConflict {
|
||||||
|
exists, err = rm.organizationExists()
|
||||||
|
if err == nil && exists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("unexpected Forgejo organization create status %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rm *RepoManager) organizationExists() (bool, error) {
|
||||||
|
status, err := rm.apiRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/orgs/%s", rm.BaseURL, url.PathEscape(rm.Owner)), nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case http.StatusOK:
|
||||||
|
return true, nil
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("unexpected Forgejo organization lookup status %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error {
|
func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error {
|
||||||
exists, err := rm.repoExists(name)
|
exists, err := rm.repoExists(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -328,7 +395,12 @@ func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool,
|
||||||
if err != nil || !changed || !existing {
|
if err != nil || !changed || !existing {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rm.MigrationPending = true
|
for _, repository := range rm.MigrationRepositories {
|
||||||
|
if repository == repoName {
|
||||||
|
return rm.createMigrationPullRequest(repoName, targetBranch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rm.MigrationRepositories = append(rm.MigrationRepositories, repoName)
|
||||||
return rm.createMigrationPullRequest(repoName, targetBranch)
|
return rm.createMigrationPullRequest(repoName, targetBranch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,12 +420,57 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsurePullRequest creates one pull request or returns the one already open
|
||||||
|
// for the exact head branch. It refuses duplicate or otherwise ambiguous state.
|
||||||
|
func (rm *RepoManager) EnsurePullRequest(repo, title, head, base string) error {
|
||||||
|
if head == "" || base == "" || head == base {
|
||||||
|
return fmt.Errorf("Forgejo pull request head and base must be different non-empty branches")
|
||||||
|
}
|
||||||
|
open, err := rm.HasOpenPullRequest(repo, head)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if open {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(pullRequestRequest{Title: title, Head: head, Base: base})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", rm.BaseURL, rm.Owner, repo), body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status != http.StatusCreated {
|
||||||
|
return fmt.Errorf("unexpected Forgejo pull request status %d", status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasOpenPullRequest reports whether exactly one pull request is open for head.
|
||||||
|
func (rm *RepoManager) HasOpenPullRequest(repo, head string) (bool, error) {
|
||||||
|
values := url.Values{"state": {"open"}, "head": {head}}
|
||||||
|
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode())
|
||||||
|
var pullRequests []pullRequest
|
||||||
|
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &pullRequests)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
return false, fmt.Errorf("unexpected Forgejo pull request lookup status %d", status)
|
||||||
|
}
|
||||||
|
if len(pullRequests) > 1 {
|
||||||
|
return false, fmt.Errorf("multiple open Forgejo pull requests exist for branch %q", head)
|
||||||
|
}
|
||||||
|
return len(pullRequests) == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (rm *RepoManager) MergePullRequest(repo, head string) error {
|
func (rm *RepoManager) MergePullRequest(repo, head string) error {
|
||||||
values := url.Values{"state": {"open"}, "head": {head}}
|
values := url.Values{"state": {"open"}, "head": {head}}
|
||||||
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode())
|
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode())
|
||||||
|
|
@ -392,71 +509,72 @@ 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.
|
// PublishRepositoryPullRequest applies a managed change on a dedicated branch.
|
||||||
func DeliveryBranch(appName, baseBranch string) (string, error) {
|
// An existing branch is accepted only when it has exactly one open pull request.
|
||||||
branch := "maidn/delivery-" + appName
|
func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base string, change func(string) error) (bool, error) {
|
||||||
if appName == "" || branch == baseBranch {
|
if repo == "" || branch == "" || base == "" || branch == base {
|
||||||
return "", fmt.Errorf("delivery branch and configured base branch must differ")
|
return false, errors.New("repository pull request requires distinct non-empty branches")
|
||||||
}
|
}
|
||||||
return branch, nil
|
repoURL := CloneURL(rm.BaseURL, rm.Owner, repo)
|
||||||
}
|
hasBranch, err := hasRemoteBranch(rm, repoURL, branch)
|
||||||
|
|
||||||
// PublishDeliveryBranch generates and commits delivery content in a temporary clone.
|
|
||||||
func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, deliveryBranch string, generate func(string) error) error {
|
|
||||||
if sourceDir == "" || sourceBranch == "" || deliveryBranch == "" || deliveryBranch == rm.Branch {
|
|
||||||
return fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch")
|
|
||||||
}
|
|
||||||
temporary, err := os.MkdirTemp("", "maidn-delivery-*")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
|
||||||
defer os.RemoveAll(temporary)
|
|
||||||
if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := generate(temporary); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cleanupAskPass, environment, err := rm.gitEnvironment()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer cleanupAskPass()
|
|
||||||
if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := runGit(temporary, environment, "add", ".tekton"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
changed, err := gitDiffQuiet(temporary, environment, "--cached")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if changed {
|
|
||||||
for _, args := range [][]string{{"config", "user.name", "Maidn"}, {"config", "user.email", "maidn@free-maidn.com"}, {"commit", "-m", "feat: add Maidn delivery pipeline"}} {
|
|
||||||
if err := runGit(temporary, environment, args...); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if hasBranch {
|
if hasBranch {
|
||||||
if err := runGit(temporary, environment, "fetch", repoURL, "refs/heads/"+deliveryBranch); err != nil {
|
open, err := rm.HasOpenPullRequest(repo, branch)
|
||||||
return err
|
|
||||||
}
|
|
||||||
different, err := gitDiffQuiet(temporary, environment, "HEAD", "FETCH_HEAD")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
if !different {
|
if !open {
|
||||||
return nil
|
return false, fmt.Errorf("Forgejo branch %q exists without exactly one open pull request; refusing to reuse it", branch)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("dedicated delivery branch %q differs from generated content; refusing to overwrite it", deliveryBranch)
|
|
||||||
}
|
}
|
||||||
return rm.PushBranch(temporary, repoURL, deliveryBranch)
|
temporary, err := os.MkdirTemp("", "maidn-registration-*")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(temporary)
|
||||||
|
cleanupAskPass, environment, err := rm.gitEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer cleanupAskPass()
|
||||||
|
checkout := base
|
||||||
|
if hasBranch {
|
||||||
|
checkout = branch
|
||||||
|
}
|
||||||
|
if err := runGit("", environment, "clone", "--branch", checkout, repoURL, temporary); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !hasBranch {
|
||||||
|
if err := runGit(temporary, environment, "checkout", "-B", branch, "origin/"+base); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := change(temporary); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
changed, err := commitAndPush(temporary, repo, branch, environment)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
if !hasBranch {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
open, err := rm.HasOpenPullRequest(repo, branch)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !open {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitDiffQuiet(dir string, environment []string, args ...string) (bool, error) {
|
func gitDiffQuiet(dir string, environment []string, args ...string) (bool, error) {
|
||||||
|
|
@ -489,6 +607,43 @@ func (rm *RepoManager) HasRemoteBranch(repoURL, branch string) (bool, error) {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BranchRevision resolves branch to the checked-out commit that may be published.
|
||||||
|
func BranchRevision(dir, branch string) (string, error) {
|
||||||
|
command := exec.Command("git", "rev-parse", "--verify", branch+"^{commit}")
|
||||||
|
command.Dir = dir
|
||||||
|
revision, err := command.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve source branch %q: %w", branch, err)
|
||||||
|
}
|
||||||
|
if revision = bytes.TrimSpace(revision); len(revision) == 0 {
|
||||||
|
return "", fmt.Errorf("source branch %q has no commit", branch)
|
||||||
|
}
|
||||||
|
return string(revision), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteBranchRevision returns the remote branch commit, or an empty string when absent.
|
||||||
|
func (rm *RepoManager) RemoteBranchRevision(repoURL, branch string) (string, error) {
|
||||||
|
cleanupAskPass, environment, err := rm.gitEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer cleanupAskPass()
|
||||||
|
command := exec.Command("git", "ls-remote", "--refs", repoURL, "refs/heads/"+branch)
|
||||||
|
command.Env = environment
|
||||||
|
output, err := command.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fields := strings.Fields(string(output))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if len(fields) != 2 || fields[1] != "refs/heads/"+branch {
|
||||||
|
return "", fmt.Errorf("unexpected remote ref response for branch %q", branch)
|
||||||
|
}
|
||||||
|
return fields[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
func CurrentBranch(dir string) (string, error) {
|
func CurrentBranch(dir string) (string, error) {
|
||||||
command := exec.Command("git", "branch", "--show-current")
|
command := exec.Command("git", "branch", "--show-current")
|
||||||
command.Dir = dir
|
command.Dir = dir
|
||||||
|
|
@ -564,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})
|
||||||
|
|
@ -594,10 +749,51 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureProtectedBranch disables direct pushes to the configured production branch.
|
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.
|
||||||
|
func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error {
|
||||||
|
if repo == "" || webhookURL == "" || branch == "" {
|
||||||
|
return errors.New("Forgejo repository, webhook URL, and branch are required")
|
||||||
|
}
|
||||||
|
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", rm.BaseURL, rm.Owner, repo)
|
||||||
|
var hooks []hook
|
||||||
|
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &hooks)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
return fmt.Errorf("unexpected Forgejo webhook lookup status %d", status)
|
||||||
|
}
|
||||||
|
var matching []hook
|
||||||
|
for _, candidate := range hooks {
|
||||||
|
if hookURL(candidate) == webhookURL {
|
||||||
|
matching = append(matching, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(matching) != 1 {
|
||||||
|
return fmt.Errorf("expected one Forgejo webhook for URL %q", webhookURL)
|
||||||
|
}
|
||||||
|
values := url.Values{"ref": {branch}}
|
||||||
|
status, err = rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/%d/tests?%s", endpoint, matching[0].ID, values.Encode()), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return fmt.Errorf("unexpected Forgejo webhook test status %d", status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureProtectedBranch disables direct pushes to 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
|
||||||
|
|
@ -615,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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,34 @@ func TestCreateRegistryTokenUsesBasicAuthAndPackageScopes(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateDeliveryStatusTokenUsesOnlyStatusAndCommentScopes(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/users/delivery-bot/tokens" {
|
||||||
|
t.Fatal("unexpected Forgejo delivery-token request")
|
||||||
|
}
|
||||||
|
username, password, ok := request.BasicAuth()
|
||||||
|
if !ok || username != "delivery-bot" || password != "password" || request.Header.Get("X-Forgejo-OTP") != "123456" {
|
||||||
|
t.Fatal("delivery token request did not use the supplied credentials through HTTP authentication")
|
||||||
|
}
|
||||||
|
var body createTokenRequest
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body.Name != "maidn-delivery-status" || !reflect.DeepEqual(body.Scopes, []string{"write:issue", "write:repository"}) {
|
||||||
|
t.Fatalf("delivery token request has unexpected privileges: %#v", body)
|
||||||
|
}
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
writer.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = writer.Write([]byte(`{"sha1":"delivery-token"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
token, err := createDeliveryStatusToken(server.Client(), server.URL, "delivery-bot", "password", "123456")
|
||||||
|
if err != nil || token != "delivery-token" {
|
||||||
|
t.Fatal("CreateDeliveryStatusToken() did not return the Forgejo token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureRepositoryCopyUsesAskPassAndCredentialFreeGitArguments(t *testing.T) {
|
func TestEnsureRepositoryCopyUsesAskPassAndCredentialFreeGitArguments(t *testing.T) {
|
||||||
original := copyGit
|
original := copyGit
|
||||||
t.Cleanup(func() { copyGit = original })
|
t.Cleanup(func() { copyGit = original })
|
||||||
|
|
@ -141,6 +169,40 @@ func TestRepoExistsOnlyCreatesOnNotFound(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnsureOrganizationCreatesOnlyWhenRequested(t *testing.T) {
|
||||||
|
requests := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
requests++
|
||||||
|
switch request.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
if request.URL.Path != "/api/v1/orgs/new-org" {
|
||||||
|
t.Fatalf("unexpected organization lookup %q", request.URL.Path)
|
||||||
|
}
|
||||||
|
writer.WriteHeader(http.StatusNotFound)
|
||||||
|
case http.MethodPost:
|
||||||
|
var body createOrganizationRequest
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Username != "new-org" {
|
||||||
|
t.Fatalf("unexpected organization create request: %#v, %v", body, err)
|
||||||
|
}
|
||||||
|
writer.WriteHeader(http.StatusCreated)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected Forgejo method %q", request.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
manager := NewRepoManager(server.URL, "token", "new-org", "user", "", "", "main", "")
|
||||||
|
manager.HTTPClient = server.Client()
|
||||||
|
if _, err := manager.EnsureOrganization(false); err == nil {
|
||||||
|
t.Fatal("missing organization was accepted without explicit create")
|
||||||
|
}
|
||||||
|
if requests != 1 {
|
||||||
|
t.Fatal("organization lookup performed unexpected remote actions")
|
||||||
|
}
|
||||||
|
if created, err := manager.EnsureOrganization(true); err != nil || !created {
|
||||||
|
t.Fatalf("EnsureOrganization(true) = (%t, %v)", created, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) {
|
func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) {
|
||||||
requests := 0
|
requests := 0
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
|
@ -150,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)
|
||||||
|
|
@ -204,6 +266,28 @@ func TestEnsureWebhookCreatesMissingWebhook(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTriggerWebhookTestUsesManagedHookAndBaseBranch(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.Method + " " + request.URL.Path {
|
||||||
|
case http.MethodGet + " /api/v1/repos/owner/app/hooks":
|
||||||
|
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, URL: "https://tekton.example.test/"}})
|
||||||
|
case http.MethodPost + " /api/v1/repos/owner/app/hooks/7/tests":
|
||||||
|
if request.URL.Query().Get("ref") != "main" || request.Header.Get("Authorization") == "" {
|
||||||
|
t.Fatal("webhook test did not use the managed hook and base branch")
|
||||||
|
}
|
||||||
|
writer.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected Forgejo request %s %s", request.Method, request.URL.String())
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
manager := NewRepoManager(server.URL, "test-token", "owner", "user", "", "", "main", "")
|
||||||
|
manager.HTTPClient = server.Client()
|
||||||
|
if err := manager.TriggerWebhookTest("app", "https://tekton.example.test/", "main"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureProtectedBranchCreatesDirectPushProtection(t *testing.T) {
|
func TestEnsureProtectedBranchCreatesDirectPushProtection(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
if request.URL.Path != "/api/v1/repos/owner/app/branch_protections" {
|
if request.URL.Path != "/api/v1/repos/owner/app/branch_protections" {
|
||||||
|
|
@ -233,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 {
|
||||||
|
|
@ -294,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 {
|
||||||
|
|
@ -320,6 +454,49 @@ 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) {
|
||||||
|
requests := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
requests++
|
||||||
|
switch request.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
if request.URL.Path != "/api/v1/repos/owner/app/pulls" || request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/delivery-app" {
|
||||||
|
t.Fatalf("unexpected pull request lookup: %s", request.URL.String())
|
||||||
|
}
|
||||||
|
_, _ = writer.Write([]byte("[]"))
|
||||||
|
case http.MethodPost:
|
||||||
|
var body pullRequestRequest
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Head != "maidn/delivery-app" || body.Base != "main" {
|
||||||
|
t.Fatalf("unexpected pull request create: %#v, %v", body, err)
|
||||||
|
}
|
||||||
|
writer.WriteHeader(http.StatusCreated)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request method %s", request.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
manager := NewRepoManager(server.URL, "test-token", "owner", "bot", "", "", "main", "")
|
||||||
|
manager.HTTPClient = server.Client()
|
||||||
|
if err := manager.EnsurePullRequest("app", "delivery", "maidn/delivery-app", "main"); err != nil || requests != 2 {
|
||||||
|
t.Fatalf("EnsurePullRequest() = %v, requests = %d", err, requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRepoExistsReturnsFalseOnNotFound(t *testing.T) {
|
func TestRepoExistsReturnsFalseOnNotFound(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
writer.WriteHeader(http.StatusNotFound)
|
writer.WriteHeader(http.StatusNotFound)
|
||||||
|
|
|
||||||
138
internal/openbao/app_secret.go
Normal file
138
internal/openbao/app_secret.go
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
package openbao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var managedSecretPart = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||||
|
|
||||||
|
// ManagedSecretPath limits application-secret operations to the declared app
|
||||||
|
// and shared OpenBao namespaces.
|
||||||
|
func ManagedSecretPath(shared bool, owner, name string) (string, error) {
|
||||||
|
if !managedSecretPart.MatchString(owner) || !managedSecretPart.MatchString(name) {
|
||||||
|
return "", errors.New("application, shared group, and secret names must be lowercase DNS labels")
|
||||||
|
}
|
||||||
|
if shared {
|
||||||
|
return "shared/" + owner + "/" + name, nil
|
||||||
|
}
|
||||||
|
return "apps/" + owner + "/" + name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func managedSecretScope(shared bool, owner string) (string, error) {
|
||||||
|
if !managedSecretPart.MatchString(owner) {
|
||||||
|
return "", errors.New("application and shared group names must be lowercase DNS labels")
|
||||||
|
}
|
||||||
|
if shared {
|
||||||
|
return "shared/" + owner, nil
|
||||||
|
}
|
||||||
|
return "apps/" + owner, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreManagedSecret keeps the token and value on stdin all the way to OpenBao.
|
||||||
|
func StoreManagedSecret(kubeconfig, tokenPath, path string, value []byte) error {
|
||||||
|
if _, err := managedSecretPathParts(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(value) == 0 {
|
||||||
|
return errors.New("secret value must not be empty")
|
||||||
|
}
|
||||||
|
token, err := readRestrictedToken(tokenPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeSecret(kubeconfig, token, path, map[string]string{"value": string(value)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListManagedSecrets returns only secret names from KV metadata.
|
||||||
|
func ListManagedSecrets(kubeconfig, tokenPath string, shared bool, owner string) ([]string, error) {
|
||||||
|
scope, err := managedSecretScope(shared, owner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
token, err := readRestrictedToken(tokenPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
output, err := execInPod(kubeconfig, []byte(token+"\n"), "sh", "-ec", "read -r token\nexport BAO_TOKEN=\"$token\"\nbao kv list -format=json secret/metadata/"+scope)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(strings.ToLower(string(output)), "no value found") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("list OpenBao secret metadata")
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Data struct {
|
||||||
|
Keys []string `json:"keys"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output, &response); err != nil {
|
||||||
|
return nil, errors.New("parse OpenBao secret metadata")
|
||||||
|
}
|
||||||
|
return response.Data.Keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManagedSecretStatus checks KV metadata without reading the secret value.
|
||||||
|
func ManagedSecretStatus(kubeconfig, tokenPath, path string) (bool, error) {
|
||||||
|
if _, err := managedSecretPathParts(path); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
token, err := readRestrictedToken(tokenPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
output, err := execInPod(kubeconfig, []byte(token+"\n"), "sh", "-ec", "read -r token\nexport BAO_TOKEN=\"$token\"\nbao kv metadata get -format=json secret/"+path)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(strings.ToLower(string(output)), "no value found") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.New("read OpenBao secret metadata")
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output, &response); err != nil || len(response.Data) == 0 {
|
||||||
|
return false, errors.New("parse OpenBao secret metadata")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteManagedSecret(kubeconfig, tokenPath, path string) error {
|
||||||
|
if _, err := managedSecretPathParts(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token, err := readRestrictedToken(tokenPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := execInPodMutation(kubeconfig, []byte(token+"\n"), "sh", "-ec", "read -r token\nexport BAO_TOKEN=\"$token\"\nbao kv metadata delete secret/"+path+" >/dev/null"); err != nil {
|
||||||
|
return errors.New("delete OpenBao secret")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readRestrictedToken(path string) (string, error) {
|
||||||
|
if path == "" {
|
||||||
|
return "", errors.New("--token-file is required")
|
||||||
|
}
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("read OpenBao token file")
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(string(contents))
|
||||||
|
if token == "" || strings.ContainsAny(token, " \t\r\n") {
|
||||||
|
return "", errors.New("OpenBao token file must contain one token")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func managedSecretPathParts(path string) ([]string, error) {
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
if len(parts) != 3 || (parts[0] != "apps" && parts[0] != "shared") || !managedSecretPart.MatchString(parts[1]) || !managedSecretPart.MatchString(parts[2]) {
|
||||||
|
return nil, errors.New("invalid managed OpenBao secret path")
|
||||||
|
}
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
122
internal/openbao/app_secret_test.go
Normal file
122
internal/openbao/app_secret_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package openbao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func restrictedTokenFile(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "openbao-token")
|
||||||
|
if err := os.WriteFile(path, []byte("test-restricted-token\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagedSecretPathOnlyAllowsAppAndSharedNamespaces(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
shared bool
|
||||||
|
owner string
|
||||||
|
name string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{false, "orders-api", "publish", "apps/orders-api/publish"},
|
||||||
|
{true, "rabbitmq", "password", "shared/rabbitmq/password"},
|
||||||
|
} {
|
||||||
|
got, err := ManagedSecretPath(test.shared, test.owner, test.name)
|
||||||
|
if err != nil || got != test.want {
|
||||||
|
t.Fatalf("ManagedSecretPath(%t, %q, %q) = %q, %v", test.shared, test.owner, test.name, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := ManagedSecretPath(false, "orders-api", "../root"); err == nil {
|
||||||
|
t.Fatal("ManagedSecretPath accepted an unsafe path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreManagedSecretUsesRestrictedTokenStdinWithoutRecoveryInputs(t *testing.T) {
|
||||||
|
originalDecrypt, originalExec := decryptRecovery, execInPodMutation
|
||||||
|
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
|
||||||
|
decryptRecovery = func(_, _ string) ([]byte, error) {
|
||||||
|
t.Fatal("application secret CRUD must not read recovery material")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
const value = "must-not-appear-in-command"
|
||||||
|
execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) {
|
||||||
|
if string(input) != "test-restricted-token\n"+base64.StdEncoding.EncodeToString([]byte(value))+"\n" {
|
||||||
|
t.Fatal("secret token and value were not framed on stdin")
|
||||||
|
}
|
||||||
|
command := strings.Join(args, " ")
|
||||||
|
if !strings.Contains(command, "bao kv put secret/apps/orders-api/publish value=\"$value0\"") || strings.Contains(command, value) || strings.Contains(command, "test-restricted-token") {
|
||||||
|
t.Fatal("secret command exposed a value or token")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := StoreManagedSecret("kubeconfig", restrictedTokenFile(t), "apps/orders-api/publish", []byte(value)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAndStatusReadMetadataOnly(t *testing.T) {
|
||||||
|
originalDecrypt, originalExec := decryptRecovery, execInPod
|
||||||
|
t.Cleanup(func() { decryptRecovery, execInPod = originalDecrypt, originalExec })
|
||||||
|
decryptRecovery = func(_, _ string) ([]byte, error) {
|
||||||
|
t.Fatal("application secret CRUD must not read recovery material")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
execInPod = func(_ string, input []byte, args ...string) ([]byte, error) {
|
||||||
|
calls++
|
||||||
|
if string(input) != "test-restricted-token\n" || strings.Contains(strings.Join(args, " "), " kv get ") || strings.Contains(strings.Join(args, " "), "test-restricted-token") {
|
||||||
|
t.Fatal("metadata query used an unsafe secret-read boundary")
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.Join(args, " "), "kv list") {
|
||||||
|
return []byte(`{"data":{"keys":["publish"]}}`), nil
|
||||||
|
}
|
||||||
|
return []byte(`{"data":{"created_time":"2026-01-01T00:00:00Z"}}`), nil
|
||||||
|
}
|
||||||
|
tokenPath := restrictedTokenFile(t)
|
||||||
|
values, err := ListManagedSecrets("kubeconfig", tokenPath, false, "orders-api")
|
||||||
|
if err != nil || len(values) != 1 || values[0] != "publish" {
|
||||||
|
t.Fatalf("ListManagedSecrets() = %q, %v", values, err)
|
||||||
|
}
|
||||||
|
present, err := ManagedSecretStatus("kubeconfig", tokenPath, "apps/orders-api/publish")
|
||||||
|
if err != nil || !present || calls != 2 {
|
||||||
|
t.Fatalf("ManagedSecretStatus() = %t, %v; calls = %d", present, err, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteManagedSecretUsesMetadataDelete(t *testing.T) {
|
||||||
|
originalDecrypt, originalExec := decryptRecovery, execInPodMutation
|
||||||
|
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
|
||||||
|
decryptRecovery = func(_, _ string) ([]byte, error) {
|
||||||
|
t.Fatal("application secret CRUD must not read recovery material")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) {
|
||||||
|
if string(input) != "test-restricted-token\n" || !strings.Contains(strings.Join(args, " "), "bao kv metadata delete secret/shared/rabbitmq/password") || strings.Contains(strings.Join(args, " "), "test-restricted-token") {
|
||||||
|
t.Fatal("delete did not use a metadata delete with token on stdin")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := DeleteManagedSecret("kubeconfig", restrictedTokenFile(t), "shared/rabbitmq/password"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreManagedSecretRedactsTokenAndValueFromFailures(t *testing.T) {
|
||||||
|
original := execInPodMutation
|
||||||
|
t.Cleanup(func() { execInPodMutation = original })
|
||||||
|
const value = "must-not-leak-value"
|
||||||
|
execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) {
|
||||||
|
return []byte("test-restricted-token " + value), errors.New("failed")
|
||||||
|
}
|
||||||
|
err := StoreManagedSecret("kubeconfig", restrictedTokenFile(t), "apps/orders-api/publish", []byte(value))
|
||||||
|
if err == nil || strings.Contains(err.Error(), "test-restricted-token") || strings.Contains(err.Error(), value) {
|
||||||
|
t.Fatal("secret operation leaked a token or value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package openbao
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -15,9 +16,15 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type AppSecretIdentityTokens struct {
|
||||||
|
Admin string
|
||||||
|
E2E string
|
||||||
|
}
|
||||||
|
|
||||||
type status struct {
|
type status struct {
|
||||||
Initialized bool `json:"initialized"`
|
Initialized bool `json:"initialized"`
|
||||||
Sealed bool `json:"sealed"`
|
Sealed bool `json:"sealed"`
|
||||||
|
|
@ -34,9 +41,14 @@ type operationalSecrets struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
var decryptRecovery = func(identityPath, bundlePath string) ([]byte, error) {
|
var decryptRecovery = func(identityPath, bundlePath string) ([]byte, error) {
|
||||||
cmd := exec.Command("age", "-d", "-i", identityPath, bundlePath)
|
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "age", "-d", "-i", identityPath, bundlePath)
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
return nil, fmt.Errorf("decrypt OpenBao recovery material timed out after %s", commandTimeout)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("decrypt OpenBao recovery material: %w", err)
|
return nil, fmt.Errorf("decrypt OpenBao recovery material: %w", err)
|
||||||
}
|
}
|
||||||
return output, nil
|
return output, nil
|
||||||
|
|
@ -44,6 +56,10 @@ var decryptRecovery = func(identityPath, bundlePath string) ([]byte, error) {
|
||||||
|
|
||||||
var openBaoStatus = getStatus
|
var openBaoStatus = getStatus
|
||||||
|
|
||||||
|
var commandTimeout = time.Minute
|
||||||
|
|
||||||
|
var openBaoMutationTimeout = 5 * time.Minute
|
||||||
|
|
||||||
func EnsureRecoveryIdentity(identityPath string) (string, error) {
|
func EnsureRecoveryIdentity(identityPath string) (string, error) {
|
||||||
if _, err := os.Stat(identityPath); os.IsNotExist(err) {
|
if _, err := os.Stat(identityPath); os.IsNotExist(err) {
|
||||||
if err := os.MkdirAll(filepath.Dir(identityPath), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(identityPath), 0700); err != nil {
|
||||||
|
|
@ -66,18 +82,22 @@ func EnsureRecoveryIdentity(identityPath string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func Initialize(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, operationalSecretsPath string) (map[string]map[string]string, error) {
|
func Initialize(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, operationalSecretsPath string) (map[string]map[string]string, error) {
|
||||||
|
fmt.Fprintln(os.Stderr, "OpenBao: validate recovery recipient")
|
||||||
if err := validateRecoveryRecipient(recipient, bundlePath); err != nil {
|
if err := validateRecoveryRecipient(recipient, bundlePath); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "OpenBao: wait for pod")
|
||||||
if err := waitForPod(kubeconfig); err != nil {
|
if err := waitForPod(kubeconfig); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "OpenBao: read status")
|
||||||
current, err := getStatus(kubeconfig)
|
current, err := getStatus(kubeconfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var material RecoveryMaterial
|
var material RecoveryMaterial
|
||||||
if !current.Initialized {
|
if !current.Initialized {
|
||||||
|
fmt.Fprintln(os.Stderr, "OpenBao: initialize")
|
||||||
output, err := execInPod(kubeconfig, nil, "bao", "operator", "init", "-format=json")
|
output, err := execInPod(kubeconfig, nil, "bao", "operator", "init", "-format=json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("initialize OpenBao: %w", err)
|
return nil, fmt.Errorf("initialize OpenBao: %w", err)
|
||||||
|
|
@ -90,12 +110,14 @@ func Initialize(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, ope
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
fmt.Fprintln(os.Stderr, "OpenBao: decrypt recovery material")
|
||||||
material, err = ReadRecoveryMaterial(identityPath, bundlePath)
|
material, err = ReadRecoveryMaterial(identityPath, bundlePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if current.Sealed {
|
if current.Sealed {
|
||||||
|
fmt.Fprintln(os.Stderr, "OpenBao: unseal")
|
||||||
if err := unseal(kubeconfig, material); err != nil {
|
if err := unseal(kubeconfig, material); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -104,13 +126,16 @@ func Initialize(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, ope
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create OpenBao Kubernetes token reviewer token: %w", err)
|
return nil, fmt.Errorf("create OpenBao Kubernetes token reviewer token: %w", err)
|
||||||
}
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "OpenBao: configure Kubernetes auth")
|
||||||
if err := configureKubernetesAuth(kubeconfig, material.RootToken, string(bytes.TrimSpace(reviewerToken))); err != nil {
|
if err := configureKubernetesAuth(kubeconfig, material.RootToken, string(bytes.TrimSpace(reviewerToken))); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "OpenBao: seed operational secrets")
|
||||||
secrets, err := seedOperationalSecrets(kubeconfig, material.RootToken, ageKeyPath, operationalSecretsPath)
|
secrets, err := seedOperationalSecrets(kubeconfig, material.RootToken, ageKeyPath, operationalSecretsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "OpenBao: refresh External Secrets")
|
||||||
if err := refreshExternalSecrets(kubeconfig); err != nil {
|
if err := refreshExternalSecrets(kubeconfig); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -118,10 +143,15 @@ func Initialize(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, ope
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedOperationalSecrets(kubeconfig, rootToken, ageKeyPath, path string) (map[string]map[string]string, error) {
|
func seedOperationalSecrets(kubeconfig, rootToken, ageKeyPath, path string) (map[string]map[string]string, error) {
|
||||||
cmd := exec.Command("sops", "--decrypt", "--output-type", "yaml", path)
|
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "sops", "--decrypt", "--output-type", "yaml", path)
|
||||||
cmd.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+ageKeyPath)
|
cmd.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+ageKeyPath)
|
||||||
plaintext, err := cmd.Output()
|
plaintext, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
return nil, fmt.Errorf("decrypt operational SOPS secrets timed out after %s", commandTimeout)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("decrypt operational SOPS secrets: %w", err)
|
return nil, fmt.Errorf("decrypt operational SOPS secrets: %w", err)
|
||||||
}
|
}
|
||||||
var document operationalSecrets
|
var document operationalSecrets
|
||||||
|
|
@ -158,8 +188,14 @@ func validateRecoveryRecipient(recipient, bundlePath string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer os.Remove(probePath)
|
defer os.Remove(probePath)
|
||||||
cmd := exec.Command("age", "-r", recipient, "-o", probePath)
|
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "age", "-r", recipient, "-o", probePath)
|
||||||
|
cmd.Stdin = bytes.NewReader(nil)
|
||||||
if output, err := cmd.CombinedOutput(); err != nil {
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
return fmt.Errorf("validate OpenBao recovery recipient timed out after %s", commandTimeout)
|
||||||
|
}
|
||||||
return fmt.Errorf("validate OpenBao recovery recipient: %w: %s", err, bytes.TrimSpace(output))
|
return fmt.Errorf("validate OpenBao recovery recipient: %w: %s", err, bytes.TrimSpace(output))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -205,7 +241,7 @@ func parseRecoveryMaterial(plaintext []byte) (RecoveryMaterial, error) {
|
||||||
return material, nil
|
return material, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]string) error {
|
func writeSecret(kubeconfig, token, secretPath string, values map[string]string) error {
|
||||||
if !regexp.MustCompile(`^[a-z0-9][a-z0-9/_-]*$`).MatchString(secretPath) || len(values) == 0 {
|
if !regexp.MustCompile(`^[a-z0-9][a-z0-9/_-]*$`).MatchString(secretPath) || len(values) == 0 {
|
||||||
return fmt.Errorf("invalid OpenBao secret path %q", secretPath)
|
return fmt.Errorf("invalid OpenBao secret path %q", secretPath)
|
||||||
}
|
}
|
||||||
|
|
@ -219,7 +255,7 @@ func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]str
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
arguments := make([]string, 0, len(keys))
|
arguments := make([]string, 0, len(keys))
|
||||||
input := strings.Builder{}
|
input := strings.Builder{}
|
||||||
input.WriteString(rootToken)
|
input.WriteString(token)
|
||||||
input.WriteByte('\n')
|
input.WriteByte('\n')
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
arguments = append(arguments, fmt.Sprintf("%s=\"$value%d\"", key, len(arguments)))
|
arguments = append(arguments, fmt.Sprintf("%s=\"$value%d\"", key, len(arguments)))
|
||||||
|
|
@ -232,25 +268,39 @@ func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]str
|
||||||
reads = append(reads, fmt.Sprintf("read -r value%d_b64", index))
|
reads = append(reads, fmt.Sprintf("read -r value%d_b64", index))
|
||||||
decodes = append(decodes, fmt.Sprintf("value%d=$(printf '%%s' \"$value%d_b64\" | base64 -d; printf x)\nvalue%d=${value%d%%x}", index, index, index, index))
|
decodes = append(decodes, fmt.Sprintf("value%d=$(printf '%%s' \"$value%d_b64\" | base64 -d; printf x)\nvalue%d=${value%d%%x}", index, index, index, index))
|
||||||
}
|
}
|
||||||
script := "read -r root_token\n" + strings.Join(reads, "\n") + "\n" + strings.Join(decodes, "\n") + "\nexport BAO_TOKEN=\"$root_token\"\nbao kv put secret/" + secretPath + " " + strings.Join(arguments, " ") + " >/dev/null"
|
script := "read -r token\n" + strings.Join(reads, "\n") + "\n" + strings.Join(decodes, "\n") + "\nexport BAO_TOKEN=\"$token\"\nbao kv put secret/" + secretPath + " " + strings.Join(arguments, " ") + " >/dev/null"
|
||||||
_, err := execInPod(kubeconfig, []byte(input.String()), "sh", "-ec", script)
|
_, err := execInPodMutation(kubeconfig, []byte(input.String()), "sh", "-ec", script)
|
||||||
return err
|
if err != nil {
|
||||||
|
return fmt.Errorf("write OpenBao secret %q", secretPath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForPod(kubeconfig string) error {
|
func waitForPod(kubeconfig string) error {
|
||||||
deadline := time.Now().Add(10 * time.Minute)
|
deadline := time.Now().Add(10 * time.Minute)
|
||||||
|
var lastErr error
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
if _, err := getStatus(kubeconfig); err == nil {
|
if _, err := getStatus(kubeconfig); err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
} else {
|
||||||
|
lastErr = err
|
||||||
}
|
}
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
}
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return fmt.Errorf("OpenBao pod did not become ready: %w", lastErr)
|
||||||
|
}
|
||||||
return fmt.Errorf("OpenBao pod did not become ready")
|
return fmt.Errorf("OpenBao pod did not become ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStatus(kubeconfig string) (status, error) {
|
func getStatus(kubeconfig string) (status, error) {
|
||||||
command := []string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "openbao-0", "--", "bao", "status", "-format=json"}
|
command := []string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "openbao-0", "--", "bao", "status", "-format=json"}
|
||||||
output, err := exec.Command("kubectl", command...).Output()
|
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||||
|
defer cancel()
|
||||||
|
output, err := exec.CommandContext(ctx, "kubectl", command...).Output()
|
||||||
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
return status{}, fmt.Errorf("get OpenBao status timed out after %s", commandTimeout)
|
||||||
|
}
|
||||||
if err != nil && !json.Valid(output) {
|
if err != nil && !json.Valid(output) {
|
||||||
return status{}, fmt.Errorf("get OpenBao status: %w", err)
|
return status{}, fmt.Errorf("get OpenBao status: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -298,44 +348,169 @@ done`
|
||||||
}
|
}
|
||||||
|
|
||||||
func configureKubernetesAuth(kubeconfig, rootToken, reviewerToken string) error {
|
func configureKubernetesAuth(kubeconfig, rootToken, reviewerToken string) error {
|
||||||
const script = `read -r root_token
|
const script = `fail() { printf '%s\n' "$1" >&2; exit 1; }
|
||||||
|
read -r root_token
|
||||||
read -r reviewer_token
|
read -r reviewer_token
|
||||||
export BAO_TOKEN="$root_token"
|
export BAO_TOKEN="$root_token"
|
||||||
bao secrets enable -path=secret kv-v2 >/dev/null 2>&1 || true
|
bao secrets enable -path=secret kv-v2 >/dev/null 2>&1 || true
|
||||||
bao auth enable kubernetes >/dev/null 2>&1 || true
|
bao auth enable kubernetes >/dev/null 2>&1 || true
|
||||||
bao write auth/kubernetes/config token_reviewer_jwt="$reviewer_token" kubernetes_host="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}" kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt >/dev/null
|
bao write auth/kubernetes/config token_reviewer_jwt="$reviewer_token" kubernetes_host="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}" kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt >/dev/null || fail kubernetes-auth-config
|
||||||
cat >/tmp/external-secrets.hcl <<'EOF'
|
cat >/tmp/external-secrets.hcl <<'EOF'
|
||||||
path "secret/data/*" {
|
path "secret/data/platform/*" {
|
||||||
capabilities = ["read"]
|
capabilities = ["read"]
|
||||||
}
|
}
|
||||||
path "secret/metadata/*" {
|
path "secret/data/cicd/*" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
path "secret/metadata/platform/*" {
|
||||||
|
capabilities = ["list", "read"]
|
||||||
|
}
|
||||||
|
path "secret/metadata/cicd/*" {
|
||||||
capabilities = ["list", "read"]
|
capabilities = ["list", "read"]
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
bao policy write external-secrets /tmp/external-secrets.hcl >/dev/null
|
bao policy write external-secrets /tmp/external-secrets.hcl >/dev/null || fail platform-external-secrets-policy
|
||||||
rm -f /tmp/external-secrets.hcl
|
rm -f /tmp/external-secrets.hcl
|
||||||
bao write auth/kubernetes/role/external-secrets bound_service_account_names=external-secrets bound_service_account_namespaces=external-secrets policies=external-secrets ttl=1h >/dev/null`
|
bao write auth/kubernetes/role/external-secrets bound_service_account_names=external-secrets bound_service_account_namespaces=external-secrets policies=external-secrets ttl=1h >/dev/null 2>&1 || fail platform-external-secrets-role`
|
||||||
input := []byte(rootToken + "\n" + reviewerToken + "\n")
|
input := []byte(rootToken + "\n" + reviewerToken + "\n")
|
||||||
_, err := execInPod(kubeconfig, input, "sh", "-ec", script)
|
output, err := execInPodMutation(kubeconfig, input, "sh", "-ec", script)
|
||||||
|
if err != nil {
|
||||||
|
return openBaoMutationError("configure Kubernetes auth", err, output, rootToken, reviewerToken)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureSecretGrants creates only OpenBao policies and Kubernetes auth
|
||||||
|
// roles. GitOps manifests create the matching SecretStores and ExternalSecrets.
|
||||||
|
func ConfigureSecretGrants(kubeconfig, identityPath, bundlePath string, grants []config.SecretGrant) error {
|
||||||
|
if err := config.ValidateSecretGrants(grants); err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
if len(grants) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
material, err := ReadRecoveryMaterial(identityPath, bundlePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var script strings.Builder
|
||||||
|
script.WriteString("read -r root_token\nexport BAO_TOKEN=\"$root_token\"\n")
|
||||||
|
for _, grant := range grants {
|
||||||
|
name := "maidn-" + grant.Application + "-" + grant.Consumer
|
||||||
|
namespace := "tekton-pipelines"
|
||||||
|
if grant.Consumer == "runtime" {
|
||||||
|
name += "-" + grant.Environment
|
||||||
|
namespace = grant.Environment
|
||||||
|
}
|
||||||
|
script.WriteString("cat >/tmp/" + name + ".hcl <<'EOF'\n")
|
||||||
|
for _, secret := range grant.Secrets {
|
||||||
|
script.WriteString("path \"secret/data/apps/" + grant.Application + "/" + secret + "\" {\n capabilities = [\"read\"]\n}\n")
|
||||||
|
}
|
||||||
|
for _, shared := range grant.Shared {
|
||||||
|
script.WriteString("path \"secret/data/shared/" + shared + "/*\" {\n capabilities = [\"read\"]\n}\n")
|
||||||
|
}
|
||||||
|
script.WriteString("EOF\n")
|
||||||
|
script.WriteString("bao policy write " + name + " /tmp/" + name + ".hcl >/dev/null\n")
|
||||||
|
script.WriteString("rm -f /tmp/" + name + ".hcl\n")
|
||||||
|
script.WriteString("bao write auth/kubernetes/role/" + name + " bound_service_account_names=" + name + " bound_service_account_namespaces=" + namespace + " policies=" + name + " ttl=1h >/dev/null\n")
|
||||||
|
}
|
||||||
|
output, err := execInPodMutation(kubeconfig, []byte(material.RootToken+"\n"), "sh", "-ec", script.String())
|
||||||
|
if err != nil {
|
||||||
|
return openBaoMutationError("configure OpenBao secret grants", err, output, material.RootToken)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvisionAppSecretIdentities creates short-lived non-root tokens for secret
|
||||||
|
// administration and one fixture probe. Tokens are returned only to be placed
|
||||||
|
// into encrypted operational state by the caller.
|
||||||
|
func ProvisionAppSecretIdentities(kubeconfig, identityPath, bundlePath, app string) (AppSecretIdentityTokens, error) {
|
||||||
|
if !managedSecretPart.MatchString(app) {
|
||||||
|
return AppSecretIdentityTokens{}, errors.New("E2E application must be a lowercase DNS label")
|
||||||
|
}
|
||||||
|
material, err := ReadRecoveryMaterial(identityPath, bundlePath)
|
||||||
|
if err != nil {
|
||||||
|
return AppSecretIdentityTokens{}, err
|
||||||
|
}
|
||||||
|
script := `read -r root_token
|
||||||
|
export BAO_TOKEN="$root_token"
|
||||||
|
cat >/tmp/maidn-app-secret-admin.hcl <<'EOF'
|
||||||
|
path "secret/data/apps/*" { capabilities = ["create", "update"] }
|
||||||
|
path "secret/metadata/apps/*" { capabilities = ["list", "read", "delete"] }
|
||||||
|
path "secret/data/shared/*" { capabilities = ["create", "update"] }
|
||||||
|
path "secret/metadata/shared/*" { capabilities = ["list", "read", "delete"] }
|
||||||
|
EOF
|
||||||
|
cat >/tmp/maidn-e2e.hcl <<'EOF'
|
||||||
|
path "secret/data/apps/` + app + `/e2e-probe" { capabilities = ["create", "update"] }
|
||||||
|
path "secret/metadata/apps/` + app + `" { capabilities = ["list"] }
|
||||||
|
path "secret/metadata/apps/` + app + `/e2e-probe" { capabilities = ["read", "delete"] }
|
||||||
|
EOF
|
||||||
|
bao policy write maidn-app-secret-admin /tmp/maidn-app-secret-admin.hcl >/dev/null
|
||||||
|
bao policy write maidn-e2e-` + app + ` /tmp/maidn-e2e.hcl >/dev/null
|
||||||
|
bao token create -orphan -policy=maidn-app-secret-admin -ttl=1h -explicit-max-ttl=1h -format=json | base64 | tr -d '\n'; printf '\n'
|
||||||
|
bao token create -orphan -policy=maidn-e2e-` + app + ` -ttl=1h -explicit-max-ttl=1h -format=json | base64 | tr -d '\n'; printf '\n'
|
||||||
|
rm -f /tmp/maidn-app-secret-admin.hcl /tmp/maidn-e2e.hcl`
|
||||||
|
output, err := execInPodMutation(kubeconfig, []byte(material.RootToken+"\n"), "sh", "-ec", script)
|
||||||
|
if err != nil {
|
||||||
|
return AppSecretIdentityTokens{}, openBaoMutationError("provision app-secret identities", err, output, material.RootToken)
|
||||||
|
}
|
||||||
|
var responses []struct {
|
||||||
|
Auth struct {
|
||||||
|
ClientToken string `json:"client_token"`
|
||||||
|
} `json:"auth"`
|
||||||
|
}
|
||||||
|
for _, line := range bytes.Split(bytes.TrimSpace(output), []byte("\n")) {
|
||||||
|
var response struct {
|
||||||
|
Auth struct {
|
||||||
|
ClientToken string `json:"client_token"`
|
||||||
|
} `json:"auth"`
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(string(line))
|
||||||
|
if err != nil || json.Unmarshal(decoded, &response) != nil || response.Auth.ClientToken == "" {
|
||||||
|
return AppSecretIdentityTokens{}, errors.New("parse provisioned app-secret identity")
|
||||||
|
}
|
||||||
|
responses = append(responses, response)
|
||||||
|
}
|
||||||
|
if len(responses) != 2 {
|
||||||
|
return AppSecretIdentityTokens{}, errors.New("provision app-secret identities returned an incomplete result")
|
||||||
|
}
|
||||||
|
return AppSecretIdentityTokens{Admin: responses[0].Auth.ClientToken, E2E: responses[1].Auth.ClientToken}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBaoMutationError(action string, err error, output []byte, sensitive ...string) error {
|
||||||
|
diagnostic := redactOpenBaoDiagnostic(strings.TrimSpace(string(output)), sensitive...)
|
||||||
|
if diagnostic == "" && err != nil {
|
||||||
|
diagnostic = redactOpenBaoDiagnostic(err.Error(), sensitive...)
|
||||||
|
}
|
||||||
|
if diagnostic == "" {
|
||||||
|
return errors.New(action)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: %s", action, diagnostic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactOpenBaoDiagnostic(diagnostic string, sensitive ...string) string {
|
||||||
|
for _, value := range sensitive {
|
||||||
|
if value != "" {
|
||||||
|
diagnostic = strings.ReplaceAll(diagnostic, value, "[REDACTED]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostic
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
_, err = kubectlOutput(kubeconfig, externalSecretRefreshArgs(time.Now().UnixNano())...)
|
timestamp := time.Now().UnixNano()
|
||||||
|
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 ExternalSecrets after OpenBao seed: %w", err)
|
return fmt.Errorf("refresh OpenBao secret store after 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
|
||||||
|
|
@ -350,17 +525,36 @@ func encryptRecovery(recipient, bundlePath string, plaintext []byte) error {
|
||||||
|
|
||||||
var execInPod = func(kubeconfig string, input []byte, args ...string) ([]byte, error) {
|
var execInPod = func(kubeconfig string, input []byte, args ...string) ([]byte, error) {
|
||||||
command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...)
|
command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...)
|
||||||
cmd := exec.Command("kubectl", command...)
|
return commandOutput(input, "kubectl", command...)
|
||||||
cmd.Stdin = bytes.NewReader(input)
|
}
|
||||||
return cmd.CombinedOutput()
|
|
||||||
|
var execInPodMutation = func(kubeconfig string, input []byte, args ...string) ([]byte, error) {
|
||||||
|
command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...)
|
||||||
|
return commandOutputWithTimeout(input, openBaoMutationTimeout, "kubectl", command...)
|
||||||
}
|
}
|
||||||
|
|
||||||
var execInUnsealController = func(kubeconfig, script string) ([]byte, error) {
|
var execInUnsealController = func(kubeconfig, script string) ([]byte, error) {
|
||||||
command := []string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "deployment/openbao-unseal", "--", "sh", "-ec", script}
|
command := []string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "deployment/openbao-unseal", "--", "sh", "-ec", script}
|
||||||
return exec.Command("kubectl", command...).CombinedOutput()
|
return commandOutput(nil, "kubectl", command...)
|
||||||
}
|
}
|
||||||
|
|
||||||
var kubectlOutput = func(kubeconfig string, args ...string) ([]byte, error) {
|
var kubectlOutput = func(kubeconfig string, args ...string) ([]byte, error) {
|
||||||
command := append([]string{"--kubeconfig", kubeconfig}, args...)
|
command := append([]string{"--kubeconfig", kubeconfig}, args...)
|
||||||
return exec.Command("kubectl", command...).Output()
|
return commandOutput(nil, "kubectl", command...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandOutput(input []byte, name string, args ...string) ([]byte, error) {
|
||||||
|
return commandOutputWithTimeout(input, commandTimeout, name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandOutputWithTimeout(input []byte, timeout time.Duration, name string, args ...string) ([]byte, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Stdin = bytes.NewReader(input)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
return output, fmt.Errorf("%s timed out after %s", name, timeout)
|
||||||
|
}
|
||||||
|
return output, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEnsureRecoveryIdentity(t *testing.T) {
|
func TestEnsureRecoveryIdentity(t *testing.T) {
|
||||||
|
|
@ -22,17 +25,20 @@ func TestEnsureRecoveryIdentity(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnsealSubmitsAllSharesAndVerifiesResult(t *testing.T) {
|
func TestUnsealUsesPromptedStdinOnly(t *testing.T) {
|
||||||
originalExec, originalStatus := execInPod, openBaoStatus
|
originalExec, originalStatus := execInPod, openBaoStatus
|
||||||
t.Cleanup(func() { execInPod, openBaoStatus = originalExec, originalStatus })
|
t.Cleanup(func() { execInPod, openBaoStatus = originalExec, originalStatus })
|
||||||
var shares []string
|
calls := 0
|
||||||
execInPod = func(_ string, input []byte, _ ...string) ([]byte, error) {
|
execInPod = func(_ string, input []byte, args ...string) ([]byte, error) {
|
||||||
shares = append(shares, strings.TrimSpace(string(input)))
|
if len(input) == 0 || len(args) != 3 || args[0] != "sh" || args[1] != "-ec" || args[2] != "read -r key; bao operator unseal \"$key\" >/dev/null" {
|
||||||
|
t.Fatal("unseal share was not submitted through prompted stdin")
|
||||||
|
}
|
||||||
|
calls++
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
openBaoStatus = func(string) (status, error) { return status{Initialized: true}, nil }
|
openBaoStatus = func(string) (status, error) { return status{Initialized: true}, nil }
|
||||||
if err := unseal("kubeconfig", RecoveryMaterial{UnsealKeysB64: []string{"share-1", "share-2", "share-3"}, UnsealThreshold: 2}); err != nil || strings.Join(shares, ",") != "share-1,share-2,share-3" {
|
if err := unseal("kubeconfig", RecoveryMaterial{UnsealKeysB64: []string{"share-1", "share-2", "share-3"}, UnsealThreshold: 2}); err != nil || calls != 3 {
|
||||||
t.Fatalf("unseal = shares:%q err:%v", shares, err)
|
t.Fatalf("unseal calls:%d err:%v", calls, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,10 +65,10 @@ func TestUnsealFallsBackToControllerSecret(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteSecretFramesMultilineValues(t *testing.T) {
|
func TestWriteSecretFramesMultilineValues(t *testing.T) {
|
||||||
original := execInPod
|
original := execInPodMutation
|
||||||
t.Cleanup(func() { execInPod = original })
|
t.Cleanup(func() { execInPodMutation = original })
|
||||||
var input, script string
|
var input, script string
|
||||||
execInPod = func(_ string, contents []byte, args ...string) ([]byte, error) {
|
execInPodMutation = func(_ string, contents []byte, args ...string) ([]byte, error) {
|
||||||
input, script = string(contents), args[len(args)-1]
|
input, script = string(contents), args[len(args)-1]
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -72,6 +78,19 @@ func TestWriteSecretFramesMultilineValues(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteSecretRedactsMutationFailure(t *testing.T) {
|
||||||
|
original := execInPodMutation
|
||||||
|
t.Cleanup(func() { execInPodMutation = original })
|
||||||
|
const value = "must-not-leak"
|
||||||
|
execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) {
|
||||||
|
return []byte(value), errors.New(value)
|
||||||
|
}
|
||||||
|
err := writeSecret("kubeconfig", "root-token", "cicd/demo", map[string]string{"password": value})
|
||||||
|
if err == nil || err.Error() != `write OpenBao secret "cicd/demo"` || strings.Contains(err.Error(), value) {
|
||||||
|
t.Fatalf("secret write error leaked a value: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRefreshExternalSecretsIsReadyGatedAndScoped(t *testing.T) {
|
func TestRefreshExternalSecretsIsReadyGatedAndScoped(t *testing.T) {
|
||||||
original := kubectlOutput
|
original := kubectlOutput
|
||||||
t.Cleanup(func() { kubectlOutput = original })
|
t.Cleanup(func() { kubectlOutput = original })
|
||||||
|
|
@ -83,11 +102,114 @@ func TestRefreshExternalSecretsIsReadyGatedAndScoped(t *testing.T) {
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 2 || !strings.Contains(calls[0], "get deployment/external-secrets") || !strings.Contains(calls[1], "annotate externalsecret forgejo-webhook") || strings.Contains(calls[1], "--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 TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) {
|
||||||
|
originalDecrypt, originalExec := decryptRecovery, execInPodMutation
|
||||||
|
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
|
||||||
|
decryptRecovery = func(_, _ string) ([]byte, error) {
|
||||||
|
return []byte(`{"unseal_keys_b64":["share"],"unseal_threshold":1,"root_token":"root"}`), nil
|
||||||
|
}
|
||||||
|
var script string
|
||||||
|
execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) {
|
||||||
|
if string(input) != "root\n" || len(args) != 3 || args[0] != "sh" || args[1] != "-ec" {
|
||||||
|
t.Fatal("secret grant did not use root token through stdin")
|
||||||
|
}
|
||||||
|
script = args[2]
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
grants := []config.SecretGrant{
|
||||||
|
{Application: "orders-api", Consumer: "publish", Secrets: []string{"registry"}, Shared: []string{"artifact-cache"}},
|
||||||
|
{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"rabbitmq"}},
|
||||||
|
}
|
||||||
|
if err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", grants); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`secret/data/apps/orders-api/registry`,
|
||||||
|
`secret/data/apps/orders-api/database`,
|
||||||
|
`secret/data/shared/artifact-cache/*`,
|
||||||
|
`secret/data/shared/rabbitmq/*`,
|
||||||
|
`bound_service_account_names=maidn-orders-api-publish`,
|
||||||
|
`bound_service_account_namespaces=production`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(script, want) {
|
||||||
|
t.Fatalf("secret grant script missing %q: %s", want, script)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(script, `secret/data/*`) {
|
||||||
|
t.Fatal("secret grant widened access to every OpenBao secret")
|
||||||
|
}
|
||||||
|
if strings.Contains(script, `secret/data/apps/orders-api/registry/*`) || strings.Contains(script, `secret/data/apps/orders-api/database/*`) {
|
||||||
|
t.Fatal("secret grant widened access beyond declared application secrets")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigureSecretGrantsPreservesRedactedPolicyDiagnostics(t *testing.T) {
|
||||||
|
originalDecrypt, originalExec := decryptRecovery, execInPodMutation
|
||||||
|
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
|
||||||
|
const rootToken = "must-not-leak"
|
||||||
|
decryptRecovery = func(_, _ string) ([]byte, error) {
|
||||||
|
return []byte(`{"unseal_keys_b64":["share"],"unseal_threshold":1,"root_token":"must-not-leak"}`), nil
|
||||||
|
}
|
||||||
|
execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) {
|
||||||
|
return []byte("policy write denied for " + rootToken), errors.New("exit status 1")
|
||||||
|
}
|
||||||
|
err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", []config.SecretGrant{{Application: "orders-api", Consumer: "publish", Secrets: []string{"registry"}}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "policy write denied") || strings.Contains(err.Error(), rootToken) {
|
||||||
|
t.Fatalf("policy diagnostics were not useful and redacted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvisionAppSecretIdentitiesScopesFixtureWithoutRootLeak(t *testing.T) {
|
||||||
|
originalDecrypt, originalMutation := decryptRecovery, execInPodMutation
|
||||||
|
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalMutation })
|
||||||
|
decryptRecovery = func(string, string) ([]byte, error) {
|
||||||
|
return []byte(`{"root_token":"root-token","unseal_keys_b64":["share"],"unseal_threshold":1}`), nil
|
||||||
|
}
|
||||||
|
execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) {
|
||||||
|
command := strings.Join(args, " ")
|
||||||
|
if string(input) != "root-token\n" || !strings.Contains(command, `secret/data/apps/maidn-e2e-web/e2e-probe`) || strings.Contains(command, `secret/data/apps/maidn-e2e-web/*`) {
|
||||||
|
t.Fatal("fixture identity policy scope is incorrect")
|
||||||
|
}
|
||||||
|
return []byte("eyJhdXRoIjp7ImNsaWVudF90b2tlbiI6ImFkbWluLXRva2VuIn19\neyJhdXRoIjp7ImNsaWVudF90b2tlbiI6ImUyZS10b2tlbiJ9fQ==\n"), nil
|
||||||
|
}
|
||||||
|
tokens, err := ProvisionAppSecretIdentities("kubeconfig", "identity", "bundle", "maidn-e2e-web")
|
||||||
|
if err != nil || tokens.Admin != "admin-token" || tokens.E2E != "e2e-token" {
|
||||||
|
t.Fatalf("ProvisionAppSecretIdentities() = %#v, %v", tokens, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigureKubernetesAuthLimitsPlatformStore(t *testing.T) {
|
||||||
|
original := execInPodMutation
|
||||||
|
t.Cleanup(func() { execInPodMutation = original })
|
||||||
|
var script string
|
||||||
|
execInPodMutation = func(_ string, _ []byte, args ...string) ([]byte, error) {
|
||||||
|
script = args[len(args)-1]
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := configureKubernetesAuth("kubeconfig", "root", "reviewer"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`secret/data/platform/*`, `secret/data/cicd/*`} {
|
||||||
|
if !strings.Contains(script, want) {
|
||||||
|
t.Fatalf("platform policy missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(script, `secret/data/*`) {
|
||||||
|
t.Fatal("platform External Secrets role can read every secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenBaoMutationTimeoutIsSeparateFromProbeTimeout(t *testing.T) {
|
||||||
|
if commandTimeout != time.Minute || openBaoMutationTimeout != 5*time.Minute {
|
||||||
|
t.Fatalf("probe timeout %s, mutation timeout %s", commandTimeout, openBaoMutationTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReadRecoveryMaterialDecryptsAndValidatesBundle(t *testing.T) {
|
func TestReadRecoveryMaterialDecryptsAndValidatesBundle(t *testing.T) {
|
||||||
original := decryptRecovery
|
original := decryptRecovery
|
||||||
t.Cleanup(func() { decryptRecovery = original })
|
t.Cleanup(func() { decryptRecovery = original })
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,17 @@ func PromptForgejoRegistryToken() (password, otp, name string, err error) {
|
||||||
return password, otp, name, nil
|
return password, otp, name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PromptForgejoDeliveryStatusToken collects credentials used only to create
|
||||||
|
// the fixed-purpose delivery-status token.
|
||||||
|
func PromptForgejoDeliveryStatusToken() (password, otp string, err error) {
|
||||||
|
password, err = promptHiddenRequired("Forgejo account password")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
otp, err = promptHiddenOptional("Forgejo OTP (optional)")
|
||||||
|
return password, otp, err
|
||||||
|
}
|
||||||
|
|
||||||
func promptForToken(reader *bufio.Reader, prompt string) (string, error) {
|
func promptForToken(reader *bufio.Reader, prompt string) (string, error) {
|
||||||
token := promptSecret(reader, prompt, "")
|
token := promptSecret(reader, prompt, "")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
|
|
@ -156,6 +167,13 @@ func promptHiddenRequired(prompt string) (string, error) {
|
||||||
|
|
||||||
func promptHiddenOptional(prompt string) (string, error) {
|
func promptHiddenOptional(prompt string) (string, error) {
|
||||||
fmt.Printf("%s: ", prompt)
|
fmt.Printf("%s: ", prompt)
|
||||||
|
if !term.IsTerminal(int(syscall.Stdin)) {
|
||||||
|
value, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
if err != nil && len(value) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(value), nil
|
||||||
|
}
|
||||||
value, err := term.ReadPassword(int(syscall.Stdin))
|
value, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue