Compare commits
No commits in common. "main" and "docs/credential-containment" have entirely different histories.
main
...
docs/crede
|
|
@ -1,16 +0,0 @@
|
||||||
.git
|
|
||||||
.age
|
|
||||||
.recovery
|
|
||||||
maidn-bootstrap*.yaml
|
|
||||||
maidn-workspace
|
|
||||||
*.sops.yaml
|
|
||||||
terraform.tfvars
|
|
||||||
*.tfvars
|
|
||||||
*.tfstate*
|
|
||||||
kubeconfig
|
|
||||||
*.kubeconfig
|
|
||||||
*.kube
|
|
||||||
.kube
|
|
||||||
clusterconfig
|
|
||||||
*.key
|
|
||||||
*.pem
|
|
||||||
52
AGENTS.md
52
AGENTS.md
|
|
@ -1,52 +0,0 @@
|
||||||
# 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
15
Dockerfile
|
|
@ -1,15 +0,0 @@
|
||||||
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"]
|
|
||||||
21
README.md
21
README.md
|
|
@ -1,16 +1,23 @@
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `cicd-tool repo init` is a legacy GitHub workflow and is not used for new Forgejo/GitOps platform onboarding.
|
go mod init github.com/Pingu-Studio/MaidnCLI
|
||||||
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,295 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
187
cmd/bootstrap.go
187
cmd/bootstrap.go
|
|
@ -1,10 +1,7 @@
|
||||||
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"
|
||||||
|
|
@ -24,23 +21,17 @@ 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 bootstrapEnableDelivery bool
|
||||||
var bootstrapDestroyDemocraticCSIStorage bool
|
|
||||||
var bootstrapRefreshTemplateRevisions bool
|
|
||||||
|
|
||||||
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
|
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
|
||||||
var readOperationalSecrets = bootstrap.ReadOperationalSecrets
|
var loadPublishAppConfig = config.Load
|
||||||
var initializeOpenBao = bootstrap.InitializeOpenBao
|
var ensurePublishAppCheckoutClean = forgejo.EnsureCleanCheckout
|
||||||
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.",
|
||||||
|
|
@ -59,41 +50,17 @@ 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(&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")
|
||||||
|
|
@ -111,23 +78,6 @@ 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")
|
||||||
|
|
@ -147,7 +97,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, RefreshWebhookSecret: true}.Run()
|
return bootstrap.Runner{Config: cfg, RegisterWebhook: true}.Run()
|
||||||
}
|
}
|
||||||
if bootstrapInitializeOpenBao {
|
if bootstrapInitializeOpenBao {
|
||||||
if bootstrapConfigPath == "" {
|
if bootstrapConfigPath == "" {
|
||||||
|
|
@ -174,17 +124,67 @@ 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, "")
|
||||||
for _, repository := range []string{cfg.Flux.ManifestsRepo, cfg.Flux.RepoName} {
|
if err := manager.MergePullRequest(cfg.Flux.RepoName, "maidn/bootstrap-"+cfg.ClusterID); err != nil {
|
||||||
open, err := manager.HasOpenPullRequest(repository, "maidn/bootstrap-"+cfg.ClusterID)
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 open {
|
cfg, err = config.ResolveDelivery(cfg)
|
||||||
if err := manager.MergePullRequest(repository, "maidn/bootstrap-"+cfg.ClusterID); err != nil {
|
if 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 {
|
||||||
|
|
@ -254,17 +254,10 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes, RegisterWebhook: bootstrapRegisterWebhook, EnableDelivery: bootstrapEnableDelivery, DestroyDemocraticCSIStorage: bootstrapDestroyDemocraticCSIStorage, RefreshTemplateRevisions: bootstrapRefreshTemplateRevisions}
|
runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes, RegisterWebhook: bootstrapRegisterWebhook, EnableDelivery: bootstrapEnableDelivery}
|
||||||
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)
|
||||||
|
|
@ -279,7 +272,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 := forgejoRegistryTokenCredentials()
|
password, otp, name, err := ui.PromptForgejoRegistryToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -299,59 +292,3 @@ 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,31 +5,9 @@ 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() {
|
||||||
|
|
@ -43,6 +21,31 @@ func TestCreateForgejoRegistryTokenRequiresConfig(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPublishAppRequiresDeliveryConfigBeforeCheckout(t *testing.T) {
|
||||||
|
originalConfigPath, originalPublish := bootstrapConfigPath, bootstrapPublishAppFrom
|
||||||
|
originalLoad, originalClean := loadPublishAppConfig, ensurePublishAppCheckoutClean
|
||||||
|
t.Cleanup(func() {
|
||||||
|
bootstrapConfigPath = originalConfigPath
|
||||||
|
bootstrapPublishAppFrom = originalPublish
|
||||||
|
loadPublishAppConfig = originalLoad
|
||||||
|
ensurePublishAppCheckoutClean = originalClean
|
||||||
|
})
|
||||||
|
bootstrapConfigPath = "test-config.yaml"
|
||||||
|
bootstrapPublishAppFrom = "app-checkout"
|
||||||
|
loadPublishAppConfig = func(string) (config.Config, error) {
|
||||||
|
return config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}}, nil
|
||||||
|
}
|
||||||
|
ensurePublishAppCheckoutClean = func(string) error {
|
||||||
|
t.Fatal("publish inspected the checkout before validating delivery config")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runBootstrap(nil, nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "delivery appName") {
|
||||||
|
t.Fatalf("runBootstrap() error = %v, want incomplete delivery error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSeedForgejoOperationalCredentialsUsesEncryptedUpsertBoundary(t *testing.T) {
|
func TestSeedForgejoOperationalCredentialsUsesEncryptedUpsertBoundary(t *testing.T) {
|
||||||
original := upsertOperationalSecret
|
original := upsertOperationalSecret
|
||||||
t.Cleanup(func() { upsertOperationalSecret = original })
|
t.Cleanup(func() { upsertOperationalSecret = original })
|
||||||
|
|
@ -84,72 +87,3 @@ 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
77
cmd/e2e.go
|
|
@ -1,77 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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
107
cmd/fresh.go
|
|
@ -1,107 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
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,7 +1,6 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
|
@ -11,15 +10,12 @@ 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 {
|
||||||
if !errors.Is(err, errE2EChecks) {
|
fmt.Println(err)
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
}
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
# Delivery Ownership
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
This is the approved target architecture. Central onboarding is available for
|
|
||||||
new applications; existing source-owned registrations remain migration work.
|
|
||||||
|
|
||||||
## Trust Boundary
|
|
||||||
|
|
||||||
Application repositories are build inputs, not GitOps inputs. A developer can
|
|
||||||
change application code on `main`, but cannot change an active Pipeline, Task,
|
|
||||||
runtime secret reference, Helm chart, environment value, or promotion policy.
|
|
||||||
|
|
||||||
| Concern | Owner | Location |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Application code, tests, Dockerfile | Developers | `Maidn/<app>` `main` |
|
|
||||||
| Approved charts and environment values | Platform | `Maidn/<app>` `maidn/platform-<app>` |
|
|
||||||
| Pipeline, Tasks, triggers, runtime access | Platform | Cluster repository |
|
|
||||||
| Image tags, preview ownership, promotion PRs | Platform | Deployment manifests repository |
|
|
||||||
| Secret values and policies | Platform | Private config, SOPS operational state, OpenBao |
|
|
||||||
|
|
||||||
The protected platform branch may be readable by developers, but only platform
|
|
||||||
operators and approved automation may push or merge into it. Flux must track
|
|
||||||
only that branch for chart content. Flux must never track an application `main`
|
|
||||||
branch or `maidn/delivery-*` branch.
|
|
||||||
|
|
||||||
Onboarding requires an existing `maidn/platform-<app>` branch and verifies its
|
|
||||||
no-direct-push protection before it opens the central registration PR. It never
|
|
||||||
seeds a platform branch from developer-controlled `main`.
|
|
||||||
|
|
||||||
## Resource Flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
App[Application main branch\ncode only]
|
|
||||||
Platform[Protected platform branch\nchart and values]
|
|
||||||
Cluster[Cluster repository\nPipeline, Tasks, secret access]
|
|
||||||
Manifests[Manifests repository\nimage tags and promotion]
|
|
||||||
Flux[Flux]
|
|
||||||
Workload[Preview, staging, production]
|
|
||||||
|
|
||||||
App -->|exact source SHA| Cluster
|
|
||||||
Cluster --> Manifests
|
|
||||||
Platform --> Flux
|
|
||||||
Manifests --> Flux
|
|
||||||
Flux --> Workload
|
|
||||||
```
|
|
||||||
|
|
||||||
The central Pipeline clones the application repository at the event SHA only to
|
|
||||||
build an image. It obtains chart content from the protected platform branch and
|
|
||||||
writes only image-tag and promotion state to the manifests repository.
|
|
||||||
|
|
||||||
## Prohibited Application Content
|
|
||||||
|
|
||||||
Application repositories must not contain active delivery control-plane
|
|
||||||
resources:
|
|
||||||
|
|
||||||
- `.tekton/` Pipeline or Task resources
|
|
||||||
- `.maidn/` Flux, RBAC, SecretStore, or ExternalSecret resources
|
|
||||||
- Flux Kustomizations or GitRepositories
|
|
||||||
- Kubernetes Secret values, SOPS identities, OpenBao tokens, or kubeconfigs
|
|
||||||
|
|
||||||
An application `main` branch may retain a chart as a developer proposal, but it
|
|
||||||
has no delivery effect. A platform operator explicitly reviews and copies an
|
|
||||||
approved chart and environment values to `maidn/platform-<app>`.
|
|
||||||
|
|
||||||
## Approval Sequence
|
|
||||||
|
|
||||||
1. A platform operator creates or updates the protected platform branch through
|
|
||||||
a reviewed platform PR.
|
|
||||||
2. A platform operator reviews and merges the central cluster registration that
|
|
||||||
renders the Pipeline, Tasks, protected chart source, and runtime references.
|
|
||||||
3. Flux applies only the central cluster resources and protected chart branch.
|
|
||||||
4. Developers use Forgejo pull requests and pushes to trigger builds; they do
|
|
||||||
not edit deployment control-plane resources.
|
|
||||||
5. Production promotion remains a reviewed PR in the manifests repository.
|
|
||||||
|
|
||||||
## Migration Rules
|
|
||||||
|
|
||||||
Existing source-owned registrations are migrated one application at a time.
|
|
||||||
First establish the protected platform package, then merge the central cluster
|
|
||||||
registration, then verify Flux and delivery. Remove the legacy source
|
|
||||||
GitRepository, Kustomizations, and delivery branch only after the replacement
|
|
||||||
is Ready.
|
|
||||||
|
|
||||||
The canonical fixtures are migration pilots. `Maidn/maidn-e2e-secret` remains
|
|
||||||
disabled until its central replacement is reviewed. Angular and web legacy
|
|
||||||
registrations must follow the same migration path.
|
|
||||||
|
|
||||||
## Command Status
|
|
||||||
|
|
||||||
`cicd-tool bootstrap`, `cicd-tool app`, and `cicd-tool e2e` are the current
|
|
||||||
Forgejo and GitOps command families. `cicd-tool repo init` and `cicd-tool vault
|
|
||||||
create-*` are legacy GitHub/direct-apply paths and are not part of new platform
|
|
||||||
onboarding. They will be deprecated or removed after the central delivery
|
|
||||||
migration.
|
|
||||||
|
|
||||||
## Acceptance
|
|
||||||
|
|
||||||
The architecture is accepted only after a canonical fixture proves all of the
|
|
||||||
following with a real Forgejo event:
|
|
||||||
|
|
||||||
- pull request preview build, route, and delivery feedback
|
|
||||||
- main-to-staging deployment at an immutable image SHA
|
|
||||||
- reviewed production promotion PR
|
|
||||||
- runtime secret and shared database projection without preview credentials
|
|
||||||
- closed-preview cleanup and orphan-cleaner recovery
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
# 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
38
docs/e2e.md
|
|
@ -1,38 +0,0 @@
|
||||||
# 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,86 +23,6 @@ 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:
|
||||||
|
|
@ -114,22 +34,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -21,21 +21,6 @@ Include Forgejo API access, Flux source authentication, webhook authorization, T
|
||||||
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.
|
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.
|
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
|
## 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
|
|
||||||
`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.
|
`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.
|
||||||
|
|
||||||
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.
|
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 and webhook-authorization flows also require an explicit configuration; they are live credential operations and are not offline-safe.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
|
|
@ -20,15 +20,3 @@ The webhook-only path requires a complete delivery contract, an approved configu
|
||||||
- Never pass credential values in CLI arguments, URLs, logs, Git commits, generated config, tickets, or evidence.
|
- 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.
|
- 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.
|
- 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.
|
|
||||||
|
|
||||||
## Automated app-secret identities
|
|
||||||
|
|
||||||
Use `bootstrap --provision-app-secret-identities --e2e-app <app>` to create a
|
|
||||||
short-lived non-root `admin` identity and an exact-path `e2e-probe` identity.
|
|
||||||
Bootstrap reads root recovery material only through its encrypted recovery
|
|
||||||
bundle, writes the generated tokens only into encrypted operational state, and
|
|
||||||
never prints either value. `app secret --identity admin` and
|
|
||||||
`app secret --identity e2e:<app>` create a temporary local token file only for
|
|
||||||
the command lifetime. Root tokens, recovery bundles, and unseal shares are not
|
|
||||||
valid app-secret identities.
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,6 @@ 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
|
||||||
|
|
@ -27,11 +24,9 @@ spec:
|
||||||
- name: environment
|
- name: environment
|
||||||
- name: pr-number
|
- name: pr-number
|
||||||
default: ""
|
default: ""
|
||||||
- name: forgejo-base-url
|
- name: app-url
|
||||||
default: ""
|
default: ""
|
||||||
- name: forgejo-owner
|
- name: app-revision
|
||||||
default: ""
|
|
||||||
- name: manifests-repo
|
|
||||||
default: ""
|
default: ""
|
||||||
steps:
|
steps:
|
||||||
- name: update
|
- name: update
|
||||||
|
|
@ -53,12 +48,10 @@ spec:
|
||||||
value: $(params.environment)
|
value: $(params.environment)
|
||||||
- name: PR_NUMBER
|
- name: PR_NUMBER
|
||||||
value: $(params.pr-number)
|
value: $(params.pr-number)
|
||||||
- name: FORGEJO_BASE_URL
|
- name: APP_URL
|
||||||
value: $(params.forgejo-base-url)
|
value: $(params.app-url)
|
||||||
- name: FORGEJO_OWNER
|
- name: APP_REVISION
|
||||||
value: $(params.forgejo-owner)
|
value: $(params.app-revision)
|
||||||
- name: MANIFESTS_REPO
|
|
||||||
value: $(params.manifests-repo)
|
|
||||||
script: |
|
script: |
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
@ -68,32 +61,18 @@ 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
|
||||||
|
|
@ -119,7 +98,12 @@ 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
|
||||||
|
|
@ -154,20 +138,14 @@ spec:
|
||||||
chart:
|
chart:
|
||||||
spec:
|
spec:
|
||||||
chart: ./charts/$APP_NAME
|
chart: ./charts/$APP_NAME
|
||||||
reconcileStrategy: Revision
|
|
||||||
sourceRef:
|
sourceRef:
|
||||||
kind: GitRepository
|
kind: GitRepository
|
||||||
name: $APP_NAME
|
name: $APP_NAME
|
||||||
namespace: flux-system
|
namespace: flux-system
|
||||||
valuesFiles:
|
|
||||||
- ./charts/$APP_NAME/values.yaml
|
|
||||||
- ./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
|
||||||
|
|
@ -181,108 +159,10 @@ 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 ]; then
|
elif [ "$ENVIRONMENT" = staging ] || [ "$ENVIRONMENT" = production ]; then
|
||||||
app_dir="apps/staging/$APP_NAME"
|
app_dir="apps/$ENVIRONMENT/$APP_NAME"
|
||||||
root=apps/staging/kustomization.yaml
|
[ -f "$app_dir/release.yaml" ] || fail
|
||||||
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
|
||||||
|
|
@ -290,311 +170,7 @@ 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
|
||||||
|
|
@ -621,20 +197,18 @@ spec:
|
||||||
default: {{ quote .ManifestsURL }}
|
default: {{ quote .ManifestsURL }}
|
||||||
- name: manifests-branch
|
- name: manifests-branch
|
||||||
default: {{ quote .ManifestsBranch }}
|
default: {{ quote .ManifestsBranch }}
|
||||||
- name: forgejo-base-url
|
workspaces:
|
||||||
default: {{ quote .ForgejoBaseURL }}
|
- name: source
|
||||||
- name: forgejo-owner
|
taskRunTemplate:
|
||||||
default: {{ quote .ForgejoOwner }}
|
serviceAccountName: tekton-delivery
|
||||||
- name: manifests-repo
|
|
||||||
default: {{ quote .ManifestsRepo }}
|
|
||||||
tasks:
|
tasks:
|
||||||
- name: build-layer
|
- name: build-and-push
|
||||||
when:
|
when:
|
||||||
- input: $(params.event-action)
|
- input: $(params.event-action)
|
||||||
operator: notin
|
operator: notin
|
||||||
values: [closed]
|
values: [closed]
|
||||||
taskRef:
|
taskRef:
|
||||||
name: {{ if eq .BuildStrategy "static" }}maidn-node-static-image{{ else }}maidn-node-runtime-image{{ end }}
|
name: maidn-node-static-image
|
||||||
params:
|
params:
|
||||||
- name: url
|
- name: url
|
||||||
value: {{ quote .AppRepoURL }}
|
value: {{ quote .AppRepoURL }}
|
||||||
|
|
@ -642,14 +216,15 @@ spec:
|
||||||
value: $(params.git-revision)
|
value: $(params.git-revision)
|
||||||
- name: image
|
- name: image
|
||||||
value: $(params.image)
|
value: $(params.image)
|
||||||
{{ if eq .BuildStrategy "static" }}
|
|
||||||
- name: output-directory
|
- name: output-directory
|
||||||
value: {{ quote .BuildOutputDirectory }}
|
value: {{ quote .BuildOutputDirectory }}
|
||||||
- name: build-configuration
|
- name: build-configuration
|
||||||
value: {{ quote .BuildConfiguration }}
|
value: {{ quote .BuildConfiguration }}
|
||||||
{{ end }}
|
workspaces:
|
||||||
|
- name: source
|
||||||
|
workspace: source
|
||||||
- name: update-preview
|
- name: update-preview
|
||||||
runAfter: [build-layer]
|
runAfter: [build-and-push]
|
||||||
when:
|
when:
|
||||||
- input: $(params.event-type)
|
- input: $(params.event-type)
|
||||||
operator: in
|
operator: in
|
||||||
|
|
@ -676,8 +251,12 @@ spec:
|
||||||
value: preview
|
value: preview
|
||||||
- name: pr-number
|
- name: pr-number
|
||||||
value: $(params.pr-number)
|
value: $(params.pr-number)
|
||||||
|
- name: app-url
|
||||||
|
value: {{ quote .AppRepoURL }}
|
||||||
|
- name: app-revision
|
||||||
|
value: $(params.git-revision)
|
||||||
- name: update-staging
|
- name: update-staging
|
||||||
runAfter: [build-layer]
|
runAfter: [build-and-push]
|
||||||
when:
|
when:
|
||||||
- input: $(params.event-type)
|
- input: $(params.event-type)
|
||||||
operator: in
|
operator: in
|
||||||
|
|
@ -702,50 +281,8 @@ 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-layer]
|
runAfter: [build-and-push]
|
||||||
when:
|
when:
|
||||||
- input: $(params.event-type)
|
- input: $(params.event-type)
|
||||||
operator: in
|
operator: in
|
||||||
|
|
@ -770,12 +307,6 @@ 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)
|
||||||
|
|
@ -793,34 +324,3 @@ 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,12 +44,7 @@ type Runner struct {
|
||||||
Mode Mode
|
Mode Mode
|
||||||
ConfirmRebuild bool
|
ConfirmRebuild bool
|
||||||
RegisterWebhook bool
|
RegisterWebhook bool
|
||||||
RefreshWebhookSecret bool
|
|
||||||
EnableDelivery bool
|
EnableDelivery bool
|
||||||
SkipDeliveryScaffolding bool
|
|
||||||
AutoMergeBootstrapMigration bool
|
|
||||||
DestroyDemocraticCSIStorage bool
|
|
||||||
RefreshTemplateRevisions bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type operationalSecrets struct {
|
type operationalSecrets struct {
|
||||||
|
|
@ -58,8 +53,6 @@ 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
|
||||||
|
|
@ -75,8 +68,6 @@ 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
|
||||||
|
|
@ -127,22 +118,11 @@ func terraformNoStateFile(stderr []byte) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
var destroyTalosVMs = func(terraformDir string, environment []string) error {
|
var destroyTalosVMs = func(terraformDir string, environment []string) error {
|
||||||
command := exec.Command("terraform", "destroy", "-auto-approve", "-input=false", "-target=proxmox_virtual_environment_vm.vm")
|
return utils.RunCommandInDirEnv(terraformDir, environment, "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 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
|
||||||
|
|
@ -159,9 +139,6 @@ 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
|
||||||
|
|
@ -174,15 +151,7 @@ func (r Runner) Run() error {
|
||||||
}
|
}
|
||||||
r.Config = resolvedDelivery
|
r.Config = resolvedDelivery
|
||||||
}
|
}
|
||||||
if r.Mode, err = resolveLifecycleMode(r.Mode, r.ConfirmRebuild); err != nil {
|
deliveryConfigured := r.Config.Delivery.Configured()
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
@ -197,12 +166,16 @@ 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 == "" {
|
||||||
|
|
@ -215,15 +188,9 @@ 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)
|
||||||
|
|
@ -245,16 +212,23 @@ 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
|
||||||
}
|
}
|
||||||
if err := copyTemplateBaseComponents(cicdTemplateDir, dir, true); err != nil {
|
} else if err := copyDirExcept(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false, deliveryTemplateBaseComponents); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := copyTemplateBaseComponents(cicdTemplateDir, dir, deliveryConfigured); err != nil {
|
||||||
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
|
||||||
|
|
@ -273,9 +247,11 @@ 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 err := copyAndRenderPlatformDeliveryBases(cicdTemplateDir, dir, r.Config); err != nil {
|
if deliveryConfigured {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
@ -286,7 +262,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, true); err != nil {
|
if err := ensureClusterKustomizations(clusterDir, deliveryConfigured); 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)
|
||||||
|
|
@ -294,8 +270,8 @@ func (r Runner) Run() error {
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := r.reconcileBootstrapMigration(manager); err != nil {
|
if manager.MigrationPending {
|
||||||
return err
|
return errors.New("existing repository migration PR created; merge and rerun bootstrap before infrastructure changes")
|
||||||
}
|
}
|
||||||
if err := r.reconcileCloudflareTunnel(); err != nil {
|
if err := r.reconcileCloudflareTunnel(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -316,11 +292,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -355,39 +326,11 @@ func (r Runner) Run() error {
|
||||||
if err := configureFluxSOPS(generatedDir); err != nil {
|
if err := configureFluxSOPS(generatedDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return r.completeFluxBootstrap(generatedDir)
|
return r.reconcileWebhook(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 {
|
||||||
|
|
@ -416,26 +359,15 @@ func (r Runner) reconcileCloudflareTunnel() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) reconcileWebhook(generatedDir string) error {
|
func (r Runner) reconcileWebhook(generatedDir string) error {
|
||||||
var (
|
operationalSecrets, err := initializeOpenBao(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath)
|
||||||
operationalSecrets 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); err != nil {
|
if err := waitForWebhookTargets(generatedDir, r.Config, authorization); 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 {
|
||||||
|
|
@ -444,26 +376,6 @@ 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,
|
||||||
|
|
@ -483,16 +395,19 @@ func renderCiliumConfig(dir string, cfg config.Config) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderPlatformDeliveryConfig(dir string, cfg config.Config) error {
|
func renderDeliveryConfig(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}", "tekton."+cfg.Flux.ClusterDomain,
|
"${WEBHOOK_HOSTNAME}", cfg.Delivery.WebhookHostname,
|
||||||
"${WEBHOOK_PATH}", "/",
|
"${WEBHOOK_PATH}", cfg.Delivery.WebhookPath,
|
||||||
)
|
)
|
||||||
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() {
|
||||||
|
|
@ -506,43 +421,28 @@ func renderPlatformDeliveryConfig(dir string, cfg config.Config) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyAndRenderPlatformDeliveryBases(templateDir, repoDir string, cfg config.Config) error {
|
func copyAndRenderDeliveryBases(templateDir, repoDir string, cfg config.Config) error {
|
||||||
|
if err := config.ValidateDelivery(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
bases := []string{"gateway", "tekton", "tekton-triggers"}
|
bases := []string{"gateway", "tekton", "tekton-triggers"}
|
||||||
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
|
|
||||||
}
|
|
||||||
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 := renderPlatformDeliveryConfig(baseDir, cfg); err != nil {
|
if err := renderDeliveryConfig(baseDir, cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return writePreviewDeliveryConfig(filepath.Join(repoDir, "base", "tekton"), cfg)
|
dir := filepath.Join(repoDir, "base", "tekton")
|
||||||
|
if err := writePreviewDeliveryConfig(dir, cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return removeDuplicateAppDeliverySource(dir, cfg.Delivery.AppName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writePreviewDeliveryConfig(dir string, cfg config.Config) error {
|
func writePreviewDeliveryConfig(dir string, cfg config.Config) error {
|
||||||
|
|
@ -564,7 +464,6 @@ 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 {
|
||||||
|
|
@ -624,26 +523,69 @@ 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,45}[a-z0-9])?$`)
|
var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[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
|
||||||
|
|
@ -655,10 +597,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,
|
||||||
BuildStrategy: cfg.Delivery.BuildStrategy, BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration,
|
BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration,
|
||||||
ForgejoBaseURL: origin, ForgejoOwner: cfg.Git.Owner, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsRepo: cfg.Flux.ManifestsRepo, ManifestsBranch: cfg.Flux.Branch, ClusterDomain: cfg.Flux.ClusterDomain,
|
ForgejoBaseURL: origin, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsBranch: cfg.Flux.Branch,
|
||||||
}
|
}
|
||||||
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} {
|
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} {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
@ -671,7 +613,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 bytes.ReplaceAll(rendered.Bytes(), []byte("\r\n"), []byte("\n")), nil
|
return rendered.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deliveryRepository(baseURL, repositoryURL string) (string, error) {
|
func deliveryRepository(baseURL, repositoryURL string) (string, error) {
|
||||||
|
|
@ -847,10 +789,10 @@ func writeOpenBaoUnsealSecret(path, recoveryIdentityPath, recoveryBundlePath, ag
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderOpenBaoUnsealSecret(material openbao.RecoveryMaterial) ([]byte, error) {
|
func renderOpenBaoUnsealSecret(material openbao.RecoveryMaterial) ([]byte, error) {
|
||||||
if material.UnsealThreshold < 1 || len(material.UnsealKeysB64) < material.UnsealThreshold {
|
if material.RootToken == "" || 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 := make(map[string]string, len(material.UnsealKeysB64))
|
data := map[string]string{"root-token": material.RootToken}
|
||||||
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")
|
||||||
|
|
@ -981,29 +923,9 @@ 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 {
|
||||||
|
|
@ -1078,22 +1000,38 @@ func renderDemocraticCSISecret(csi config.DemocraticCSIConfig) ([]byte, error) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForWebhookTargets(dir string, _ config.Config) error {
|
func waitForWebhookTargets(dir string, cfg config.Config, authorization string) error {
|
||||||
if err := waitForWebhookExternalSecret(dir); err != nil {
|
if err := waitForWebhookAuthorization(dir, authorization); 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 waitForWebhookExternalSecret(dir string) error {
|
func waitForWebhookAuthorization(dir, authorization 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", "externalsecret/forgejo-webhook", "-o=jsonpath={.status.conditions[0].status}")
|
output, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", "secret/forgejo-webhook", "-o=jsonpath={.data.authorization}")
|
||||||
if err == nil && strings.TrimSpace(string(output)) == "True" {
|
if err == nil {
|
||||||
|
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 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")
|
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")
|
||||||
}
|
}
|
||||||
time.Sleep(webhookTargetPollInterval)
|
time.Sleep(webhookTargetPollInterval)
|
||||||
}
|
}
|
||||||
|
|
@ -1113,6 +1051,9 @@ 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"
|
||||||
}
|
}
|
||||||
|
|
@ -1125,15 +1066,10 @@ 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 {
|
||||||
excluded := generatedTemplateFiles[component]
|
if !includeDelivery && deliveryTemplateBaseComponents[component] {
|
||||||
if component == "tekton" {
|
continue
|
||||||
excluded = make(map[string]bool, len(excluded)+1)
|
|
||||||
for path, skip := range generatedTemplateFiles[component] {
|
|
||||||
excluded[path] = skip
|
|
||||||
}
|
}
|
||||||
excluded["apps"] = true
|
if err := copyDirExcept(filepath.Join(templateDir, "base", component), filepath.Join(repoDir, "base", component), true, generatedTemplateFiles[component]); err != nil {
|
||||||
}
|
|
||||||
if err := copyDirExcept(filepath.Join(templateDir, "base", component), filepath.Join(repoDir, "base", component), true, excluded); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1141,9 +1077,6 @@ 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
|
||||||
|
|
@ -1434,7 +1367,7 @@ func (r Runner) reconcileTerraform(terraformDir string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func terraformReconcileTargets(cfg config.Config) []string {
|
func terraformReconcileTargets(cfg config.Config) []string {
|
||||||
targets := []string{"-target=proxmox_virtual_environment_download_file.talos_iso", "-target=local_file.talconfig"}
|
targets := []string{"-target=proxmox_virtual_environment_download_file.talos_iso"}
|
||||||
if cfg.Talos.Cluster.ManageNetworkBridges {
|
if cfg.Talos.Cluster.ManageNetworkBridges {
|
||||||
targets = append(targets, "-target=proxmox_virtual_environment_network_linux_bridge.cluster_bridge")
|
targets = append(targets, "-target=proxmox_virtual_environment_network_linux_bridge.cluster_bridge")
|
||||||
}
|
}
|
||||||
|
|
@ -1443,20 +1376,9 @@ func terraformReconcileTargets(cfg config.Config) []string {
|
||||||
|
|
||||||
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
for attempt := 0; attempt < 3; attempt++ {
|
return destroyTalosVMs(terraformDir, environment)
|
||||||
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 {
|
func (r Runner) importConfiguredTalosVMs(terraformDir string, environment []string) error {
|
||||||
|
|
@ -1685,42 +1607,6 @@ func EnsureTemplateRevisions(cfg config.Config) error {
|
||||||
return ensureTemplateRevisions(cfg.WorkspaceDir, cfg)
|
return ensureTemplateRevisions(cfg.WorkspaceDir, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshTemplateRevisions replaces an existing template lock with configured ref heads.
|
|
||||||
func RefreshTemplateRevisions(cfg config.Config) error {
|
|
||||||
return refreshTemplateRevisions(cfg.WorkspaceDir, cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshTemplateRevisions(workspace string, cfg config.Config) error {
|
|
||||||
lockPath := filepath.Join(workspace, "maidn-template-revisions.yaml")
|
|
||||||
lock, err := readTemplateRevisionLock(lockPath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return errors.New("template revision lock does not exist; run bootstrap without --refresh-template-revisions first")
|
|
||||||
}
|
|
||||||
return errors.New("template revision lock is invalid")
|
|
||||||
}
|
|
||||||
checkouts := []templateCheckout{
|
|
||||||
{Dir: filepath.Join(workspace, "maidn-cicd-cluster-template"), Repository: cfg.Templates.CICDRepoURL, Ref: cfg.Templates.CICDRepoRef},
|
|
||||||
{Dir: filepath.Join(workspace, "cicd-deployment-manifests-template"), Repository: cfg.Templates.ManifestsRepoURL, Ref: cfg.Templates.ManifestsRepoRef},
|
|
||||||
{Dir: filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName), Repository: cfg.Templates.TalosRepoURL, Ref: cfg.Templates.TalosRepoRef, GeneratedDir: cfg.Talos.GeneratedDir},
|
|
||||||
}
|
|
||||||
revisions := []templateRevision{lock.CICD, lock.Manifests, lock.Talos}
|
|
||||||
for index, checkout := range checkouts {
|
|
||||||
if !sameTemplateSource(revisions[index], checkout) {
|
|
||||||
return errors.New("configured template source or ref differs from its workspace revision lock")
|
|
||||||
}
|
|
||||||
commit, err := checkoutTemplateRevision(cfg, checkout, "")
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("configured template revision cannot be resolved; correct the template source or ref, then rerun bootstrap")
|
|
||||||
}
|
|
||||||
revisions[index].Commit = commit
|
|
||||||
}
|
|
||||||
if err := replaceTemplateRevisionLock(lockPath, templateRevisionLock{Version: 1, CICD: revisions[0], Manifests: revisions[1], Talos: revisions[2]}); err != nil {
|
|
||||||
return errors.New("cannot update template revision lock")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readTemplateRevisionLock(path string) (templateRevisionLock, error) {
|
func readTemplateRevisionLock(path string) (templateRevisionLock, error) {
|
||||||
var lock templateRevisionLock
|
var lock templateRevisionLock
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
|
|
@ -1757,27 +1643,6 @@ 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,7 +4,6 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -18,7 +17,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -41,23 +39,6 @@ func TestRenderCiliumConfig(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitializeOpenBaoConfiguresDeclaredSecretGrants(t *testing.T) {
|
|
||||||
originalInitialize, originalGrants := initializeOpenBao, configureOpenBaoSecretGrants
|
|
||||||
t.Cleanup(func() { initializeOpenBao, configureOpenBaoSecretGrants = originalInitialize, originalGrants })
|
|
||||||
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) {
|
func TestCiliumChartVersion(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "release.yaml")
|
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 {
|
if err := os.WriteFile(path, []byte("spec:\n chart:\n spec:\n version: test-version\n"), 0644); err != nil {
|
||||||
|
|
@ -69,22 +50,22 @@ func TestCiliumChartVersion(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderPlatformDeliveryConfig(t *testing.T) {
|
func TestRenderDeliveryConfig(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "webhook.yaml")
|
path := filepath.Join(dir, "webhook.yaml")
|
||||||
if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\nowner: ${GIT_OWNER}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil {
|
if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\nproduction: ${PRODUCTION_BRANCH}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{ClusterDomain: "example.test", TektonCatalogRepo: "my-tekton-catalog"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}}
|
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog"}, Delivery: config.DeliveryConfig{ProductionBranch: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}}
|
||||||
if err := renderPlatformDeliveryConfig(dir, cfg); err != nil {
|
if err := renderDeliveryConfig(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), "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") {
|
if strings.Contains(string(content), "${") || !strings.Contains(string(content), "/hooks/forgejo") || !strings.Contains(string(content), "production: production") || !strings.Contains(string(content), "https://git.example.test/user-org/my-tekton-catalog.git") || !strings.Contains(string(content), "ref: release") {
|
||||||
t.Fatalf("platform delivery configuration was not rendered: %s", content)
|
t.Fatalf("delivery configuration was not rendered: %s", content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,92 +89,34 @@ func TestRemoveDuplicateAppDeliverySource(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(t *testing.T) {
|
func TestGeneratedDeliveryIsGenericAndUsesSafePreviewCleanupContract(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", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
|
Flux: config.FluxConfig{Branch: "main", 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/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", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"},
|
||||||
}
|
}
|
||||||
content, err := renderAppDelivery(cfg)
|
content, err := renderAppDelivery(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, expected := range []string{"name: build-layer", "maidn-node-static-image", "name: url\n value: \"https://git.example.test/apps/web-ui.git\"", "name: revision\n value: $(params.git-revision)", "name: image\n value: $(params.image)", "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]"} {
|
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]"} {
|
||||||
if !strings.Contains(string(content), expected) {
|
if !strings.Contains(string(content), expected) {
|
||||||
t.Fatalf("generated delivery does not contain %q", expected)
|
t.Fatalf("generated delivery does not contain %q", expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, expected := range []string{"reconcileStrategy: Revision", "valuesFiles:\n - ./charts/$APP_NAME/values.yaml\n - ./preview/values.yaml", "valuesFiles:\n - ./charts/$APP_NAME/values.yaml\n - ./staging/values.yaml", "valuesFiles:\n - ./charts/$APP_NAME/values.yaml\n - ./production/values.yaml", "hostname: $APP_NAME-pr-$PR_NUMBER.example.test"} {
|
if strings.Contains(string(content), "easycsr") || strings.Contains(string(content), "test-org") || strings.Contains(string(content), "git rm -r") {
|
||||||
if !strings.Contains(string(content), expected) {
|
t.Fatal("generated delivery contains a non-generic or unsafe literal")
|
||||||
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 TestGeneratedDeliveryUsesRuntimeImageWithoutStaticParameters(t *testing.T) {
|
|
||||||
cfg := config.Config{
|
|
||||||
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
|
|
||||||
Flux: config.FluxConfig{Branch: "main", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
|
|
||||||
Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "runtime", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"},
|
|
||||||
}
|
|
||||||
content, err := renderAppDelivery(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, expected := range []string{"maidn-node-runtime-image", "name: url\n value: \"https://git.example.test/apps/web-ui.git\"", "name: revision\n value: $(params.git-revision)", "name: image\n value: $(params.image)"} {
|
|
||||||
if !strings.Contains(string(content), expected) {
|
|
||||||
t.Fatalf("generated runtime delivery does not contain %q", expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, unexpected := range []string{"maidn-node-static-image", "name: output-directory", "name: build-configuration"} {
|
|
||||||
if strings.Contains(string(content), unexpected) {
|
|
||||||
t.Fatalf("generated runtime delivery contains static-only %q", unexpected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.T) {
|
|
||||||
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) {
|
func TestGenerateAppDeliveryRequiresCompleteConfig(t *testing.T) {
|
||||||
_, err := renderAppDelivery(config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}})
|
dir := t.TempDir()
|
||||||
if err == nil || !strings.Contains(err.Error(), "delivery buildStrategy") {
|
err := GenerateAppDelivery(dir, config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}})
|
||||||
t.Fatalf("renderAppDelivery() error = %v, want incomplete delivery error", err)
|
if err == nil || !strings.Contains(err.Error(), "delivery appName") {
|
||||||
|
t.Fatalf("GenerateAppDelivery() error = %v, want incomplete delivery error", err)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(dir, ".tekton")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatal("GenerateAppDelivery() wrote delivery files before rejecting incomplete config")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,12 +125,12 @@ 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", TektonDashboardURL: "https://tekton.example.test"}}
|
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"}}
|
||||||
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, "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), "tekton-dashboard-url: https://tekton.example.test") || 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), "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) {
|
if _, err := os.Stat(filepath.Join(dir, "maidn-preview-delivery-config.yaml")); !os.IsNotExist(err) {
|
||||||
|
|
@ -230,15 +153,15 @@ func TestWritePreviewDeliveryConfigRejectsAmbiguousKustomization(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCopyAndRenderPlatformDeliveryBasesOverwritesExistingMigrationOutput(t *testing.T) {
|
func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(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: /\n"},
|
{"gateway", "route.yaml", "host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\n", "host: tekton.example.test\npath: /hooks/forgejo\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", "owner: ${FORGEJO_OWNER}\nhost: ${WEBHOOK_HOSTNAME}\n", "owner: user-org\nhost: tekton.example.test\n"},
|
{"tekton-triggers", "trigger.yaml", "app: ${APP_NAME}\nrepo: ${APP_REPO_URL}\n", "app: demo\nrepo: https://git.example.test/demo.git\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)
|
||||||
|
|
@ -259,30 +182,18 @@ func TestCopyAndRenderPlatformDeliveryBasesOverwritesExistingMigrationOutput(t *
|
||||||
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{ClusterDomain: "example.test", TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"},
|
Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"},
|
||||||
|
Delivery: config.DeliveryConfig{AppName: "demo", AppRepoURL: "https://git.example.test/demo.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/demo", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"},
|
||||||
Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"},
|
Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"},
|
||||||
}
|
}
|
||||||
if err := copyAndRenderPlatformDeliveryBases(templateDir, repoDir, cfg); err != nil {
|
if err := copyAndRenderDeliveryBases(templateDir, repoDir, cfg); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
|
|
@ -295,10 +206,6 @@ func TestCopyAndRenderPlatformDeliveryBasesOverwritesExistingMigrationOutput(t *
|
||||||
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) {
|
||||||
|
|
@ -490,7 +397,7 @@ func TestWriteDemocraticCSISecretEncryptsValues(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteOpenBaoUnsealSecretRendersOnlyUnsealShares(t *testing.T) {
|
func TestWriteOpenBaoUnsealSecretRendersRecoveryMaterial(t *testing.T) {
|
||||||
originalRead := readOpenBaoRecovery
|
originalRead := readOpenBaoRecovery
|
||||||
originalWrite := writeGeneratedSOPS
|
originalWrite := writeGeneratedSOPS
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
|
@ -503,66 +410,24 @@ func TestWriteOpenBaoUnsealSecretRendersOnlyUnsealShares(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")
|
||||||
}
|
}
|
||||||
var secret struct {
|
rendered = append([]byte(nil), plaintext...)
|
||||||
Metadata map[string]string `yaml:"metadata"`
|
return os.WriteFile(path, []byte("sops: {}\n"), 0600)
|
||||||
StringData map[string]string `yaml:"stringData"`
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
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 {
|
if err := writeOpenBaoUnsealSecret(filepath.Join(t.TempDir(), "unseal.sops.yaml"), "recovery-identity", "recovery-bundle", "flux-age-identity"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
var secret struct {
|
||||||
|
Metadata map[string]string `yaml:"metadata"`
|
||||||
|
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"] == "" {
|
||||||
func TestWriteOpenBaoUnsealSecretEncryptsSharesWithoutRootToken(t *testing.T) {
|
t.Fatal("OpenBao unseal Secret was not rendered")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -842,55 +707,6 @@ 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")
|
||||||
|
|
@ -954,65 +770,16 @@ func TestRunnerEnableDeliveryAppliesDefaults(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
})
|
})
|
||||||
|
|
@ -1031,12 +798,9 @@ 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, " "), "externalsecret/forgejo-webhook") {
|
if strings.Contains(strings.Join(args, " "), "secret/forgejo-webhook") {
|
||||||
return []byte("True"), nil
|
return []byte(base64.StdEncoding.EncodeToString([]byte(authorization))), nil
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -1066,16 +830,14 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReconcileWebhookWaitsForReadyExternalSecret(t *testing.T) {
|
func TestReconcileWebhookWaitsForRefreshedTargetSecret(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
|
||||||
|
|
@ -1083,19 +845,21 @@ func TestReconcileWebhookWaitsForReadyExternalSecret(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, " "), "externalsecret/forgejo-webhook") {
|
if strings.Contains(strings.Join(args, " "), "secret/forgejo-webhook") {
|
||||||
targetChecks++
|
targetChecks++
|
||||||
if targetChecks == 1 {
|
if targetChecks == 1 {
|
||||||
return []byte("False"), nil
|
return []byte(staleTarget), nil
|
||||||
}
|
}
|
||||||
refreshedObserved = true
|
refreshedObserved = true
|
||||||
return []byte("True"), nil
|
return []byte(refreshedTarget), nil
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -1104,7 +868,7 @@ func TestReconcileWebhookWaitsForReadyExternalSecret(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 ExternalSecret was ready")
|
t.Error("Forgejo was called before the refreshed webhook Secret was observed")
|
||||||
writer.WriteHeader(http.StatusInternalServerError)
|
writer.WriteHeader(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1136,29 +900,24 @@ func TestReconcileWebhookWaitsForReadyExternalSecret(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 ExternalSecret became ready")
|
t.Fatal("Forgejo webhook was not updated after the target Secret refreshed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -1169,11 +928,8 @@ 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("False"), nil
|
return []byte(base64.StdEncoding.EncodeToString([]byte("Bearer stale-webhook-authorization"))), nil
|
||||||
}
|
}
|
||||||
webhookTargetTimeout = -time.Nanosecond
|
webhookTargetTimeout = -time.Nanosecond
|
||||||
webhookTargetPollInterval = 0
|
webhookTargetPollInterval = 0
|
||||||
|
|
@ -1188,7 +944,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 ExternalSecret became ready")
|
t.Fatal("Forgejo webhook update was attempted before the target Secret refreshed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1215,13 +971,13 @@ func TestTerraformPlanPathIsAbsolute(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTerraformReconcileTargetsStageImageTalconfigAndManagedBridgesOnly(t *testing.T) {
|
func TestTerraformReconcileTargetsStageImageAndManagedBridgesOnly(t *testing.T) {
|
||||||
if targets := strings.Join(terraformReconcileTargets(config.Config{}), " "); targets != "-target=proxmox_virtual_environment_download_file.talos_iso -target=local_file.talconfig" {
|
if targets := strings.Join(terraformReconcileTargets(config.Config{}), " "); targets != "-target=proxmox_virtual_environment_download_file.talos_iso" {
|
||||||
t.Fatalf("default reconcile targets = %q", targets)
|
t.Fatalf("default reconcile targets = %q", targets)
|
||||||
}
|
}
|
||||||
cfg := config.Config{Talos: config.TalosConfig{Cluster: config.TalosClusterConfig{ManageNetworkBridges: true}}}
|
cfg := config.Config{Talos: config.TalosConfig{Cluster: config.TalosClusterConfig{ManageNetworkBridges: true}}}
|
||||||
targets := strings.Join(terraformReconcileTargets(cfg), " ")
|
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") {
|
if !strings.Contains(targets, "-target=proxmox_virtual_environment_download_file.talos_iso") || !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)
|
t.Fatalf("managed bridge reconcile targets = %q", targets)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1303,7 +1059,7 @@ func TestReconcileTerraformImportsConfiguredTalosVMsBeforePlan(t *testing.T) {
|
||||||
if len(state) != 1 || state[0] != importedAddress {
|
if len(state) != 1 || state[0] != importedAddress {
|
||||||
return errors.New("VM create collision")
|
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") {
|
if strings.Contains(strings.Join(args, " "), "proxmox_virtual_environment_vm.vm") || !strings.Contains(strings.Join(args, " "), "-target=proxmox_virtual_environment_download_file.talos_iso") {
|
||||||
t.Fatalf("reconcile plan did not exclude the Talos VM: %q", args)
|
t.Fatalf("reconcile plan did not exclude the Talos VM: %q", args)
|
||||||
}
|
}
|
||||||
events = append(events, "plan")
|
events = append(events, "plan")
|
||||||
|
|
@ -1385,97 +1141,6 @@ func TestRebuildTerraformRetainsFullTalosVMLifecycle(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,368 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
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,61 +40,20 @@ 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 := map[string]string{}
|
locked := git.commits["cicd"]
|
||||||
for name, commit := range git.commits {
|
git.commits["cicd"] = strings.Repeat("d", 40)
|
||||||
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)
|
||||||
}
|
}
|
||||||
lock, err := readTemplateRevisionLock(filepath.Join(workspace, "maidn-template-revisions.yaml"))
|
if git.fetched["cicd"] != locked || git.checkedOut["cicd"] != locked {
|
||||||
if err != nil {
|
t.Fatalf("branch drift changed locked CICD revision: fetched %q, checked out %q", git.fetched["cicd"], git.checkedOut["cicd"])
|
||||||
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,7 +66,6 @@ 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
|
||||||
|
|
@ -107,117 +106,7 @@ func ResolveDelivery(cfg Config) (Config, error) {
|
||||||
return cfg, ValidateDelivery(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
|
||||||
}
|
}
|
||||||
|
|
@ -320,9 +209,6 @@ func applyDefaults(cfg *Config) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyDeliveryDefaults(cfg *Config) {
|
func applyDeliveryDefaults(cfg *Config) {
|
||||||
if cfg.Delivery.BuildStrategy == "" {
|
|
||||||
cfg.Delivery.BuildStrategy = "static"
|
|
||||||
}
|
|
||||||
if cfg.Delivery.AppRepoRef == "" {
|
if cfg.Delivery.AppRepoRef == "" {
|
||||||
cfg.Delivery.AppRepoRef = cfg.Flux.Branch
|
cfg.Delivery.AppRepoRef = cfg.Flux.Branch
|
||||||
}
|
}
|
||||||
|
|
@ -373,9 +259,6 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
@ -497,59 +380,13 @@ 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,45}[a-z0-9])?$`).MatchString(cfg.Delivery.AppName) {
|
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`).MatchString(cfg.Delivery.AppName) {
|
||||||
return errors.New("delivery appName must be a lowercase DNS label of at most 47 characters")
|
return errors.New("delivery appName must be a lowercase DNS label")
|
||||||
}
|
|
||||||
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")
|
||||||
|
|
@ -560,11 +397,6 @@ 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", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"},
|
Delivery: DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/web-ui", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"},
|
||||||
Talos: TalosConfig{
|
Talos: TalosConfig{
|
||||||
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
|
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
|
||||||
Proxmox: TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
|
Proxmox: TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
|
||||||
|
|
@ -64,7 +64,6 @@ func TestResolveDeliveryAppliesDefaultsOnlyForExplicitAppOperations(t *testing.T
|
||||||
cfg.Delivery.AppRepoRef = ""
|
cfg.Delivery.AppRepoRef = ""
|
||||||
cfg.Delivery.BuildOutputDirectory = ""
|
cfg.Delivery.BuildOutputDirectory = ""
|
||||||
cfg.Delivery.BuildConfiguration = ""
|
cfg.Delivery.BuildConfiguration = ""
|
||||||
cfg.Delivery.BuildStrategy = ""
|
|
||||||
cfg.Delivery.WebhookHostname = ""
|
cfg.Delivery.WebhookHostname = ""
|
||||||
cfg.Delivery.WebhookPath = ""
|
cfg.Delivery.WebhookPath = ""
|
||||||
|
|
||||||
|
|
@ -79,25 +78,11 @@ func TestResolveDeliveryAppliesDefaultsOnlyForExplicitAppOperations(t *testing.T
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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/" {
|
if !delivery.Delivery.Configured() || delivery.Delivery.AppRepoRef != "main" || delivery.Delivery.BuildOutputDirectory != "dist" || delivery.Delivery.BuildConfiguration != "production" || delivery.Delivery.WebhookURL() != "https://tekton.example.test/" {
|
||||||
t.Fatalf("ResolveDelivery() did not apply the complete delivery contract: %#v", delivery.Delivery)
|
t.Fatalf("ResolveDelivery() did not apply the complete delivery contract: %#v", delivery.Delivery)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateDeliveryBuildStrategy(t *testing.T) {
|
|
||||||
cfg := validConfig(t)
|
|
||||||
for _, strategy := range []string{"static", "runtime"} {
|
|
||||||
cfg.Delivery.BuildStrategy = strategy
|
|
||||||
if err := ValidateDelivery(cfg); err != nil {
|
|
||||||
t.Fatalf("ValidateDelivery() rejected %q: %v", strategy, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cfg.Delivery.BuildStrategy = "container"
|
|
||||||
if err := ValidateDelivery(cfg); err == nil || !strings.Contains(err.Error(), "buildStrategy") {
|
|
||||||
t.Fatalf("ValidateDelivery() accepted invalid build strategy: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) {
|
func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) {
|
||||||
cfg := validConfig(t)
|
cfg := validConfig(t)
|
||||||
cfg.Delivery.ImageRepository = ""
|
cfg.Delivery.ImageRepository = ""
|
||||||
|
|
@ -106,44 +91,6 @@ 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"
|
||||||
|
|
@ -152,23 +99,6 @@ 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"
|
||||||
|
|
@ -213,32 +143,6 @@ 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,20 +12,9 @@ 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"`
|
||||||
|
|
@ -45,10 +34,8 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,360 +0,0 @@
|
||||||
// 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 }}}
|
|
||||||
}
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
// 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 }}}
|
|
||||||
}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
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
|
||||||
MigrationRepositories []string
|
MigrationPending bool
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,10 +36,6 @@ 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"`
|
||||||
|
|
@ -57,7 +53,6 @@ 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 {
|
||||||
|
|
@ -97,7 +92,6 @@ 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)
|
||||||
|
|
@ -123,24 +117,10 @@ 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: scopes})
|
body, err := json.Marshal(createTokenRequest{Name: name, Scopes: []string{"read:package", "write:package"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -164,10 +144,10 @@ func createToken(client *http.Client, baseURL, username, password, otp, name str
|
||||||
}
|
}
|
||||||
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 token response: %w", err)
|
return "", fmt.Errorf("parse Forgejo registry token response: %w", err)
|
||||||
}
|
}
|
||||||
if token.SHA1 == "" {
|
if token.SHA1 == "" {
|
||||||
return "", fmt.Errorf("Forgejo did not return a token")
|
return "", fmt.Errorf("Forgejo did not return a registry token")
|
||||||
}
|
}
|
||||||
return token.SHA1, nil
|
return token.SHA1, nil
|
||||||
}
|
}
|
||||||
|
|
@ -179,53 +159,6 @@ 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 {
|
||||||
|
|
@ -395,12 +328,7 @@ func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool,
|
||||||
if err != nil || !changed || !existing {
|
if err != nil || !changed || !existing {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, repository := range rm.MigrationRepositories {
|
rm.MigrationPending = true
|
||||||
if repository == repoName {
|
|
||||||
return rm.createMigrationPullRequest(repoName, targetBranch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rm.MigrationRepositories = append(rm.MigrationRepositories, repoName)
|
|
||||||
return rm.createMigrationPullRequest(repoName, targetBranch)
|
return rm.createMigrationPullRequest(repoName, targetBranch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -420,57 +348,12 @@ 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 && status != http.StatusConflict {
|
if status != http.StatusCreated && status != http.StatusUnprocessableEntity {
|
||||||
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())
|
||||||
|
|
@ -509,72 +392,71 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublishRepositoryPullRequest applies a managed change on a dedicated branch.
|
// DeliveryBranch returns the dedicated branch that carries generated delivery content.
|
||||||
// An existing branch is accepted only when it has exactly one open pull request.
|
func DeliveryBranch(appName, baseBranch string) (string, error) {
|
||||||
func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base string, change func(string) error) (bool, error) {
|
branch := "maidn/delivery-" + appName
|
||||||
if repo == "" || branch == "" || base == "" || branch == base {
|
if appName == "" || branch == baseBranch {
|
||||||
return false, errors.New("repository pull request requires distinct non-empty branches")
|
return "", fmt.Errorf("delivery branch and configured base branch must differ")
|
||||||
}
|
}
|
||||||
repoURL := CloneURL(rm.BaseURL, rm.Owner, repo)
|
return branch, nil
|
||||||
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 false, err
|
return err
|
||||||
}
|
|
||||||
if hasBranch {
|
|
||||||
open, err := rm.HasOpenPullRequest(repo, branch)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !open {
|
|
||||||
return false, fmt.Errorf("Forgejo branch %q exists without exactly one open pull request; refusing to reuse it", branch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
temporary, err := os.MkdirTemp("", "maidn-registration-*")
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(temporary)
|
defer os.RemoveAll(temporary)
|
||||||
|
if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := generate(temporary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
cleanupAskPass, environment, err := rm.gitEnvironment()
|
cleanupAskPass, environment, err := rm.gitEnvironment()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return err
|
||||||
}
|
}
|
||||||
defer cleanupAskPass()
|
defer cleanupAskPass()
|
||||||
checkout := base
|
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 {
|
||||||
checkout = branch
|
if err := runGit(temporary, environment, "fetch", repoURL, "refs/heads/"+deliveryBranch); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if err := runGit("", environment, "clone", "--branch", checkout, repoURL, temporary); err != nil {
|
different, err := gitDiffQuiet(temporary, environment, "HEAD", "FETCH_HEAD")
|
||||||
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 {
|
if err != nil {
|
||||||
return false, err
|
return err
|
||||||
}
|
}
|
||||||
if !changed {
|
if !different {
|
||||||
if !hasBranch {
|
return nil
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
open, err := rm.HasOpenPullRequest(repo, branch)
|
return fmt.Errorf("dedicated delivery branch %q differs from generated content; refusing to overwrite it", deliveryBranch)
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
if !open {
|
return rm.PushBranch(temporary, repoURL, deliveryBranch)
|
||||||
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) {
|
||||||
|
|
@ -607,43 +489,6 @@ 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
|
||||||
|
|
@ -719,7 +564,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 hookURL(existing) != webhookURL {
|
if existing.URL != 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})
|
||||||
|
|
@ -749,51 +594,10 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hookURL(existing hook) string {
|
// EnsureProtectedBranch disables direct pushes to the configured production branch.
|
||||||
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 branch are required")
|
return errors.New("Forgejo repository and production 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
|
||||||
|
|
@ -811,11 +615,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 branch %q", branch)
|
return fmt.Errorf("multiple Forgejo branch protections match production 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 branch %q permits direct pushes", branch)
|
return fmt.Errorf("Forgejo production branch %q permits direct pushes", branch)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,34 +41,6 @@ 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 })
|
||||||
|
|
@ -169,40 +141,6 @@ 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) {
|
||||||
|
|
@ -212,7 +150,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, Config: map[string]string{"url": "https://tekton.example.test/"}}})
|
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, 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)
|
||||||
|
|
@ -266,28 +204,6 @@ 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" {
|
||||||
|
|
@ -317,28 +233,6 @@ 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 {
|
||||||
|
|
@ -400,34 +294,6 @@ 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 {
|
||||||
|
|
@ -454,49 +320,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
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,7 +2,6 @@ package openbao
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -16,15 +15,9 @@ 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"`
|
||||||
|
|
@ -41,14 +34,9 @@ type operationalSecrets struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
var decryptRecovery = func(identityPath, bundlePath string) ([]byte, error) {
|
var decryptRecovery = func(identityPath, bundlePath string) ([]byte, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
cmd := exec.Command("age", "-d", "-i", identityPath, bundlePath)
|
||||||
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
|
||||||
|
|
@ -56,10 +44,6 @@ 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 {
|
||||||
|
|
@ -82,22 +66,18 @@ 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)
|
||||||
|
|
@ -110,14 +90,12 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -126,16 +104,13 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -143,15 +118,10 @@ 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) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
cmd := exec.Command("sops", "--decrypt", "--output-type", "yaml", path)
|
||||||
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
|
||||||
|
|
@ -188,14 +158,8 @@ func validateRecoveryRecipient(recipient, bundlePath string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer os.Remove(probePath)
|
defer os.Remove(probePath)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
cmd := exec.Command("age", "-r", recipient, "-o", probePath)
|
||||||
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
|
||||||
|
|
@ -241,7 +205,7 @@ func parseRecoveryMaterial(plaintext []byte) (RecoveryMaterial, error) {
|
||||||
return material, nil
|
return material, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeSecret(kubeconfig, token, secretPath string, values map[string]string) error {
|
func writeSecret(kubeconfig, rootToken, 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)
|
||||||
}
|
}
|
||||||
|
|
@ -255,7 +219,7 @@ func writeSecret(kubeconfig, token, secretPath string, values map[string]string)
|
||||||
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(token)
|
input.WriteString(rootToken)
|
||||||
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)))
|
||||||
|
|
@ -268,39 +232,25 @@ func writeSecret(kubeconfig, token, secretPath string, values map[string]string)
|
||||||
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 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"
|
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"
|
||||||
_, err := execInPodMutation(kubeconfig, []byte(input.String()), "sh", "-ec", script)
|
_, err := execInPod(kubeconfig, []byte(input.String()), "sh", "-ec", script)
|
||||||
if err != nil {
|
return err
|
||||||
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"}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
output, err := exec.Command("kubectl", command...).Output()
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
@ -348,169 +298,49 @@ done`
|
||||||
}
|
}
|
||||||
|
|
||||||
func configureKubernetesAuth(kubeconfig, rootToken, reviewerToken string) error {
|
func configureKubernetesAuth(kubeconfig, rootToken, reviewerToken string) error {
|
||||||
const script = `fail() { printf '%s\n' "$1" >&2; exit 1; }
|
const script = `read -r root_token
|
||||||
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 || fail kubernetes-auth-config
|
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
|
||||||
cat >/tmp/external-secrets.hcl <<'EOF'
|
cat >/tmp/external-secrets.hcl <<'EOF'
|
||||||
path "secret/data/platform/*" {
|
path "secret/data/*" {
|
||||||
capabilities = ["read"]
|
capabilities = ["read"]
|
||||||
}
|
}
|
||||||
path "secret/data/cicd/*" {
|
path "secret/metadata/*" {
|
||||||
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 || fail platform-external-secrets-policy
|
bao policy write external-secrets /tmp/external-secrets.hcl >/dev/null
|
||||||
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 2>&1 || fail platform-external-secrets-role`
|
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`
|
||||||
input := []byte(rootToken + "\n" + reviewerToken + "\n")
|
input := []byte(rootToken + "\n" + reviewerToken + "\n")
|
||||||
output, err := execInPodMutation(kubeconfig, input, "sh", "-ec", script)
|
_, err := execInPod(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, "--request-timeout=30s", "-n", "external-secrets", "get", "deployment/external-secrets", "-o=jsonpath={.status.conditions[?(@.type==\"Available\")].status}")
|
available, err := kubectlOutput(kubeconfig, "-n", "external-secrets", "get", "deployment/external-secrets", "-o=jsonpath={.status.conditions[?(@.type==\"Available\")].status}")
|
||||||
if err != nil || strings.TrimSpace(string(available)) != "True" {
|
if err != nil || strings.TrimSpace(string(available)) != "True" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
timestamp := time.Now().UnixNano()
|
timestamp := time.Now().UnixNano()
|
||||||
fmt.Fprintln(os.Stderr, "OpenBao: refresh OpenBao secret store")
|
_, err = kubectlOutput(kubeconfig, "annotate", "clustersecretstore", "openbao", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite")
|
||||||
_, err = kubectlOutput(kubeconfig, "--request-timeout=30s", "annotate", "clustersecretstore", "openbao", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("refresh OpenBao secret store after seed: %w", err)
|
return fmt.Errorf("refresh OpenBao secret store after seed: %w", err)
|
||||||
}
|
}
|
||||||
|
_, err = kubectlOutput(kubeconfig, externalSecretRefreshArgs(timestamp)...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("refresh ExternalSecrets after OpenBao seed: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func externalSecretRefreshArgs(timestamp int64) []string {
|
||||||
|
return []string{"annotate", "externalsecret", "forgejo-webhook", "-n", "tekton-pipelines", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite"}
|
||||||
|
}
|
||||||
|
|
||||||
func encryptRecovery(recipient, bundlePath string, plaintext []byte) error {
|
func encryptRecovery(recipient, bundlePath string, plaintext []byte) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -525,36 +355,17 @@ 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...)
|
||||||
return commandOutput(input, "kubectl", command...)
|
cmd := exec.Command("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 commandOutput(nil, "kubectl", command...)
|
return exec.Command("kubectl", command...).CombinedOutput()
|
||||||
}
|
}
|
||||||
|
|
||||||
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 commandOutput(nil, "kubectl", command...)
|
return exec.Command("kubectl", command...).Output()
|
||||||
}
|
|
||||||
|
|
||||||
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,9 +7,6 @@ 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) {
|
||||||
|
|
@ -25,20 +22,17 @@ func TestEnsureRecoveryIdentity(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnsealUsesPromptedStdinOnly(t *testing.T) {
|
func TestUnsealSubmitsAllSharesAndVerifiesResult(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 })
|
||||||
calls := 0
|
var shares []string
|
||||||
execInPod = func(_ string, input []byte, args ...string) ([]byte, error) {
|
execInPod = func(_ string, input []byte, _ ...string) ([]byte, error) {
|
||||||
if len(input) == 0 || len(args) != 3 || args[0] != "sh" || args[1] != "-ec" || args[2] != "read -r key; bao operator unseal \"$key\" >/dev/null" {
|
shares = append(shares, strings.TrimSpace(string(input)))
|
||||||
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 || calls != 3 {
|
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" {
|
||||||
t.Fatalf("unseal calls:%d err:%v", calls, err)
|
t.Fatalf("unseal = shares:%q err:%v", shares, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,10 +59,10 @@ func TestUnsealFallsBackToControllerSecret(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteSecretFramesMultilineValues(t *testing.T) {
|
func TestWriteSecretFramesMultilineValues(t *testing.T) {
|
||||||
original := execInPodMutation
|
original := execInPod
|
||||||
t.Cleanup(func() { execInPodMutation = original })
|
t.Cleanup(func() { execInPod = original })
|
||||||
var input, script string
|
var input, script string
|
||||||
execInPodMutation = func(_ string, contents []byte, args ...string) ([]byte, error) {
|
execInPod = 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
|
||||||
}
|
}
|
||||||
|
|
@ -78,19 +72,6 @@ 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 })
|
||||||
|
|
@ -102,114 +83,11 @@ 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 clustersecretstore openbao") || strings.Contains(calls[1], "--all") {
|
if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 3 || !strings.Contains(calls[0], "get deployment/external-secrets") || !strings.Contains(calls[1], "annotate clustersecretstore openbao") || !strings.Contains(calls[2], "annotate externalsecret forgejo-webhook") || strings.Contains(calls[1], "--all") || strings.Contains(calls[2], "--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,17 +98,6 @@ 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 == "" {
|
||||||
|
|
@ -167,13 +156,6 @@ 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