Compare commits
No commits in common. "main" and "fix/forgejo-catalog-default" have entirely different histories.
main
...
fix/forgej
|
|
@ -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"]
|
||||
13
How2Auth.md
13
How2Auth.md
|
|
@ -1,11 +1,2 @@
|
|||
# Credential onboarding
|
||||
|
||||
Do not put credential values in this file, Git, generated configuration, command arguments, terminal history, or logs.
|
||||
|
||||
1. Obtain written authorization for `<credential-purpose>`, `<consumer-inventory>`, `<approved-scope>`, and `<rotation-window>`.
|
||||
2. Have the authorized operator enter `<credential-value>` only through the approved secure prompt or standard input boundary.
|
||||
3. Store it only in the configured SOPS-encrypted operational-secrets file, then reseed and verify OpenBao before changing consumers.
|
||||
4. Keep the previous credential active only for the approved overlap window; revoke it only after every consumer check succeeds.
|
||||
5. Record `<credential-identifier>`, `<timestamp>`, `<operator>`, and `<status>` without recording any credential value.
|
||||
|
||||
See [docs/secrets.md](docs/secrets.md) and [docs/runbooks/credential-rotation.md](docs/runbooks/credential-rotation.md).
|
||||
edingrech
|
||||
dckr_pat_bfKKDH4g3qUxchs9UMLxFx2oTiU
|
||||
36
README.md
36
README.md
|
|
@ -1,16 +1,22 @@
|
|||
## 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 --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/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
|
||||
|
||||
|
|
@ -39,3 +45,15 @@ talos:
|
|||
ip: <node-traffic-address>
|
||||
vlanId: <opnsense-traffic-vlan>
|
||||
```
|
||||
|
||||
|
||||
https://192.168.0.15:8006
|
||||
root@pam!maidn-test-key
|
||||
2ce7bff1-ac98-4a45-8db4-186d49ae4159
|
||||
|
||||
test-org-test-key:147878348db3b8ab660b1af2799c3842705a31bc
|
||||
maidn-dev-work:fca448f2677362cc165eebb7859ce8f9f5e3735c
|
||||
|
||||
https://192.168.0.13:8006
|
||||
root@pam!maidn-test-key
|
||||
3773033f-552b-4572-83e9-cd16e8ac2e13
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
226
cmd/bootstrap.go
226
cmd/bootstrap.go
|
|
@ -1,10 +1,7 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||
|
|
@ -16,7 +13,6 @@ import (
|
|||
|
||||
var bootstrapConfigPath string
|
||||
var bootstrapOutputPath string
|
||||
var bootstrapWorkspaceDir string
|
||||
var bootstrapMode string
|
||||
var bootstrapYes bool
|
||||
var bootstrapPromptDemocraticCSI bool
|
||||
|
|
@ -24,23 +20,10 @@ var bootstrapPromptOperationalSecrets bool
|
|||
var bootstrapInitializeOpenBaoRecovery bool
|
||||
var bootstrapInitializeOpenBao bool
|
||||
var bootstrapCreateForgejoRegistryToken bool
|
||||
var bootstrapForgejoPasswordFile string
|
||||
var bootstrapCreateForgejoDeliveryStatusToken bool
|
||||
var bootstrapProvisionAppSecretIdentities bool
|
||||
var bootstrapE2EApp string
|
||||
var bootstrapRegisterWebhook bool
|
||||
var bootstrapRotateWebhookAuthorization bool
|
||||
var bootstrapPublishAppFrom string
|
||||
var bootstrapMergeBootstrapPR bool
|
||||
var bootstrapManageNetworkBridges bool
|
||||
var bootstrapEnableDelivery bool
|
||||
var bootstrapDestroyDemocraticCSIStorage bool
|
||||
var bootstrapRefreshTemplateRevisions bool
|
||||
|
||||
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
|
||||
var readOperationalSecrets = bootstrap.ReadOperationalSecrets
|
||||
var initializeOpenBao = bootstrap.InitializeOpenBao
|
||||
var createForgejoDeliveryStatusToken = forgejo.CreateDeliveryStatusToken
|
||||
var promptForgejoDeliveryStatusToken = ui.PromptForgejoDeliveryStatusToken
|
||||
var bootstrapCmd = &cobra.Command{
|
||||
Use: "bootstrap",
|
||||
Short: "Bootstrap Talos and Flux from config or an interactive wizard.",
|
||||
|
|
@ -51,7 +34,6 @@ func init() {
|
|||
rootCmd.AddCommand(bootstrapCmd)
|
||||
bootstrapCmd.Flags().StringVar(&bootstrapConfigPath, "config", "", "Path to bootstrap config YAML")
|
||||
bootstrapCmd.Flags().StringVar(&bootstrapOutputPath, "out", "maidn-bootstrap.yaml", "Path to save generated config")
|
||||
bootstrapCmd.Flags().StringVar(&bootstrapWorkspaceDir, "workspace-dir", "", "Override workspace directory for this bootstrap run")
|
||||
bootstrapCmd.Flags().StringVar(&bootstrapMode, "mode", string(bootstrap.Reconcile), "Lifecycle mode: reconcile or rebuild")
|
||||
bootstrapCmd.Flags().BoolVar(&bootstrapYes, "yes", false, "Confirm destructive rebuild")
|
||||
bootstrapCmd.Flags().BoolVar(&bootstrapPromptDemocraticCSI, "prompt-democratic-csi", false, "Prompt for and save Democratic CSI settings in --config")
|
||||
|
|
@ -59,41 +41,14 @@ func init() {
|
|||
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(&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(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao")
|
||||
bootstrapCmd.Flags().StringVar(&bootstrapPublishAppFrom, "publish-app-from", "", "Push this app checkout's current branch and create a Forgejo delivery PR")
|
||||
bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping")
|
||||
bootstrapCmd.Flags().BoolVar(&bootstrapManageNetworkBridges, "manage-network-bridges", false, "Persist Terraform management for existing Talos network bridges")
|
||||
bootstrapCmd.Flags().BoolVar(&bootstrapEnableDelivery, "enable-delivery", false, "Resolve delivery defaults and reconcile the configured app delivery source")
|
||||
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 {
|
||||
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 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 bootstrapConfigPath == "" {
|
||||
return fmt.Errorf("--create-forgejo-registry-token requires --config")
|
||||
|
|
@ -102,53 +57,8 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
|||
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 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 bootstrapConfigPath == "" {
|
||||
return fmt.Errorf("--rotate-webhook-authorization requires --config")
|
||||
}
|
||||
cfg, err = config.Load(bootstrapConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err = config.ResolveDelivery(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authorization, err := bootstrap.NewWebhookAuthorization()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate Forgejo webhook authorization: %w", err)
|
||||
}
|
||||
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 bootstrap.Runner{Config: cfg, RegisterWebhook: true, RefreshWebhookSecret: true}.Run()
|
||||
}
|
||||
if bootstrapInitializeOpenBao {
|
||||
if bootstrapConfigPath == "" {
|
||||
return fmt.Errorf("--initialize-openbao requires --config")
|
||||
|
|
@ -157,9 +67,6 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return bootstrap.InitializeOpenBao(cfg)
|
||||
}
|
||||
if bootstrapMergeBootstrapPR {
|
||||
|
|
@ -170,22 +77,47 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
|
||||
manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, cfg.Git.Owner, cfg.Git.Username, "", "", cfg.Flux.Branch, "")
|
||||
if err := manager.MergePullRequest(cfg.Flux.RepoName, "maidn/bootstrap-"+cfg.ClusterID); err != nil {
|
||||
return err
|
||||
}
|
||||
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} {
|
||||
open, err := manager.HasOpenPullRequest(repository, "maidn/bootstrap-"+cfg.ClusterID)
|
||||
}
|
||||
if bootstrapPublishAppFrom != "" {
|
||||
if bootstrapConfigPath == "" {
|
||||
return fmt.Errorf("--publish-app-from requires --config")
|
||||
}
|
||||
cfg, err = config.Load(bootstrapConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if open {
|
||||
if err := manager.MergePullRequest(repository, "maidn/bootstrap-"+cfg.ClusterID); err != nil {
|
||||
branch, err := forgejo.CurrentBranch(bootstrapPublishAppFrom)
|
||||
if 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
|
||||
}
|
||||
hasDeliveryBranch, err := manager.HasRemoteBranch(cfg.Delivery.AppRepoURL, cfg.Delivery.AppRepoRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created || !hasDeliveryBranch {
|
||||
if err := manager.PushRef(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, "HEAD", cfg.Delivery.AppRepoRef); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := manager.PushBranch(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, branch); err != nil {
|
||||
return err
|
||||
}
|
||||
return manager.CreatePullRequest(repo, "feat: migrate delivery to Tekton", branch, cfg.Delivery.AppRepoRef)
|
||||
}
|
||||
|
||||
if bootstrapConfigPath != "" {
|
||||
if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery || bootstrapManageNetworkBridges {
|
||||
cfg, err = config.LoadRaw(bootstrapConfigPath)
|
||||
|
|
@ -198,9 +130,6 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
cfg, err = config.Resolve(cfg)
|
||||
}
|
||||
if err == nil && bootstrapPromptOperationalSecrets {
|
||||
cfg, err = config.ResolveDelivery(cfg)
|
||||
}
|
||||
if err == nil && bootstrapPromptOperationalSecrets {
|
||||
var secrets map[string]map[string]string
|
||||
secrets, err = ui.PromptOperationalSecrets(cfg)
|
||||
|
|
@ -241,45 +170,16 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bootstrapWorkspaceDir != "" {
|
||||
cfg.WorkspaceDir = bootstrapWorkspaceDir
|
||||
}
|
||||
if bootstrapRegisterWebhook {
|
||||
cfg, err = config.ResolveDelivery(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedForgejoOperationalCredentials(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
if err := upsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo", "token", cfg.Git.Token); err != nil {
|
||||
return fmt.Errorf("save Forgejo token for webhook registration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createForgejoRegistryToken(cfg config.Config) error {
|
||||
if _, err := bootstrap.ReadOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
password, otp, name, err := forgejoRegistryTokenCredentials()
|
||||
password, otp, name, err := ui.PromptForgejoRegistryToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -299,59 +199,3 @@ func createForgejoRegistryToken(cfg config.Config) error {
|
|||
}
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,6 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
|
||||
"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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
import "testing"
|
||||
|
||||
func TestCreateForgejoRegistryTokenRequiresConfig(t *testing.T) {
|
||||
originalConfigPath, originalCreate := bootstrapConfigPath, bootstrapCreateForgejoRegistryToken
|
||||
|
|
@ -42,114 +14,3 @@ func TestCreateForgejoRegistryTokenRequiresConfig(t *testing.T) {
|
|||
t.Fatal("--create-forgejo-registry-token accepted a missing --config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedForgejoOperationalCredentialsUsesEncryptedUpsertBoundary(t *testing.T) {
|
||||
original := upsertOperationalSecret
|
||||
t.Cleanup(func() { upsertOperationalSecret = original })
|
||||
cfg := config.Config{
|
||||
Git: config.GitConfig{Username: "webhook-bot", Token: "test-pat"},
|
||||
SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets.sops.yaml", AgeKeyPath: "age-key.txt"},
|
||||
}
|
||||
type call struct{ path, key, value string }
|
||||
var calls []call
|
||||
upsertOperationalSecret = func(secretsPath, ageKeyPath, path, key, value string) error {
|
||||
if secretsPath != cfg.SOPS.OperationalSecretsPath || ageKeyPath != cfg.SOPS.AgeKeyPath {
|
||||
t.Fatal("credential upsert used unexpected secret paths")
|
||||
}
|
||||
calls = append(calls, call{path, key, value})
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := seedForgejoOperationalCredentials(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(calls) != 2 || calls[0] != (call{"cicd/forgejo", "username", cfg.Git.Username}) || calls[1] != (call{"cicd/forgejo", "token", cfg.Git.Token}) {
|
||||
t.Fatal("configured Forgejo credentials were not passed only to the encrypted upsert boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedForgejoOperationalCredentialsHidesTokenOnUpsertFailure(t *testing.T) {
|
||||
original := upsertOperationalSecret
|
||||
t.Cleanup(func() { upsertOperationalSecret = original })
|
||||
cfg := config.Config{Git: config.GitConfig{Username: "webhook-bot", Token: "test-pat"}}
|
||||
upsertOperationalSecret = func(_, _, _, key, _ string) error {
|
||||
if key == "token" {
|
||||
return errors.New("unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err := seedForgejoOperationalCredentials(cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "save Forgejo token for webhook registration") || strings.Contains(err.Error(), cfg.Git.Token) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,197 +0,0 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/cloudflare"
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var cloudflareTunnelConfigPath string
|
||||
var cloudflareTunnelHostname string
|
||||
var cloudflareTunnelService string
|
||||
var cloudflareTunnelZoneID string
|
||||
var cloudflareTunnelCredentialsFile string
|
||||
|
||||
var loadCloudflareTunnelConfig = config.Load
|
||||
var readCloudflareTunnelSecrets = bootstrap.ReadOperationalSecrets
|
||||
var writeCloudflareTunnelSecrets = bootstrap.WriteOperationalSecrets
|
||||
var newCloudflareRouteClient = func(token string) cloudflare.API { return cloudflare.NewClient(token) }
|
||||
var initializeCloudflareTunnelOpenBao = bootstrap.InitializeOpenBao
|
||||
var readCloudflareTunnelCredentials = cloudflare.ReadCredentialsFile
|
||||
|
||||
var cloudflareTunnelCmd = &cobra.Command{
|
||||
Use: "cloudflare-tunnel",
|
||||
Short: "Manage CLI-created Cloudflare Tunnel routes.",
|
||||
}
|
||||
|
||||
var cloudflareTunnelRouteCmd = &cobra.Command{Use: "route"}
|
||||
|
||||
var cloudflareTunnelRouteAddCmd = &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a proxied public route to the managed tunnel.",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runCloudflareTunnelRoute(cmd, true)
|
||||
},
|
||||
}
|
||||
|
||||
var cloudflareTunnelRouteRemoveCmd = &cobra.Command{
|
||||
Use: "remove",
|
||||
Short: "Remove a proxied public route from the managed tunnel.",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runCloudflareTunnelRoute(cmd, false)
|
||||
},
|
||||
}
|
||||
|
||||
var cloudflareTunnelImportCmd = &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "Import local Cloudflare Tunnel credentials into encrypted operational state.",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return runCloudflareTunnelImport()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(cloudflareTunnelCmd)
|
||||
cloudflareTunnelCmd.AddCommand(cloudflareTunnelRouteCmd, cloudflareTunnelImportCmd)
|
||||
cloudflareTunnelRouteCmd.AddCommand(cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd)
|
||||
cloudflareTunnelCmd.PersistentFlags().StringVar(&cloudflareTunnelConfigPath, "config", "", "Path to bootstrap config YAML")
|
||||
for _, command := range []*cobra.Command{cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd} {
|
||||
command.Flags().StringVar(&cloudflareTunnelHostname, "hostname", "", "Public DNS hostname")
|
||||
command.Flags().StringVar(&cloudflareTunnelService, "service", "", "Upstream http or https service URL")
|
||||
command.Flags().StringVar(&cloudflareTunnelZoneID, "zone-id", "", "Cloudflare zone ID")
|
||||
_ = command.MarkFlagRequired("hostname")
|
||||
_ = command.MarkFlagRequired("service")
|
||||
_ = command.MarkFlagRequired("zone-id")
|
||||
}
|
||||
cloudflareTunnelImportCmd.Flags().StringVar(&cloudflareTunnelCredentialsFile, "credentials-file", "", "Path to local Cloudflare Tunnel credentials JSON")
|
||||
_ = cloudflareTunnelImportCmd.MarkFlagRequired("credentials-file")
|
||||
}
|
||||
|
||||
func runCloudflareTunnelImport() error {
|
||||
if cloudflareTunnelConfigPath == "" {
|
||||
return errors.New("--config is required")
|
||||
}
|
||||
if cloudflareTunnelCredentialsFile == "" {
|
||||
return errors.New("--credentials-file is required")
|
||||
}
|
||||
cfg, err := loadCloudflareTunnelConfig(cloudflareTunnelConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets, err := readCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read encrypted Cloudflare operational state: %w", err)
|
||||
}
|
||||
state := secrets["platform/cloudflare-tunnel"]
|
||||
_, present, stateErr := cloudflare.ParseStoredTunnel(state)
|
||||
if present {
|
||||
return errors.New("Cloudflare tunnel state is already managed; refusing to overwrite it")
|
||||
}
|
||||
if stateErr != nil && !cloudflare.IsLegacyRunTokenState(state) {
|
||||
return stateErr
|
||||
}
|
||||
credentials, err := readCloudflareTunnelCredentials(cloudflareTunnelCredentialsFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values, err := (cloudflare.StoredTunnel{Credentials: credentials, Config: cloudflare.NewConfig(credentials.TunnelID)}).Values()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets["platform/cloudflare-tunnel"] = values
|
||||
if err := writeCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, secrets); err != nil {
|
||||
return errors.New("save encrypted Cloudflare tunnel credentials")
|
||||
}
|
||||
if err := initializeCloudflareTunnelOpenBao(cfg); err != nil {
|
||||
return errors.New("Cloudflare tunnel credentials were saved but OpenBao could not be seeded; rerun bootstrap --config <config> --initialize-openbao")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCloudflareTunnelRoute(cmd *cobra.Command, add bool) error {
|
||||
if cloudflareTunnelConfigPath == "" {
|
||||
return errors.New("--config is required")
|
||||
}
|
||||
if strings.TrimSpace(cloudflareTunnelZoneID) == "" {
|
||||
return errors.New("--zone-id is required")
|
||||
}
|
||||
route, err := cloudflare.NewRoute(cloudflareTunnelHostname, cloudflareTunnelService)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadCloudflareTunnelConfig(cloudflareTunnelConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets, err := readCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read encrypted Cloudflare operational state: %w", err)
|
||||
}
|
||||
token := strings.TrimSpace(secrets["platform/cloudflare"]["api-token"])
|
||||
if token == "" {
|
||||
return errors.New("encrypted operational secrets requires platform/cloudflare.api-token")
|
||||
}
|
||||
stored, present, err := cloudflare.ParseStoredTunnel(secrets["platform/cloudflare-tunnel"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !present {
|
||||
return errors.New("Cloudflare tunnel credentials and config are not generated; run cloudflare-tunnel import with a local credentials file")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if cmd != nil && cmd.Context() != nil {
|
||||
ctx = cmd.Context()
|
||||
}
|
||||
client := newCloudflareRouteClient(token)
|
||||
if add {
|
||||
changed, err := stored.Config.AddRoute(route)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.EnsureCNAME(ctx, strings.TrimSpace(cloudflareTunnelZoneID), route.Hostname, stored.Credentials.TunnelID); err != nil {
|
||||
return fmt.Errorf("ensure Cloudflare route DNS record: %w", err)
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
values, err := stored.Values()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets["platform/cloudflare-tunnel"] = values
|
||||
if err := writeCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, secrets); err != nil {
|
||||
return errors.New("save encrypted Cloudflare tunnel route")
|
||||
}
|
||||
if err := initializeCloudflareTunnelOpenBao(cfg); err != nil {
|
||||
return errors.New("Cloudflare tunnel route was saved but OpenBao could not be seeded; rerun bootstrap --config <config> --initialize-openbao")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
changed, err := stored.Config.RemoveRoute(route)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
values, err := stored.Values()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets["platform/cloudflare-tunnel"] = values
|
||||
if err := writeCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, secrets); err != nil {
|
||||
return errors.New("save encrypted Cloudflare tunnel route")
|
||||
}
|
||||
if err := initializeCloudflareTunnelOpenBao(cfg); err != nil {
|
||||
return errors.New("Cloudflare tunnel route was saved but OpenBao could not be seeded; rerun bootstrap --config <config> --initialize-openbao")
|
||||
}
|
||||
}
|
||||
if err := client.DeleteCNAME(ctx, strings.TrimSpace(cloudflareTunnelZoneID), route.Hostname, stored.Credentials.TunnelID); err != nil {
|
||||
return fmt.Errorf("delete Cloudflare route DNS record: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/cloudflare"
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||
)
|
||||
|
||||
type fakeCloudflareRouteAPI struct {
|
||||
ensured bool
|
||||
deleted bool
|
||||
ensureErr error
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func (f *fakeCloudflareRouteAPI) EnsureCNAME(_ context.Context, zoneID, hostname, tunnelID string) error {
|
||||
if zoneID != "zone-id" || hostname != "app.example.test" || tunnelID != "tunnel-id" {
|
||||
return &routeTestError{}
|
||||
}
|
||||
f.ensured = true
|
||||
return f.ensureErr
|
||||
}
|
||||
|
||||
func (f *fakeCloudflareRouteAPI) DeleteCNAME(_ context.Context, zoneID, hostname, tunnelID string) error {
|
||||
if zoneID != "zone-id" || hostname != "app.example.test" || tunnelID != "tunnel-id" {
|
||||
return &routeTestError{}
|
||||
}
|
||||
f.deleted = true
|
||||
f.deleteCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
type routeTestError struct{}
|
||||
|
||||
func (*routeTestError) Error() string { return "unexpected route request" }
|
||||
|
||||
func TestCloudflareTunnelRouteCommandsUpdateEncryptedStateAndDNS(t *testing.T) {
|
||||
originalLoad := loadCloudflareTunnelConfig
|
||||
originalRead := readCloudflareTunnelSecrets
|
||||
originalWrite := writeCloudflareTunnelSecrets
|
||||
originalClient := newCloudflareRouteClient
|
||||
originalInitialize := initializeCloudflareTunnelOpenBao
|
||||
originalPath, originalHostname, originalService, originalZoneID := cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID
|
||||
t.Cleanup(func() {
|
||||
loadCloudflareTunnelConfig = originalLoad
|
||||
readCloudflareTunnelSecrets = originalRead
|
||||
writeCloudflareTunnelSecrets = originalWrite
|
||||
newCloudflareRouteClient = originalClient
|
||||
initializeCloudflareTunnelOpenBao = originalInitialize
|
||||
cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID = originalPath, originalHostname, originalService, originalZoneID
|
||||
})
|
||||
values, err := (cloudflare.StoredTunnel{
|
||||
Credentials: cloudflare.Credentials{AccountTag: "account-id", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel-id"},
|
||||
Config: cloudflare.NewConfig("tunnel-id"),
|
||||
}).Values()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := map[string]map[string]string{
|
||||
"platform/cloudflare": {"api-token": "test-api-token"},
|
||||
"platform/cloudflare-tunnel": values,
|
||||
}
|
||||
client := &fakeCloudflareRouteAPI{}
|
||||
loadCloudflareTunnelConfig = func(string) (config.Config, error) {
|
||||
return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil
|
||||
}
|
||||
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
|
||||
writes := 0
|
||||
writeCloudflareTunnelSecrets = func(_ string, _ string, secrets map[string]map[string]string) error {
|
||||
if writes == 0 && !client.ensured {
|
||||
t.Fatal("route add saved config before ensuring the CNAME")
|
||||
}
|
||||
if writes == 1 && client.deleted {
|
||||
t.Fatal("route remove deleted the CNAME before saving config")
|
||||
}
|
||||
state = secrets
|
||||
writes++
|
||||
return nil
|
||||
}
|
||||
newCloudflareRouteClient = func(token string) cloudflare.API {
|
||||
if token != "test-api-token" {
|
||||
t.Fatal("Cloudflare route command leaked the token beyond the API boundary")
|
||||
}
|
||||
return client
|
||||
}
|
||||
seeds := 0
|
||||
var seedErr error
|
||||
initializeCloudflareTunnelOpenBao = func(config.Config) error {
|
||||
stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
|
||||
wantIngress := 2
|
||||
if seeds%2 == 1 {
|
||||
wantIngress = 1
|
||||
}
|
||||
if err != nil || !present || len(stored.Config.Ingress) != wantIngress {
|
||||
t.Fatal("OpenBao was not seeded with the saved tunnel config")
|
||||
}
|
||||
seeds++
|
||||
return seedErr
|
||||
}
|
||||
cloudflareTunnelConfigPath = "bootstrap.yaml"
|
||||
cloudflareTunnelHostname = "app.example.test"
|
||||
cloudflareTunnelService = "http://service.default.svc:8080"
|
||||
cloudflareTunnelZoneID = "zone-id"
|
||||
if err := runCloudflareTunnelRoute(nil, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
|
||||
if err != nil || !present || len(stored.Config.Ingress) != 2 || !client.ensured || seeds != 1 {
|
||||
t.Fatal("route add did not save config and ensure the CNAME")
|
||||
}
|
||||
if err := runCloudflareTunnelRoute(nil, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
|
||||
if err != nil || !present || len(stored.Config.Ingress) != 1 || !client.deleted || seeds != 2 {
|
||||
t.Fatal("route remove did not save config and delete the CNAME")
|
||||
}
|
||||
client.ensureErr = errors.New("unavailable")
|
||||
if err := runCloudflareTunnelRoute(nil, true); err == nil {
|
||||
t.Fatal("route add accepted a CNAME failure")
|
||||
}
|
||||
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
|
||||
if err != nil || !present || len(stored.Config.Ingress) != 1 || writes != 2 || seeds != 2 {
|
||||
t.Fatal("route add saved ingress config after a CNAME failure")
|
||||
}
|
||||
client.ensureErr = nil
|
||||
seedErr = errors.New("unavailable")
|
||||
if err := runCloudflareTunnelRoute(nil, true); err == nil || strings.Contains(err.Error(), "unavailable") {
|
||||
t.Fatal("route add did not return a safe OpenBao seeding error")
|
||||
}
|
||||
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
|
||||
if err != nil || !present || len(stored.Config.Ingress) != 2 || writes != 3 || seeds != 3 {
|
||||
t.Fatal("route add did not retain DNS-safe state before OpenBao seeding failed")
|
||||
}
|
||||
deletes := client.deleteCalls
|
||||
if err := runCloudflareTunnelRoute(nil, false); err == nil || strings.Contains(err.Error(), "unavailable") {
|
||||
t.Fatal("route remove did not return a safe OpenBao seeding error")
|
||||
}
|
||||
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
|
||||
if err != nil || !present || len(stored.Config.Ingress) != 1 || writes != 4 || seeds != 4 || client.deleteCalls != deletes {
|
||||
t.Fatal("route remove deleted DNS before OpenBao seeding completed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudflareTunnelRouteErrorsDoNotRevealToken(t *testing.T) {
|
||||
originalLoad := loadCloudflareTunnelConfig
|
||||
originalRead := readCloudflareTunnelSecrets
|
||||
originalPath, originalHostname, originalService, originalZoneID := cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID
|
||||
t.Cleanup(func() {
|
||||
loadCloudflareTunnelConfig = originalLoad
|
||||
readCloudflareTunnelSecrets = originalRead
|
||||
cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID = originalPath, originalHostname, originalService, originalZoneID
|
||||
})
|
||||
loadCloudflareTunnelConfig = func(string) (config.Config, error) { return config.Config{}, nil }
|
||||
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) {
|
||||
return map[string]map[string]string{"platform/cloudflare": {"api-token": "test-api-token"}}, nil
|
||||
}
|
||||
cloudflareTunnelConfigPath = "bootstrap.yaml"
|
||||
cloudflareTunnelHostname = "app.example.test"
|
||||
cloudflareTunnelService = "http://service.default.svc:8080"
|
||||
cloudflareTunnelZoneID = "zone-id"
|
||||
err := runCloudflareTunnelRoute(nil, true)
|
||||
if err == nil || strings.Contains(err.Error(), "test-api-token") {
|
||||
t.Fatal("route command error revealed the Cloudflare token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudflareTunnelImportAcceptsAbsentOrLegacyState(t *testing.T) {
|
||||
originalLoad := loadCloudflareTunnelConfig
|
||||
originalRead := readCloudflareTunnelSecrets
|
||||
originalWrite := writeCloudflareTunnelSecrets
|
||||
originalInitialize := initializeCloudflareTunnelOpenBao
|
||||
originalCredentials := readCloudflareTunnelCredentials
|
||||
originalPath, originalCredentialsFile := cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile
|
||||
t.Cleanup(func() {
|
||||
loadCloudflareTunnelConfig = originalLoad
|
||||
readCloudflareTunnelSecrets = originalRead
|
||||
writeCloudflareTunnelSecrets = originalWrite
|
||||
initializeCloudflareTunnelOpenBao = originalInitialize
|
||||
readCloudflareTunnelCredentials = originalCredentials
|
||||
cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile = originalPath, originalCredentialsFile
|
||||
})
|
||||
loadCloudflareTunnelConfig = func(string) (config.Config, error) {
|
||||
return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil
|
||||
}
|
||||
cloudflareTunnelConfigPath = "bootstrap.yaml"
|
||||
cloudflareTunnelCredentialsFile = "local-credentials.json"
|
||||
for _, state := range []map[string]map[string]string{
|
||||
{},
|
||||
{"platform/cloudflare-tunnel": {"token": "legacy-run-token"}},
|
||||
} {
|
||||
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
|
||||
readCloudflareTunnelCredentials = func(path string) (cloudflare.Credentials, error) {
|
||||
if path != cloudflareTunnelCredentialsFile {
|
||||
t.Fatal("credentials were not read from the explicit file path")
|
||||
}
|
||||
return cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, nil
|
||||
}
|
||||
written := false
|
||||
writeCloudflareTunnelSecrets = func(_, _ string, values map[string]map[string]string) error {
|
||||
state = values
|
||||
written = true
|
||||
return nil
|
||||
}
|
||||
initializeCloudflareTunnelOpenBao = func(config.Config) error {
|
||||
stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
|
||||
if !written || err != nil || !present || len(stored.Config.Ingress) != 1 {
|
||||
t.Fatal("OpenBao was not seeded after encrypted tunnel state was saved")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := runCloudflareTunnelImport(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]); err != nil || !present || len(state["platform/cloudflare-tunnel"]) != 2 {
|
||||
t.Fatal("credentials file was not imported as managed tunnel state")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudflareTunnelImportRefusesManagedStateAndReturnsSafeSeedFailure(t *testing.T) {
|
||||
originalLoad := loadCloudflareTunnelConfig
|
||||
originalRead := readCloudflareTunnelSecrets
|
||||
originalWrite := writeCloudflareTunnelSecrets
|
||||
originalInitialize := initializeCloudflareTunnelOpenBao
|
||||
originalCredentials := readCloudflareTunnelCredentials
|
||||
originalPath, originalCredentialsFile := cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile
|
||||
t.Cleanup(func() {
|
||||
loadCloudflareTunnelConfig = originalLoad
|
||||
readCloudflareTunnelSecrets = originalRead
|
||||
writeCloudflareTunnelSecrets = originalWrite
|
||||
initializeCloudflareTunnelOpenBao = originalInitialize
|
||||
readCloudflareTunnelCredentials = originalCredentials
|
||||
cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile = originalPath, originalCredentialsFile
|
||||
})
|
||||
loadCloudflareTunnelConfig = func(string) (config.Config, error) {
|
||||
return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil
|
||||
}
|
||||
cloudflareTunnelConfigPath = "bootstrap.yaml"
|
||||
cloudflareTunnelCredentialsFile = "local-credentials.json"
|
||||
managed, err := (cloudflare.StoredTunnel{Credentials: cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, Config: cloudflare.NewConfig("tunnel")}).Values()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := map[string]map[string]string{"platform/cloudflare-tunnel": managed}
|
||||
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
|
||||
readCloudflareTunnelCredentials = func(string) (cloudflare.Credentials, error) {
|
||||
t.Fatal("managed state import read the credential file")
|
||||
return cloudflare.Credentials{}, nil
|
||||
}
|
||||
writeCloudflareTunnelSecrets = func(string, string, map[string]map[string]string) error {
|
||||
t.Fatal("managed state import overwrote encrypted state")
|
||||
return nil
|
||||
}
|
||||
initializeCloudflareTunnelOpenBao = func(config.Config) error {
|
||||
t.Fatal("managed state import seeded OpenBao")
|
||||
return nil
|
||||
}
|
||||
if err := runCloudflareTunnelImport(); err == nil || !strings.Contains(err.Error(), "refusing to overwrite") {
|
||||
t.Fatal("managed state import was not refused")
|
||||
}
|
||||
|
||||
state = map[string]map[string]string{}
|
||||
readCloudflareTunnelCredentials = func(string) (cloudflare.Credentials, error) {
|
||||
return cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, nil
|
||||
}
|
||||
written := false
|
||||
writeCloudflareTunnelSecrets = func(_, _ string, values map[string]map[string]string) error {
|
||||
written = true
|
||||
state = values
|
||||
return nil
|
||||
}
|
||||
initializeCloudflareTunnelOpenBao = func(config.Config) error {
|
||||
if !written {
|
||||
t.Fatal("OpenBao seeding ran before encrypted state was saved")
|
||||
}
|
||||
return errors.New("unavailable")
|
||||
}
|
||||
err = runCloudflareTunnelImport()
|
||||
if err == nil || strings.Contains(err.Error(), "unavailable") {
|
||||
t.Fatal("import did not return a safe OpenBao seeding error")
|
||||
}
|
||||
if _, present, parseErr := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]); parseErr != nil || !present {
|
||||
t.Fatal("import did not retain encrypted state after OpenBao seeding failed")
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
|
|
@ -11,15 +10,12 @@ import (
|
|||
var rootCmd = &cobra.Command{
|
||||
Use: "cicd-tool",
|
||||
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.
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
if !errors.Is(err, errE2EChecks) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
}
|
||||
fmt.Println(err)
|
||||
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.
|
||||
|
|
@ -1,266 +0,0 @@
|
|||
# Operations Runbook
|
||||
|
||||
Use this runbook from the MaidnCLI checkout. Bootstrap YAML, age identities,
|
||||
OpenBao recovery material, Terraform state, and generated workspaces are
|
||||
secret-bearing local inputs. Do not commit or print them.
|
||||
|
||||
## Normal Reconciliation
|
||||
|
||||
Run after a merged GitOps migration or to recover ordinary drift:
|
||||
|
||||
```powershell
|
||||
go run . bootstrap --config <private-bootstrap-config> --mode=reconcile
|
||||
```
|
||||
|
||||
This is the only regular lifecycle command. Do not use direct `kubectl apply`,
|
||||
`flux reconcile`, Helm upgrades, or mutating `talosctl` commands.
|
||||
|
||||
Bootstrap records the CICD, manifests, and Talos template commits in the
|
||||
workspace's `maidn-template-revisions.yaml` before it creates migration PRs or
|
||||
touches infrastructure. Later runs require the same template sources and refs
|
||||
and check out those commits, even when a configured branch advances. To accept
|
||||
new template revisions intentionally, use a new empty `workspaceDir` (and a
|
||||
fresh `cloneParent` when it is configured separately) and keep the prior
|
||||
secret-bearing workspace intact for recovery.
|
||||
|
||||
## 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
|
||||
|
||||
Use only when an authorized recovery requires recreating the Talos VM:
|
||||
|
||||
```powershell
|
||||
go run . bootstrap --config <private-bootstrap-config> --mode=rebuild --yes
|
||||
```
|
||||
|
||||
The rebuild replaces Terraform-managed Talos VMs only. It does not manage or
|
||||
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
|
||||
|
||||
After a rebuild or an OpenBao restart, refresh Kubernetes auth and reseed the
|
||||
encrypted operational values:
|
||||
|
||||
```powershell
|
||||
go run . bootstrap --config <private-bootstrap-config> --initialize-openbao
|
||||
```
|
||||
|
||||
When the Pipeline and EventListener are already Ready, register or update the
|
||||
Forgejo webhook without re-running the full lifecycle:
|
||||
|
||||
```powershell
|
||||
go run . bootstrap --config <private-bootstrap-config> --register-webhook
|
||||
```
|
||||
|
||||
The webhook authorization value stays in encrypted operational secrets and is
|
||||
never supplied on the command line.
|
||||
|
||||
If that authorization value is exposed, replace it and reconcile both OpenBao
|
||||
and the Forgejo hook in one command:
|
||||
|
||||
```powershell
|
||||
go run . bootstrap --config <private-bootstrap-config> --rotate-webhook-authorization
|
||||
```
|
||||
|
||||
## Read-Only Verification
|
||||
|
||||
Set `KUBECONFIG` to the generated kubeconfig for the configured cluster, then
|
||||
check the control plane and delivery chain:
|
||||
|
||||
```powershell
|
||||
kubectl -n flux-system get kustomizations
|
||||
kubectl get clustersecretstores
|
||||
kubectl -n flux-system get externalsecrets
|
||||
kubectl -n tekton-pipelines get pipelines,eventlisteners,externalsecrets
|
||||
```
|
||||
|
||||
Expected state:
|
||||
|
||||
- Flux Kustomizations are `READY=True`.
|
||||
- `ClusterSecretStore/openbao` is `READY=True`.
|
||||
- Forgejo credential ExternalSecrets are `SecretSynced`.
|
||||
- The application Pipeline exists and the EventListener is available.
|
||||
|
||||
If OpenBao authentication is invalid after a rebuild, run
|
||||
`--initialize-openbao`, then allow the controllers to retry. Do not recreate
|
||||
the ClusterSecretStore or Secrets manually.
|
||||
|
||||
## External DNS
|
||||
|
||||
Webhook delivery requires the configured `tekton.<cluster-domain>` hostname to
|
||||
resolve through Pi-hole to the Cilium Gateway address. ExternalDNS uses the
|
||||
Pi-hole provider with Gateway API routes, an `upsert-only` policy, and no
|
||||
ownership registry.
|
||||
|
||||
```powershell
|
||||
kubectl -n external-dns get pods,externalsecrets
|
||||
kubectl -n external-dns logs deployment/external-dns --tail=100
|
||||
```
|
||||
|
||||
If `ExternalSecret/pihole-credentials` is not `SecretSynced`, rerun
|
||||
`--prompt-operational-secrets` to enter the Pi-hole server and password, then
|
||||
run `--initialize-openbao` and `--register-webhook`. Do not create or edit the
|
||||
provider Secret directly.
|
||||
|
||||
## Webhook TLS
|
||||
|
||||
The public Gateway terminates HTTPS with a cert-manager certificate. The
|
||||
Cloudflare API token issues the `nid3.com` certificate and manages explicit
|
||||
Tunnel CNAME records. Pi-hole remains the ExternalDNS provider. Check
|
||||
certificate readiness with:
|
||||
|
||||
```powershell
|
||||
kubectl -n cert-manager get externalsecret cloudflare-api-token
|
||||
kubectl -n gateway-system get certificate webhook-tls
|
||||
```
|
||||
|
||||
Enter the Pi-hole values and Cloudflare API token through
|
||||
`--prompt-operational-secrets`, then import the user-approved local credentials
|
||||
file before bootstrap:
|
||||
|
||||
```powershell
|
||||
go run . cloudflare-tunnel import --config <private-bootstrap-config> --credentials-file <local-credentials-json>
|
||||
```
|
||||
|
||||
Do not put the Cloudflare token or tunnel credentials in the cluster repository.
|
||||
|
||||
## Cloudflare Tunnel Routes
|
||||
|
||||
After importing the credentials, manage one explicit proxied CNAME and ingress
|
||||
rule at a time:
|
||||
|
||||
```powershell
|
||||
go run . cloudflare-tunnel route add --config <private-bootstrap-config> --hostname <public-hostname> --service <http-or-https-upstream-url> --zone-id <cloudflare-zone-id>
|
||||
go run . cloudflare-tunnel route remove --config <private-bootstrap-config> --hostname <public-hostname> --service <http-or-https-upstream-url> --zone-id <cloudflare-zone-id>
|
||||
```
|
||||
|
||||
Both commands require all three route values, preserve the terminal 404 rule,
|
||||
and refuse an existing CNAME that does not point to the managed tunnel.
|
||||
|
||||
## Internal Platform UIs
|
||||
|
||||
Pi-hole resolves these HTTPS names to the Cilium Gateway only on the LAN:
|
||||
|
||||
- `https://grafana.<cluster-domain>/` for Grafana. Authenticate with Grafana.
|
||||
- `https://openbao.<cluster-domain>/` for OpenBao. Authenticate with an OpenBao token.
|
||||
|
||||
Hubble UI is enabled for in-cluster troubleshooting but has no LAN route because
|
||||
it does not provide authentication. Add an authenticated proxy before exposing
|
||||
it outside the cluster.
|
||||
|
||||
## Webhook Smoke Test
|
||||
|
||||
Use Forgejo's hook test endpoint against an existing non-`main` ref. It emits
|
||||
a real push delivery, runs the Node build, and pushes a SHA-tagged registry
|
||||
image, but skips the main-only staging manifest update:
|
||||
|
||||
```powershell
|
||||
# Discover the hook ID and choose an existing non-main branch or tag.
|
||||
Invoke-RestMethod -Headers @{ Authorization = "token $env:FORGEJO_TOKEN" } `
|
||||
-Uri "https://<forgejo>/api/v1/repos/<owner>/<repo>/hooks"
|
||||
|
||||
Invoke-WebRequest -Method Post -Headers @{ Authorization = "token $env:FORGEJO_TOKEN" } `
|
||||
-Uri "https://<forgejo>/api/v1/repos/<owner>/<repo>/hooks/<hook-id>/tests?ref=<non-main-ref>"
|
||||
```
|
||||
|
||||
Forgejo returns `204` after accepting the delivery. Confirm the resulting
|
||||
PipelineRun instead of treating `204` as a successful build:
|
||||
|
||||
```powershell
|
||||
kubectl -n tekton-pipelines get pipelineruns
|
||||
kubectl -n tekton-pipelines describe pipelinerun <name>
|
||||
```
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
# Credential rotation runbook
|
||||
|
||||
## Authorization and inputs
|
||||
|
||||
Before any live action, obtain approval for the exact `<credential-identifier>`, `<credential-purpose>`, `<approved-scope>`, `<consumer-inventory>`, `<overlap-window>`, `<maintenance-window>`, and `<rollback-owner>`. The authorized operator must have access to the approved configuration, SOPS identity, recovery identity and encrypted recovery bundle, OpenBao recovery recipient, and the required Forgejo, Flux, webhook, Tekton, registry, and cluster permissions. Credential values must be entered only through an approved secure prompt or standard input boundary; never provide them to an agent or place them in an argument, file, log, or evidence record.
|
||||
|
||||
## Consumer inventory template
|
||||
|
||||
| Consumer | Logical secret path | Credential identifier | Owner | Validation | Status |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `<consumer>` | `<encrypted-logical-path>` | `<credential-identifier>` | `<owner>` | `<sanitized-check>` | `<pending>` |
|
||||
|
||||
Include Forgejo API access, Flux source authentication, webhook authorization, Tekton pipeline consumers, and registry pull/push consumers when applicable. Stop for an unlisted or ambiguous consumer; do not guess its credential source.
|
||||
|
||||
## Ordered rotation
|
||||
|
||||
1. Confirm the old credential remains valid for the approved overlap window and capture the sanitized baseline status.
|
||||
2. Create the replacement with the approved minimum scope. Do not revoke or replace the old credential yet.
|
||||
3. Put the replacement only in `sops.operationalSecretsPath`; encrypt it with `sops.ageKeyPath`. Keep the recovery identity and encrypted recovery bundle in their configured local paths. Do not write plaintext configuration or generated files.
|
||||
4. Reseed OpenBao from the encrypted operational-secrets file. Verify OpenBao is initialized, unsealed, and ready, then verify External Secrets has refreshed the intended target Secret without reading or printing its data.
|
||||
5. Validate each inventoried consumer: Forgejo authenticated operation, Flux source authentication and reconciliation readiness, webhook delivery authorization, Tekton trigger and PipelineRun behavior, and registry pull/push behavior. Record only identifiers, timestamps, and pass/fail status.
|
||||
6. If every validation passes during the overlap window, revoke the old credential through its owning system and repeat the affected consumer checks.
|
||||
|
||||
## Forgejo registry package-write recovery
|
||||
|
||||
Use this procedure when a Tekton image push fails with `401 Unauthorized: reqPackageAccess` for the target organization.
|
||||
|
||||
1. Obtain approval for package write access to the named Forgejo organization. Do not reuse a repository-status token or a user-wide credential without this approval.
|
||||
2. From the configured MaidnCLI checkout, run the prompted command below. Enter the Forgejo password and optional OTP only at its secure prompts.
|
||||
|
||||
```powershell
|
||||
cicd-tool bootstrap --config <bootstrap-config> --create-forgejo-registry-token
|
||||
```
|
||||
|
||||
3. The command creates or rotates the registry credential, saves its Docker configuration only in encrypted operational secrets, seeds OpenBao, and refreshes `tekton-pipelines/forgejo-registry-credentials`. Never copy the generated token or Docker configuration into a shell command, manifest, or report.
|
||||
4. Confirm the ExternalSecret is ready without reading Secret data, then retry one disposable PipelineRun targeting the approved organization. Record only the image repository, PipelineRun name, and pass/fail result.
|
||||
5. If the retry still returns `reqPackageAccess`, stop. Confirm the token owner has package write permission for the target organization and create a replacement through the same prompted command. Do not broaden application, Forgejo status, or webhook credentials as a workaround.
|
||||
|
||||
## Rollback
|
||||
|
||||
If OpenBao reseed, readiness, External Secrets refresh, or any consumer validation fails, stop before revocation. Restore the previously encrypted operational-secret version, reseed OpenBao, verify readiness and all affected consumers, and keep the old credential active. Escalate if the previous encrypted version or recovery material is unavailable; do not reconstruct values from logs or configuration.
|
||||
|
||||
## Sanitized evidence template
|
||||
|
||||
| Field | Record |
|
||||
| --- | --- |
|
||||
| Rotation ID | `<rotation-id>` |
|
||||
| Credential identifier | `<credential-identifier>` |
|
||||
| Scope approval | `<approval-reference>` |
|
||||
| Operator | `<operator-id>` |
|
||||
| Started / completed | `<timestamp>` / `<timestamp>` |
|
||||
| OpenBao initialized / unsealed / ready | `<status>` / `<status>` / `<status>` |
|
||||
| External Secrets target refresh | `<target-identifier>: <status>` |
|
||||
| Forgejo / Flux / webhook / Tekton / registry | `<status>` / `<status>` / `<status>` / `<status>` / `<status>` |
|
||||
| Old credential revocation | `<not-attempted|completed|rolled-back>` |
|
||||
| Follow-up | `<sanitized-reference>` |
|
||||
|
||||
Never include values, encoded values, headers, token fragments, private keys, recovery shares, or decrypted manifest content in the evidence.
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,34 +1,47 @@
|
|||
# Secrets and recovery material
|
||||
# Bootstrap Secret Inputs
|
||||
|
||||
## Storage contract
|
||||
`bootstrap` uses the Flux age identity in ignored `.age/` storage to encrypt
|
||||
the configured Democratic CSI Secret directly into the generated cluster
|
||||
repository. Its TrueNAS API key is never printed or committed in plaintext.
|
||||
|
||||
- `sops.operationalSecretsPath` defaults to `<workspace>/operational-secrets.sops.yaml`. It is a SOPS-encrypted input containing operational secrets, keyed by logical OpenBao paths. It is never plaintext Git content.
|
||||
- `sops.ageKeyPath` defaults to `<workspace>/.age/key.txt`. This Flux SOPS identity is private local material; do not commit, print, or copy it.
|
||||
- `sops.recoveryIdentityPath` defaults to `<workspace>/.age/recovery-key.txt`. This separate OpenBao-recovery identity is private local material; do not commit, print, or copy it.
|
||||
- `sops.recoveryBundlePath` defaults to `<workspace>/.recovery/openbao-recovery.age`. This bundle is encrypted to `sops.recoveryRecipient`; it contains OpenBao recovery material and is never plaintext Git content.
|
||||
- During generated Flux repository reconciliation, recovery material is rendered only into `base/openbao/unseal.sops.yaml`, encrypted with the Flux SOPS identity. This is the narrowly scoped encrypted recovery path for the `openbao-unseal` controller; no plaintext recovery material belongs in a workload, config, log, or command argument.
|
||||
`operational-secrets.sops.yaml` is decrypted only in MaidnCLI memory after
|
||||
OpenBao is initialized. It is not copied to the cluster repository. Its schema
|
||||
is:
|
||||
|
||||
## Bootstrap gates
|
||||
```yaml
|
||||
secrets:
|
||||
cicd/forgejo:
|
||||
username: encrypted-value
|
||||
token: encrypted-value
|
||||
cicd/forgejo-registry:
|
||||
dockerconfigjson: encrypted-value
|
||||
cicd/forgejo-webhook:
|
||||
authorization: encrypted-value
|
||||
platform/cloudflare:
|
||||
api-token: encrypted-value
|
||||
platform/cloudflare-tunnel:
|
||||
token: encrypted-value
|
||||
```
|
||||
|
||||
`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.
|
||||
Keys are written to OpenBao KV v2 under `secret/<path>`. Additional paths are
|
||||
allowed when they use lowercase path characters and scalar property names.
|
||||
|
||||
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.
|
||||
Set all `democraticCsi` settings in the bootstrap configuration or provide
|
||||
them through the interactive wizard. The CLI writes those values only to
|
||||
`base/democratic-csi/secret.sops.yaml` in the generated cluster repository.
|
||||
For an existing configuration, run `bootstrap --config <path>
|
||||
--prompt-democratic-csi` to enter the settings with the API key masked.
|
||||
|
||||
## Rules
|
||||
For a new operational-secret input, run `bootstrap --config <path>
|
||||
--prompt-operational-secrets`. It derives Forgejo Git and registry credentials
|
||||
from the configured Forgejo account, prompts for Cloudflare credentials with
|
||||
input masked, and generates the webhook authorization value.
|
||||
|
||||
- Credential values are accepted only at an approved secure input boundary and stored only in encrypted operational or recovery material.
|
||||
- Never pass credential values in CLI arguments, URLs, logs, Git commits, generated config, tickets, or evidence.
|
||||
- Do not revoke a previous credential until OpenBao, External Secrets, and every listed consumer have passed validation.
|
||||
- Use the sanitized procedure in [runbooks/credential-rotation.md](runbooks/credential-rotation.md) for any live rotation.
|
||||
- Application, artifact, and shared-secret access is documented in [secret-grants.md](secret-grants.md). Secret values remain outside that declaration.
|
||||
`cicd/forgejo-webhook.authorization` is required for delivery bootstrap. The
|
||||
CLI supplies it as the Forgejo webhook Authorization header and Tekton compares
|
||||
that header against the ExternalSecret-derived `forgejo-webhook` Secret.
|
||||
|
||||
## 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.
|
||||
Run `bootstrap --config <path> --initialize-openbao-recovery` to create and
|
||||
save a separate recovery age identity for `openbao-recovery.age`. The Flux SOPS
|
||||
age identity is installed in `flux-system`; it must not encrypt OpenBao
|
||||
recovery material.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,3 @@ var ManifestsReadmeTmpl string
|
|||
|
||||
//go:embed templates/flux.md.tmpl
|
||||
var FluxReadmeTmpl string
|
||||
|
||||
//go:embed templates/delivery-pipeline.yaml.tmpl
|
||||
var DeliveryPipelineTmpl string
|
||||
|
|
|
|||
|
|
@ -1,826 +0,0 @@
|
|||
apiVersion: tekton.dev/v1
|
||||
kind: Task
|
||||
metadata:
|
||||
name: {{ .AppName }}-update-manifest
|
||||
namespace: tekton-pipelines
|
||||
spec:
|
||||
stepTemplate:
|
||||
env:
|
||||
- name: HOME
|
||||
value: /tekton/home
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
params:
|
||||
- name: manifests-url
|
||||
- name: manifests-branch
|
||||
- name: app-name
|
||||
- name: app-repository
|
||||
- name: image
|
||||
- name: tag
|
||||
- name: environment
|
||||
- name: pr-number
|
||||
default: ""
|
||||
- name: forgejo-base-url
|
||||
default: ""
|
||||
- name: forgejo-owner
|
||||
default: ""
|
||||
- name: manifests-repo
|
||||
default: ""
|
||||
steps:
|
||||
- name: update
|
||||
image: alpine/git:2.47.2
|
||||
env:
|
||||
- name: MANIFESTS_URL
|
||||
value: $(params.manifests-url)
|
||||
- name: MANIFESTS_BRANCH
|
||||
value: $(params.manifests-branch)
|
||||
- name: APP_NAME
|
||||
value: $(params.app-name)
|
||||
- name: APP_REPOSITORY
|
||||
value: $(params.app-repository)
|
||||
- name: IMAGE
|
||||
value: $(params.image)
|
||||
- name: TAG
|
||||
value: $(params.tag)
|
||||
- name: ENVIRONMENT
|
||||
value: $(params.environment)
|
||||
- name: PR_NUMBER
|
||||
value: $(params.pr-number)
|
||||
- name: FORGEJO_BASE_URL
|
||||
value: $(params.forgejo-base-url)
|
||||
- name: FORGEJO_OWNER
|
||||
value: $(params.forgejo-owner)
|
||||
- name: MANIFESTS_REPO
|
||||
value: $(params.manifests-repo)
|
||||
script: |
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
fail() { exit 1; }
|
||||
valid_name() { case "$1" in ''|*[!a-z0-9-]*|-*|*-) fail ;; esac; }
|
||||
valid_revision() { case "$1" in [A-Za-z0-9]*) ;; *) fail ;; esac; case "$1" in *[!A-Za-z0-9._/-]*|*..*|*//*|*/) 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_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_name "$APP_NAME"
|
||||
valid_url "$MANIFESTS_URL"
|
||||
valid_revision "$MANIFESTS_BRANCH"
|
||||
valid_repository "$APP_REPOSITORY"
|
||||
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 -C /tmp/manifests checkout -b "$PROMOTION_BRANCH"
|
||||
fi
|
||||
else
|
||||
git clone --branch "$MANIFESTS_BRANCH" "$MANIFESTS_URL" /tmp/manifests
|
||||
fi
|
||||
cd /tmp/manifests
|
||||
if [ "$ENVIRONMENT" = preview ]; then
|
||||
valid_pr_number "$PR_NUMBER"
|
||||
app_dir="apps/previews/$APP_NAME-pr-$PR_NUMBER"
|
||||
marker="$app_dir/ownership.yaml"
|
||||
if [ -e "$app_dir" ]; then
|
||||
[ -d "$app_dir" ] && [ ! -L "$app_dir" ] && [ -f "$marker" ] && [ ! -L "$marker" ] || fail
|
||||
expected_marker=$(mktemp)
|
||||
trap 'rm -f "$expected_marker"' EXIT
|
||||
cat > "$expected_marker" <<EOF
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: maidn-preview-owner
|
||||
namespace: $APP_NAME-pr-$PR_NUMBER
|
||||
labels:
|
||||
maidn.io/preview-owner: "true"
|
||||
maidn.io/preview-app: "$APP_NAME"
|
||||
maidn.io/preview-pr: "$PR_NUMBER"
|
||||
annotations:
|
||||
maidn.io/preview-repository: "$APP_REPOSITORY"
|
||||
data:
|
||||
app: "$APP_NAME"
|
||||
repository: "$APP_REPOSITORY"
|
||||
pr-number: "$PR_NUMBER"
|
||||
EOF
|
||||
cmp -s "$expected_marker" "$marker" || fail
|
||||
fi
|
||||
mkdir -p "$app_dir"
|
||||
cat > "$marker" <<EOF
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: maidn-preview-owner
|
||||
namespace: $APP_NAME-pr-$PR_NUMBER
|
||||
labels:
|
||||
maidn.io/preview-owner: "true"
|
||||
maidn.io/preview-app: "$APP_NAME"
|
||||
maidn.io/preview-pr: "$PR_NUMBER"
|
||||
annotations:
|
||||
maidn.io/preview-repository: "$APP_REPOSITORY"
|
||||
data:
|
||||
app: "$APP_NAME"
|
||||
repository: "$APP_REPOSITORY"
|
||||
pr-number: "$PR_NUMBER"
|
||||
EOF
|
||||
cat > "$app_dir/namespace.yaml" <<EOF
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: $APP_NAME-pr-$PR_NUMBER
|
||||
EOF
|
||||
cat > "$app_dir/release.yaml" <<EOF
|
||||
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: $APP_NAME
|
||||
namespace: $APP_NAME-pr-$PR_NUMBER
|
||||
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
|
||||
- ./preview/values.yaml
|
||||
values:
|
||||
image:
|
||||
repository: $IMAGE
|
||||
tag: $TAG
|
||||
gateway:
|
||||
hostname: $APP_NAME-pr-$PR_NUMBER.{{ .ClusterDomain }}
|
||||
EOF
|
||||
cat > "$app_dir/kustomization.yaml" <<EOF
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: $APP_NAME-pr-$PR_NUMBER
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- ownership.yaml
|
||||
- release.yaml
|
||||
EOF
|
||||
root=apps/previews/kustomization.yaml
|
||||
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"
|
||||
elif [ "$ENVIRONMENT" = staging ]; then
|
||||
app_dir="apps/staging/$APP_NAME"
|
||||
root=apps/staging/kustomization.yaml
|
||||
if [ -e "$app_dir/release.yaml" ]; then
|
||||
[ -d "$app_dir" ] && [ ! -L "$app_dir" ] && [ -f "$app_dir/release.yaml" ] && [ ! -L "$app_dir/release.yaml" ] || fail
|
||||
sed -i -E "s|^([[:space:]]*tag:).*|\1 $TAG|" "$app_dir/release.yaml"
|
||||
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
|
||||
fail
|
||||
fi
|
||||
git config user.name Maidn
|
||||
git config user.email maidn@free-maidn.com
|
||||
git add apps
|
||||
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"
|
||||
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
|
||||
kind: Pipeline
|
||||
metadata:
|
||||
name: {{ .AppName }}
|
||||
namespace: tekton-pipelines
|
||||
spec:
|
||||
params:
|
||||
- name: git-url
|
||||
default: {{ quote .AppRepoURL }}
|
||||
- name: git-revision
|
||||
default: {{ quote .AppRepoRef }}
|
||||
- name: branch
|
||||
default: {{ quote .AppRepoRef }}
|
||||
- name: event-type
|
||||
default: push
|
||||
- name: event-action
|
||||
default: ""
|
||||
- name: pr-number
|
||||
default: ""
|
||||
- name: image
|
||||
default: {{ quote .ImageRepository }}
|
||||
- name: manifests-url
|
||||
default: {{ quote .ManifestsURL }}
|
||||
- name: manifests-branch
|
||||
default: {{ quote .ManifestsBranch }}
|
||||
- name: forgejo-base-url
|
||||
default: {{ quote .ForgejoBaseURL }}
|
||||
- name: forgejo-owner
|
||||
default: {{ quote .ForgejoOwner }}
|
||||
- name: manifests-repo
|
||||
default: {{ quote .ManifestsRepo }}
|
||||
tasks:
|
||||
- name: build-layer
|
||||
when:
|
||||
- input: $(params.event-action)
|
||||
operator: notin
|
||||
values: [closed]
|
||||
taskRef:
|
||||
name: {{ if eq .BuildStrategy "static" }}maidn-node-static-image{{ else }}maidn-node-runtime-image{{ end }}
|
||||
params:
|
||||
- name: url
|
||||
value: {{ quote .AppRepoURL }}
|
||||
- name: revision
|
||||
value: $(params.git-revision)
|
||||
- name: image
|
||||
value: $(params.image)
|
||||
{{ if eq .BuildStrategy "static" }}
|
||||
- name: output-directory
|
||||
value: {{ quote .BuildOutputDirectory }}
|
||||
- name: build-configuration
|
||||
value: {{ quote .BuildConfiguration }}
|
||||
{{ end }}
|
||||
- name: update-preview
|
||||
runAfter: [build-layer]
|
||||
when:
|
||||
- input: $(params.event-type)
|
||||
operator: in
|
||||
values: [pull_request]
|
||||
- input: $(params.event-action)
|
||||
operator: in
|
||||
values: [opened, reopened, synchronize]
|
||||
taskRef:
|
||||
name: {{ .AppName }}-update-manifest
|
||||
params:
|
||||
- name: manifests-url
|
||||
value: $(params.manifests-url)
|
||||
- name: manifests-branch
|
||||
value: $(params.manifests-branch)
|
||||
- name: app-name
|
||||
value: {{ quote .AppName }}
|
||||
- name: app-repository
|
||||
value: {{ quote .AppRepository }}
|
||||
- name: image
|
||||
value: $(params.image)
|
||||
- name: tag
|
||||
value: $(params.git-revision)
|
||||
- name: environment
|
||||
value: preview
|
||||
- name: pr-number
|
||||
value: $(params.pr-number)
|
||||
- name: update-staging
|
||||
runAfter: [build-layer]
|
||||
when:
|
||||
- input: $(params.event-type)
|
||||
operator: in
|
||||
values: [push]
|
||||
- input: $(params.branch)
|
||||
operator: in
|
||||
values: [{{ quote .AppRepoRef }}]
|
||||
taskRef:
|
||||
name: {{ .AppName }}-update-manifest
|
||||
params:
|
||||
- name: manifests-url
|
||||
value: $(params.manifests-url)
|
||||
- name: manifests-branch
|
||||
value: $(params.manifests-branch)
|
||||
- name: app-name
|
||||
value: {{ quote .AppName }}
|
||||
- name: app-repository
|
||||
value: {{ quote .AppRepository }}
|
||||
- name: image
|
||||
value: $(params.image)
|
||||
- name: tag
|
||||
value: $(params.git-revision)
|
||||
- name: environment
|
||||
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
|
||||
runAfter: [build-layer]
|
||||
when:
|
||||
- input: $(params.event-type)
|
||||
operator: in
|
||||
values: [promotion]
|
||||
- input: $(params.branch)
|
||||
operator: in
|
||||
values: [{{ quote .ProductionBranch }}]
|
||||
taskRef:
|
||||
name: {{ .AppName }}-update-manifest
|
||||
params:
|
||||
- name: manifests-url
|
||||
value: $(params.manifests-url)
|
||||
- name: manifests-branch
|
||||
value: $(params.manifests-branch)
|
||||
- name: app-name
|
||||
value: {{ quote .AppName }}
|
||||
- name: app-repository
|
||||
value: {{ quote .AppRepository }}
|
||||
- name: image
|
||||
value: $(params.image)
|
||||
- name: tag
|
||||
value: $(params.git-revision)
|
||||
- name: environment
|
||||
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
|
||||
when:
|
||||
- input: $(params.event-type)
|
||||
operator: in
|
||||
values: [pull_request]
|
||||
- input: $(params.event-action)
|
||||
operator: in
|
||||
values: [closed]
|
||||
taskRef:
|
||||
name: maidn-preview-orphan-reconciler
|
||||
params:
|
||||
- name: app-name
|
||||
value: {{ quote .AppName }}
|
||||
- name: pr-number
|
||||
value: $(params.pr-number)
|
||||
- name: app-repository
|
||||
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)
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,317 +0,0 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||
)
|
||||
|
||||
func TestEnsureTemplateRevisionsLocksFirstCheckout(t *testing.T) {
|
||||
workspace, cfg, git := templateRevisionTestConfig(t)
|
||||
useTemplateRevisionGit(t, git)
|
||||
|
||||
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lock, err := readTemplateRevisionLock(filepath.Join(workspace, "maidn-template-revisions.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lock.CICD.Commit != git.commits["cicd"] || lock.Manifests.Commit != git.commits["manifests"] || lock.Talos.Commit != git.commits["talos"] {
|
||||
t.Fatalf("lock did not record checked-out commits: %#v", lock)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(workspace, "maidn-template-revisions.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "template-password") || strings.Contains(git.commands(), "template-password") {
|
||||
t.Fatal("template credentials reached the lock or Git command arguments")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureTemplateRevisionsReusesLockedCommitAfterBranchDrift(t *testing.T) {
|
||||
workspace, cfg, git := templateRevisionTestConfig(t)
|
||||
useTemplateRevisionGit(t, git)
|
||||
|
||||
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
locked := map[string]string{}
|
||||
for name, commit := range git.commits {
|
||||
locked[name] = commit
|
||||
}
|
||||
git.commits = map[string]string{"cicd": strings.Repeat("d", 40), "manifests": strings.Repeat("e", 40), "talos": strings.Repeat("f", 40)}
|
||||
git.resetCalls()
|
||||
if err := ensureTemplateRevisions(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 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") {
|
||||
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) {
|
||||
workspace, cfg, git := templateRevisionTestConfig(t)
|
||||
useTemplateRevisionGit(t, git)
|
||||
|
||||
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
git.resetCalls()
|
||||
cfg.Templates.CICDRepoRef = "release"
|
||||
err := ensureTemplateRevisions(workspace, cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "workspace revision lock") || !strings.Contains(err.Error(), "new empty workspaceDir") {
|
||||
t.Fatalf("changed ref error was not safe and actionable: %v", err)
|
||||
}
|
||||
if git.commands() != "" {
|
||||
t.Fatal("changed ref touched Git before rejecting the lock mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureTemplateRevisionsHidesSourceWhenLockedCommitIsUnavailable(t *testing.T) {
|
||||
workspace, cfg, git := templateRevisionTestConfig(t)
|
||||
useTemplateRevisionGit(t, git)
|
||||
|
||||
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
git.failFetch = true
|
||||
err := ensureTemplateRevisions(workspace, cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "locked template revision cannot be resolved") || !strings.Contains(err.Error(), "new empty workspaceDir") || strings.Contains(err.Error(), "template-password") {
|
||||
t.Fatalf("locked revision failure exposed source details or lacked recovery guidance: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureTemplateRevisionsAllowsOnlyGeneratedTalosChanges(t *testing.T) {
|
||||
workspace, cfg, git := templateRevisionTestConfig(t)
|
||||
useTemplateRevisionGit(t, git)
|
||||
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
git.dirty["talos"] = []string{" M generated/talsecret.yaml", "?? generated/clusterconfig/talosconfig"}
|
||||
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
|
||||
t.Fatalf("generated Talos changes blocked reconciliation: %v", err)
|
||||
}
|
||||
git.dirty["talos"] = []string{" M terraform/main.tf"}
|
||||
if err := ensureTemplateRevisions(workspace, cfg); err == nil {
|
||||
t.Fatal("Talos changes outside generated directory were accepted")
|
||||
}
|
||||
if !hasUnexpectedTemplateChanges([]byte(" M generated/../terraform/main.tf\x00"), "generated") {
|
||||
t.Fatal("Talos changes escaping the generated directory were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateGitRunnerUsesTemporaryAskPassForPrivateSource(t *testing.T) {
|
||||
original := runGitEnvironment
|
||||
t.Cleanup(func() { runGitEnvironment = original })
|
||||
var environment []string
|
||||
var command string
|
||||
runGitEnvironment = func(_ string, env []string, args ...string) ([]byte, error) {
|
||||
environment = env
|
||||
command = strings.Join(args, " ")
|
||||
return nil, nil
|
||||
}
|
||||
source := "https://reader:template-password@git.example.test/templates/cicd.git"
|
||||
repository := config.RedactURL(source)
|
||||
run, cleanup, err := templateGitRunner(config.Config{}, source, repository)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
if _, err := run("", "clone", repository, "checkout"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var askPass string
|
||||
for _, item := range environment {
|
||||
if strings.HasPrefix(item, "GIT_ASKPASS=") {
|
||||
askPass = strings.TrimPrefix(item, "GIT_ASKPASS=")
|
||||
}
|
||||
}
|
||||
if askPass == "" || strings.Contains(command, "template-password") || strings.Contains(command, "reader:") {
|
||||
t.Fatalf("private template checkout did not isolate credentials: command=%q", command)
|
||||
}
|
||||
if _, err := os.Stat(askPass); err != nil {
|
||||
t.Fatalf("temporary askpass script was not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureTemplateRevisionsRejectsCredentialBearingExistingOrigin(t *testing.T) {
|
||||
workspace, cfg, git := templateRevisionTestConfig(t)
|
||||
useTemplateRevisionGit(t, git)
|
||||
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
git.origins[filepath.Join(workspace, "maidn-cicd-cluster-template")] = cfg.Templates.CICDRepoURL
|
||||
if err := ensureTemplateRevisions(workspace, cfg); err == nil || strings.Contains(err.Error(), "template-password") {
|
||||
t.Fatalf("credential-bearing checkout origin was accepted or exposed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func templateRevisionTestConfig(t *testing.T) (string, config.Config, *fakeTemplateGit) {
|
||||
t.Helper()
|
||||
workspace := t.TempDir()
|
||||
cloneParent := filepath.Join(workspace, "checkouts")
|
||||
if err := os.MkdirAll(cloneParent, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
git := &fakeTemplateGit{
|
||||
commits: map[string]string{
|
||||
"cicd": strings.Repeat("a", 40),
|
||||
"manifests": strings.Repeat("b", 40),
|
||||
"talos": strings.Repeat("c", 40),
|
||||
},
|
||||
origins: map[string]string{},
|
||||
fetched: map[string]string{},
|
||||
checkedOut: map[string]string{},
|
||||
dirty: map[string][]string{},
|
||||
}
|
||||
return workspace, config.Config{
|
||||
WorkspaceDir: workspace,
|
||||
Git: config.GitConfig{CloneParent: cloneParent},
|
||||
Talos: config.TalosConfig{RepoDirName: "talos", GeneratedDir: "generated"},
|
||||
Templates: config.TemplateConfig{
|
||||
CICDRepoURL: "https://reader:template-password@git.example.test/templates/cicd.git",
|
||||
CICDRepoRef: "main",
|
||||
ManifestsRepoURL: "https://git.example.test/templates/manifests.git",
|
||||
ManifestsRepoRef: "main",
|
||||
TalosRepoURL: "https://git.example.test/templates/talos.git",
|
||||
TalosRepoRef: "main",
|
||||
},
|
||||
}, git
|
||||
}
|
||||
|
||||
func useTemplateRevisionGit(t *testing.T, git *fakeTemplateGit) {
|
||||
t.Helper()
|
||||
original := runGit
|
||||
runGit = git.run
|
||||
originalEnvironment := runGitEnvironment
|
||||
runGitEnvironment = func(dir string, _ []string, args ...string) ([]byte, error) { return git.run(dir, args...) }
|
||||
t.Cleanup(func() {
|
||||
runGit = original
|
||||
runGitEnvironment = originalEnvironment
|
||||
})
|
||||
}
|
||||
|
||||
type fakeTemplateGit struct {
|
||||
commits map[string]string
|
||||
origins map[string]string
|
||||
fetched map[string]string
|
||||
checkedOut map[string]string
|
||||
dirty map[string][]string
|
||||
calls []string
|
||||
failFetch bool
|
||||
}
|
||||
|
||||
func (git *fakeTemplateGit) run(dir string, args ...string) ([]byte, error) {
|
||||
git.calls = append(git.calls, strings.Join(args, " "))
|
||||
if len(args) == 0 {
|
||||
return nil, errors.New("missing Git command")
|
||||
}
|
||||
switch args[0] {
|
||||
case "clone":
|
||||
dir = args[len(args)-1]
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
git.origins[dir] = args[len(args)-2]
|
||||
return nil, nil
|
||||
case "remote":
|
||||
return []byte(git.origins[dir] + "\n"), nil
|
||||
case "status":
|
||||
if records := git.dirty[git.templateName(dir)]; len(records) != 0 {
|
||||
return []byte(strings.Join(records, "\x00") + "\x00"), nil
|
||||
}
|
||||
return nil, nil
|
||||
case "fetch":
|
||||
if git.failFetch {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
target := args[len(args)-1]
|
||||
name := git.templateName(dir)
|
||||
if len(target) == 40 {
|
||||
git.fetched[name] = target
|
||||
} else {
|
||||
git.fetched[name] = git.commits[name]
|
||||
}
|
||||
return nil, nil
|
||||
case "checkout":
|
||||
git.checkedOut[git.templateName(dir)] = args[len(args)-1]
|
||||
return nil, nil
|
||||
case "rev-parse":
|
||||
if len(args) == 2 && args[1] == "--is-inside-work-tree" {
|
||||
return []byte("true\n"), nil
|
||||
}
|
||||
target := args[len(args)-1]
|
||||
if target == "FETCH_HEAD^{commit}" {
|
||||
return []byte(git.fetched[git.templateName(dir)] + "\n"), nil
|
||||
}
|
||||
if target == "HEAD^{commit}" {
|
||||
return []byte(git.checkedOut[git.templateName(dir)] + "\n"), nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("unexpected Git command")
|
||||
}
|
||||
|
||||
func (git *fakeTemplateGit) templateName(dir string) string {
|
||||
switch filepath.Base(dir) {
|
||||
case "maidn-cicd-cluster-template":
|
||||
return "cicd"
|
||||
case "cicd-deployment-manifests-template":
|
||||
return "manifests"
|
||||
default:
|
||||
return "talos"
|
||||
}
|
||||
}
|
||||
|
||||
func (git *fakeTemplateGit) resetCalls() {
|
||||
git.calls = nil
|
||||
}
|
||||
|
||||
func (git *fakeTemplateGit) commands() string {
|
||||
return strings.Join(git.calls, "\n")
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
package cloudflare
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const apiURL = "https://api.cloudflare.com/client/v4"
|
||||
|
||||
// API is the narrow Cloudflare DNS boundary used by route commands.
|
||||
type API interface {
|
||||
EnsureCNAME(context.Context, string, string, string) error
|
||||
DeleteCNAME(context.Context, string, string, string) error
|
||||
}
|
||||
|
||||
type dnsRecord struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Proxied bool `json:"proxied"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
token string
|
||||
}
|
||||
|
||||
func NewClient(token string) *Client {
|
||||
return &Client{baseURL: apiURL, httpClient: &http.Client{Timeout: 15 * time.Second}, token: token}
|
||||
}
|
||||
|
||||
func (c *Client) EnsureCNAME(ctx context.Context, zoneID, hostname, tunnelID string) error {
|
||||
records, err := c.cnameRecords(ctx, zoneID, hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := tunnelTarget(tunnelID)
|
||||
if len(records) == 0 {
|
||||
body, err := json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Proxied bool `json:"proxied"`
|
||||
}{Type: "CNAME", Name: hostname, Content: target, Proxied: true})
|
||||
if err != nil {
|
||||
return errors.New("encode Cloudflare DNS record")
|
||||
}
|
||||
var created dnsRecord
|
||||
return c.request(ctx, http.MethodPost, "/zones/"+url.PathEscape(zoneID)+"/dns_records", body, &created)
|
||||
}
|
||||
if len(records) != 1 || !strings.EqualFold(records[0].Name, hostname) || !strings.EqualFold(records[0].Content, target) || !records[0].Proxied {
|
||||
return errors.New("existing Cloudflare DNS record does not match the managed tunnel route")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteCNAME(ctx context.Context, zoneID, hostname, tunnelID string) error {
|
||||
records, err := c.cnameRecords(ctx, zoneID, hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
target := tunnelTarget(tunnelID)
|
||||
if len(records) != 1 || !strings.EqualFold(records[0].Name, hostname) || !strings.EqualFold(records[0].Content, target) || !records[0].Proxied {
|
||||
return errors.New("existing Cloudflare DNS record does not match the managed tunnel route")
|
||||
}
|
||||
var deleted dnsRecord
|
||||
return c.request(ctx, http.MethodDelete, "/zones/"+url.PathEscape(zoneID)+"/dns_records/"+url.PathEscape(records[0].ID), nil, &deleted)
|
||||
}
|
||||
|
||||
func (c *Client) cnameRecords(ctx context.Context, zoneID, hostname string) ([]dnsRecord, error) {
|
||||
var records []dnsRecord
|
||||
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records?" + url.Values{"type": {"CNAME"}, "name": {hostname}}.Encode()
|
||||
err := c.request(ctx, http.MethodGet, path, nil, &records)
|
||||
return records, err
|
||||
}
|
||||
|
||||
func (c *Client) request(ctx context.Context, method, path string, body []byte, result any) error {
|
||||
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return errors.New("create Cloudflare API request")
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+c.token)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
client := c.httpClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return errors.New("call Cloudflare API")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return fmt.Errorf("Cloudflare API returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
var envelope struct {
|
||||
Success bool `json:"success"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil || !envelope.Success {
|
||||
return errors.New("Cloudflare API returned an invalid response")
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Result, result); err != nil {
|
||||
return errors.New("Cloudflare API returned an invalid result")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tunnelTarget(tunnelID string) string {
|
||||
return tunnelID + ".cfargotunnel.com"
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
package cloudflare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientCreatesTunnelCNAME(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requests++
|
||||
if request.Header.Get("Authorization") != "Bearer test-api-token" {
|
||||
t.Error("Cloudflare request did not use the API token header")
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
switch request.Method + " " + request.URL.Path {
|
||||
case "GET /zones/zone-id/dns_records":
|
||||
if request.URL.Query().Get("type") != "CNAME" || request.URL.Query().Get("name") != "app.example.test" {
|
||||
t.Error("DNS lookup was invalid")
|
||||
}
|
||||
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": []any{}})
|
||||
case "POST /zones/zone-id/dns_records":
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body["content"] != "tunnel-id.cfargotunnel.com" || body["proxied"] != true {
|
||||
t.Error("DNS create request was invalid")
|
||||
}
|
||||
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": map[string]string{"id": "record-id"}})
|
||||
default:
|
||||
t.Errorf("unexpected request %s %s", request.Method, request.URL.Path)
|
||||
writer.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("test-api-token")
|
||||
client.baseURL = server.URL
|
||||
client.httpClient = server.Client()
|
||||
if err := client.EnsureCNAME(context.Background(), "zone-id", "app.example.test", "tunnel-id"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requests != 2 {
|
||||
t.Fatalf("expected two Cloudflare requests, got %d", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientFailureDoesNotRevealToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("test-api-token")
|
||||
client.baseURL = server.URL
|
||||
client.httpClient = server.Client()
|
||||
err := client.EnsureCNAME(context.Background(), "zone-id", "app.example.test", "tunnel-id")
|
||||
if err == nil || strings.Contains(err.Error(), "test-api-token") {
|
||||
t.Fatal("Cloudflare API failure exposed the token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDeletesOnlyMatchingTunnelCNAME(t *testing.T) {
|
||||
deleted := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Header.Get("Authorization") != "Bearer test-api-token" {
|
||||
t.Error("Cloudflare request did not use the API token header")
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
switch request.Method + " " + request.URL.Path {
|
||||
case "GET /zones/zone-id/dns_records":
|
||||
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": []map[string]any{{"id": "record-id", "name": "app.example.test", "content": "tunnel-id.cfargotunnel.com", "proxied": true}}})
|
||||
case "DELETE /zones/zone-id/dns_records/record-id":
|
||||
deleted = true
|
||||
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": map[string]string{"id": "record-id"}})
|
||||
default:
|
||||
t.Errorf("unexpected request %s %s", request.Method, request.URL.Path)
|
||||
writer.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("test-api-token")
|
||||
client.baseURL = server.URL
|
||||
client.httpClient = server.Client()
|
||||
if err := client.DeleteCNAME(context.Background(), "zone-id", "app.example.test", "tunnel-id"); err != nil || !deleted {
|
||||
t.Fatal("matching managed CNAME was not deleted")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
package cloudflare
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
credentialsKey = "credentials"
|
||||
configKey = "config"
|
||||
legacyCredentialsKey = "credentials.json"
|
||||
legacyConfigKey = "config.yml"
|
||||
credentialsFile = "/etc/cloudflared/credentials.json"
|
||||
)
|
||||
|
||||
var hostnamePattern = regexp.MustCompile(`(?i)^(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
|
||||
type Credentials struct {
|
||||
AccountTag string `json:"AccountTag"`
|
||||
TunnelSecret string `json:"TunnelSecret"`
|
||||
TunnelID string `json:"TunnelID"`
|
||||
}
|
||||
|
||||
type Route struct {
|
||||
Hostname string `yaml:"hostname,omitempty"`
|
||||
Service string `yaml:"service"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Tunnel string `yaml:"tunnel"`
|
||||
CredentialsFile string `yaml:"credentials-file"`
|
||||
Ingress []Route `yaml:"ingress"`
|
||||
}
|
||||
|
||||
type StoredTunnel struct {
|
||||
Credentials Credentials
|
||||
Config Config
|
||||
}
|
||||
|
||||
func NewConfig(tunnelID string) Config {
|
||||
return Config{Tunnel: tunnelID, CredentialsFile: credentialsFile, Ingress: []Route{{Service: "http_status:404"}}}
|
||||
}
|
||||
|
||||
func IsLegacyRunTokenState(values map[string]string) bool {
|
||||
return len(values) == 1 && strings.TrimSpace(values["token"]) != ""
|
||||
}
|
||||
|
||||
func ReadCredentialsFile(path string) (Credentials, error) {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Credentials{}, errors.New("invalid Cloudflare credentials file")
|
||||
}
|
||||
if credentials, err := parseCredentials(contents); err == nil {
|
||||
return credentials, nil
|
||||
}
|
||||
credentials, err := credentialsFromSecret(contents)
|
||||
if err != nil {
|
||||
return Credentials{}, errors.New("invalid Cloudflare credentials file")
|
||||
}
|
||||
return credentials, nil
|
||||
}
|
||||
|
||||
func credentialsFromSecret(contents []byte) (Credentials, error) {
|
||||
var secret struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Kind string `yaml:"kind"`
|
||||
StringData map[string]string `yaml:"stringData"`
|
||||
Data map[string]string `yaml:"data"`
|
||||
}
|
||||
decoder := yaml.NewDecoder(strings.NewReader(string(contents)))
|
||||
if err := decoder.Decode(&secret); err != nil || decoder.Decode(&struct{}{}) != io.EOF || secret.APIVersion != "v1" || secret.Kind != "Secret" {
|
||||
return Credentials{}, errors.New("invalid Secret")
|
||||
}
|
||||
plaintext, inStringData := secret.StringData[legacyCredentialsKey]
|
||||
encoded, inData := secret.Data[legacyCredentialsKey]
|
||||
if inStringData == inData {
|
||||
return Credentials{}, errors.New("missing Secret credentials")
|
||||
}
|
||||
if inStringData {
|
||||
return parseCredentials([]byte(plaintext))
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return Credentials{}, errors.New("invalid Secret credentials")
|
||||
}
|
||||
return parseCredentials(decoded)
|
||||
}
|
||||
|
||||
func NewRoute(hostname, service string) (Route, error) {
|
||||
hostname = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(hostname), "."))
|
||||
if len(hostname) > 253 || !hostnamePattern.MatchString(hostname) {
|
||||
return Route{}, errors.New("hostname must be a valid public DNS hostname")
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(service)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
|
||||
return Route{}, errors.New("service must be an absolute http or https URL")
|
||||
}
|
||||
return Route{Hostname: hostname, Service: service}, nil
|
||||
}
|
||||
|
||||
func ParseStoredTunnel(values map[string]string) (StoredTunnel, bool, error) {
|
||||
stored, present, _, err := ParseStoredTunnelState(values)
|
||||
return stored, present, err
|
||||
}
|
||||
|
||||
// ParseStoredTunnelState reports whether state uses the legacy dotted keys.
|
||||
func ParseStoredTunnelState(values map[string]string) (StoredTunnel, bool, bool, error) {
|
||||
credentialsJSON, hasCredentials := values[credentialsKey]
|
||||
configYAML, hasConfig := values[configKey]
|
||||
legacyCredentialsJSON, hasLegacyCredentials := values[legacyCredentialsKey]
|
||||
legacyConfigYAML, hasLegacyConfig := values[legacyConfigKey]
|
||||
if hasCredentials || hasConfig {
|
||||
if !hasCredentials || !hasConfig || len(values) != 2 {
|
||||
return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials and config")
|
||||
}
|
||||
return parseStoredTunnel(credentialsJSON, configYAML, false)
|
||||
}
|
||||
if hasLegacyCredentials || hasLegacyConfig {
|
||||
if !hasLegacyCredentials || !hasLegacyConfig || len(values) != 2 {
|
||||
return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials and config")
|
||||
}
|
||||
return parseStoredTunnel(legacyCredentialsJSON, legacyConfigYAML, true)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return StoredTunnel{}, false, false, nil
|
||||
}
|
||||
return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected credentials and config")
|
||||
}
|
||||
|
||||
func parseStoredTunnel(credentialsJSON, configYAML string, legacy bool) (StoredTunnel, bool, bool, error) {
|
||||
credentials, err := parseCredentials([]byte(credentialsJSON))
|
||||
if err != nil {
|
||||
return StoredTunnel{}, false, false, err
|
||||
}
|
||||
var config Config
|
||||
yamlDecoder := yaml.NewDecoder(strings.NewReader(configYAML))
|
||||
yamlDecoder.KnownFields(true)
|
||||
if err := yamlDecoder.Decode(&config); err != nil || yamlDecoder.Decode(&struct{}{}) != io.EOF || !validConfig(config) || config.Tunnel != credentials.TunnelID {
|
||||
return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel config is invalid")
|
||||
}
|
||||
return StoredTunnel{Credentials: credentials, Config: config}, true, legacy, nil
|
||||
}
|
||||
|
||||
func parseCredentials(contents []byte) (Credentials, error) {
|
||||
var credentials Credentials
|
||||
decoder := json.NewDecoder(strings.NewReader(string(contents)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF || credentials.AccountTag == "" || credentials.TunnelSecret == "" || credentials.TunnelID == "" {
|
||||
return Credentials{}, errors.New("Cloudflare tunnel credentials are invalid")
|
||||
}
|
||||
return credentials, nil
|
||||
}
|
||||
|
||||
func (s StoredTunnel) Values() (map[string]string, error) {
|
||||
credentials, err := json.Marshal(s.Credentials)
|
||||
if err != nil {
|
||||
return nil, errors.New("encode Cloudflare tunnel credentials")
|
||||
}
|
||||
config, err := yaml.Marshal(s.Config)
|
||||
if err != nil {
|
||||
return nil, errors.New("encode Cloudflare tunnel config")
|
||||
}
|
||||
return map[string]string{credentialsKey: string(credentials), configKey: string(config)}, nil
|
||||
}
|
||||
|
||||
func (c *Config) AddRoute(route Route) (bool, error) {
|
||||
if !validConfig(*c) {
|
||||
return false, errors.New("Cloudflare tunnel config is invalid")
|
||||
}
|
||||
for _, existing := range c.Ingress[:len(c.Ingress)-1] {
|
||||
if existing.Hostname == route.Hostname {
|
||||
if existing.Service == route.Service {
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.New("hostname already has a different Cloudflare tunnel route")
|
||||
}
|
||||
}
|
||||
terminal := c.Ingress[len(c.Ingress)-1]
|
||||
c.Ingress = append(c.Ingress[:len(c.Ingress)-1], route, terminal)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *Config) RemoveRoute(route Route) (bool, error) {
|
||||
if !validConfig(*c) {
|
||||
return false, errors.New("Cloudflare tunnel config is invalid")
|
||||
}
|
||||
for index, existing := range c.Ingress[:len(c.Ingress)-1] {
|
||||
if existing.Hostname != route.Hostname {
|
||||
continue
|
||||
}
|
||||
if existing.Service != route.Service {
|
||||
return false, errors.New("hostname does not match the requested Cloudflare tunnel service")
|
||||
}
|
||||
c.Ingress = append(c.Ingress[:index], c.Ingress[index+1:]...)
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func validConfig(config Config) bool {
|
||||
if config.Tunnel == "" || config.CredentialsFile != credentialsFile || len(config.Ingress) == 0 {
|
||||
return false
|
||||
}
|
||||
last := len(config.Ingress) - 1
|
||||
if config.Ingress[last].Hostname != "" || config.Ingress[last].Service != "http_status:404" {
|
||||
return false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, route := range config.Ingress[:last] {
|
||||
normalized, err := NewRoute(route.Hostname, route.Service)
|
||||
if err != nil || normalized != route || seen[route.Hostname] {
|
||||
return false
|
||||
}
|
||||
seen[route.Hostname] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
package cloudflare
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStoredTunnelStartsWithoutPublicIngress(t *testing.T) {
|
||||
stored := StoredTunnel{Credentials: Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"}, Config: NewConfig("tunnel")}
|
||||
values, err := stored.Values()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, hasCredentials := values["credentials"]; !hasCredentials {
|
||||
t.Fatal("new tunnel state did not use a simple credentials key")
|
||||
}
|
||||
parsed, present, err := ParseStoredTunnel(values)
|
||||
if err != nil || !present || len(parsed.Config.Ingress) != 1 || parsed.Config.Ingress[0].Service != "http_status:404" {
|
||||
t.Fatal("new tunnel state must contain only the terminal ingress rule")
|
||||
}
|
||||
legacy := map[string]string{"credentials.json": values["credentials"], "config.yml": values["config"]}
|
||||
_, present, isLegacy, err := ParseStoredTunnelState(legacy)
|
||||
if err != nil || !present || !isLegacy {
|
||||
t.Fatal("valid dotted tunnel state was not recognized for migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoredTunnelRejectsLegacyOrPartialState(t *testing.T) {
|
||||
for _, values := range []map[string]string{
|
||||
{"token": "legacy-run-token"},
|
||||
{"credentials.json": "{}"},
|
||||
} {
|
||||
if _, _, err := ParseStoredTunnel(values); err == nil {
|
||||
t.Fatal("ambiguous tunnel state was accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCredentialsFileRequiresKnownFields(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "credentials.json")
|
||||
raw := `{"AccountTag":"account","TunnelSecret":"secret","TunnelID":"tunnel"}`
|
||||
for _, contents := range []string{
|
||||
raw,
|
||||
"apiVersion: v1\nkind: Secret\nstringData:\n credentials.json: |\n " + raw + "\n",
|
||||
"apiVersion: v1\nkind: Secret\ndata:\n credentials.json: " + base64.StdEncoding.EncodeToString([]byte(raw)) + "\n",
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credentials, err := ReadCredentialsFile(path)
|
||||
if err != nil || credentials.TunnelID != "tunnel" {
|
||||
t.Fatal("supported credential file was not accepted")
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{"AccountTag":"account","TunnelSecret":"secret","TunnelID":"tunnel","extra":"value"}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadCredentialsFile(path); err == nil || err.Error() != "invalid Cloudflare credentials file" {
|
||||
t.Fatal("credential file with unknown fields did not return a safe error")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("apiVersion: v1\nkind: Secret\ndata: {}\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadCredentialsFile(path); err == nil || err.Error() != "invalid Cloudflare credentials file" {
|
||||
t.Fatal("Secret without credentials did not return a safe error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteConfigAddAndRemoveAreIdempotent(t *testing.T) {
|
||||
config := NewConfig("tunnel")
|
||||
route, err := NewRoute("app.example.test", "http://service.default.svc:8080")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, err := config.AddRoute(route)
|
||||
if err != nil || !changed || len(config.Ingress) != 2 {
|
||||
t.Fatal("route was not added before the terminal ingress")
|
||||
}
|
||||
changed, err = config.AddRoute(route)
|
||||
if err != nil || changed {
|
||||
t.Fatal("matching route add was not idempotent")
|
||||
}
|
||||
changed, err = config.RemoveRoute(route)
|
||||
if err != nil || !changed || len(config.Ingress) != 1 || config.Ingress[0].Service != "http_status:404" {
|
||||
t.Fatal("route removal did not preserve the terminal ingress")
|
||||
}
|
||||
changed, err = config.RemoveRoute(route)
|
||||
if err != nil || changed {
|
||||
t.Fatal("matching route remove was not idempotent")
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
|
@ -56,17 +55,8 @@ func Save(path string, cfg Config) error {
|
|||
func WriteRedacted(path string, cfg Config) error {
|
||||
redacted := cfg
|
||||
redacted.Git.Token = ""
|
||||
redacted.Git.BaseURL = RedactURL(redacted.Git.BaseURL)
|
||||
redacted.Talos.Proxmox.APITokenSecret = ""
|
||||
redacted.Talos.Proxmox.APIURL = RedactURL(redacted.Talos.Proxmox.APIURL)
|
||||
redacted.DemocraticCSI.TrueNASAPIKey = ""
|
||||
redacted.Templates.TalosRepoURL = RedactURL(redacted.Templates.TalosRepoURL)
|
||||
redacted.Templates.CICDRepoURL = RedactURL(redacted.Templates.CICDRepoURL)
|
||||
redacted.Templates.ManifestsRepoURL = RedactURL(redacted.Templates.ManifestsRepoURL)
|
||||
redacted.Templates.TektonCatalogRepoURL = RedactURL(redacted.Templates.TektonCatalogRepoURL)
|
||||
redacted.Delivery.AppRepoURL = RedactURL(redacted.Delivery.AppRepoURL)
|
||||
redacted.Delivery.ImageRepository = RedactURL(redacted.Delivery.ImageRepository)
|
||||
redacted.Delivery.TektonDashboardURL = RedactURL(redacted.Delivery.TektonDashboardURL)
|
||||
data, err := yaml.Marshal(redacted)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -74,150 +64,12 @@ func WriteRedacted(path string, cfg Config) error {
|
|||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
// RedactURL removes credential-bearing URL components before persistent output.
|
||||
func RedactURL(value string) string {
|
||||
if strings.Contains(value, "://") {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return "<redacted>"
|
||||
}
|
||||
parsed.User = nil
|
||||
parsed.RawQuery = ""
|
||||
parsed.ForceQuery = false
|
||||
parsed.Fragment = ""
|
||||
return parsed.String()
|
||||
}
|
||||
if at := strings.Index(value, "@"); at > 0 && !strings.Contains(value[:at], "/") && strings.Contains(value[at+1:], ":") {
|
||||
return value[at+1:]
|
||||
}
|
||||
if strings.ContainsAny(value, "@?#") {
|
||||
return "<redacted>"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func Resolve(cfg Config) (Config, error) {
|
||||
applyDefaults(&cfg)
|
||||
return cfg, Validate(cfg)
|
||||
}
|
||||
|
||||
// ResolveDelivery applies delivery defaults for an explicitly requested app operation.
|
||||
func ResolveDelivery(cfg Config) (Config, error) {
|
||||
applyDeliveryDefaults(&cfg)
|
||||
return cfg, ValidateDelivery(cfg)
|
||||
}
|
||||
|
||||
// ResolveFreshBootstrap validates the scoped contract needed before a new
|
||||
// Forgejo organization can be scaffolded. It deliberately does not validate
|
||||
// recovery, SOPS, Proxmox, or Talos state.
|
||||
func ResolveFreshBootstrap(cfg Config, organization string, enableDelivery bool) (Config, error) {
|
||||
if !validRepositoryPart(organization) {
|
||||
return cfg, errors.New("organization must be a Forgejo owner name")
|
||||
}
|
||||
if cfg.Git.Owner != "" && cfg.Git.Owner != organization {
|
||||
return cfg, errors.New("git owner must match --organization")
|
||||
}
|
||||
cfg.Git.Owner = organization
|
||||
if cfg.WorkspaceDir == "" || !filepath.IsAbs(cfg.WorkspaceDir) {
|
||||
return cfg, errors.New("workspaceDir must be an absolute isolated workspace path")
|
||||
}
|
||||
if cfg.Git.CloneParent == "" || !filepath.IsAbs(cfg.Git.CloneParent) || !isChildPath(cfg.WorkspaceDir, cfg.Git.CloneParent) {
|
||||
return cfg, errors.New("git cloneParent must be an absolute child of workspaceDir")
|
||||
}
|
||||
if cfg.Git.Provider != "forgejo" || cfg.Git.BaseURL == "" || cfg.Git.Username == "" || cfg.Git.Token == "" {
|
||||
return cfg, errors.New("git provider, baseUrl, username, and token are required for fresh bootstrap")
|
||||
}
|
||||
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if !validRepositoryPart(cfg.Flux.RepoName) || !validRepositoryPart(cfg.Flux.ManifestsRepo) || !validRepositoryPart(cfg.Flux.TektonCatalogRepo) || cfg.Flux.Branch == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ClusterDomain == "" {
|
||||
return cfg, errors.New("flux repoName, manifestsRepo, tektonCatalogRepo, branch, clusterPath, and clusterDomain are required")
|
||||
}
|
||||
if cfg.Talos.RepoDirName == "" || filepath.Base(cfg.Talos.RepoDirName) != cfg.Talos.RepoDirName || cfg.Talos.GeneratedDir == "" {
|
||||
return cfg, errors.New("talos repoDirName and generatedDir are required for template locking")
|
||||
}
|
||||
for _, source := range []struct{ URL, Ref string }{
|
||||
{cfg.Templates.TalosRepoURL, cfg.Templates.TalosRepoRef},
|
||||
{cfg.Templates.CICDRepoURL, cfg.Templates.CICDRepoRef},
|
||||
{cfg.Templates.ManifestsRepoURL, cfg.Templates.ManifestsRepoRef},
|
||||
{cfg.Templates.TektonCatalogRepoURL, cfg.Templates.TektonCatalogRepoRef},
|
||||
} {
|
||||
if source.URL == "" || source.Ref == "" || RedactURL(source.URL) != source.URL {
|
||||
return cfg, errors.New("template repository URLs and refs must be explicit and credential-free")
|
||||
}
|
||||
if err := validateRepositoryURL(source.URL); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ResolveAppOnboarding validates only the source-owned delivery contract.
|
||||
func ResolveAppOnboarding(cfg Config) (Config, error) {
|
||||
if cfg.Git.Owner == "" || cfg.Git.Username == "" || cfg.Git.Token == "" || cfg.Flux.RepoName == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.Branch == "" || cfg.Flux.ClusterDomain == "" {
|
||||
return cfg, errors.New("git owner, username, token, flux repoName, manifestsRepo, branch, and clusterDomain are required for app onboarding")
|
||||
}
|
||||
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
resolved, err := ResolveDelivery(cfg)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
owner, repository, err := deliveryRepositoryOwner(resolved.Delivery.AppRepoURL)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if owner != resolved.Git.Owner && (owner != "Maidn" || !strings.HasPrefix(repository, "maidn-e2e-")) {
|
||||
return cfg, errors.New("delivery appRepoUrl owner must match git owner or identify a canonical Maidn E2E fixture")
|
||||
}
|
||||
if resolved.Delivery.ProductionBranch != "production" {
|
||||
return cfg, errors.New("delivery productionBranch must be literal production for app onboarding")
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func deliveryRepositoryOwner(value string) (string, string, error) {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return "", "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
|
||||
}
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
if len(parts) != 2 || !strings.HasSuffix(parts[1], ".git") || !validRepositoryPart(parts[0]) || !validRepositoryPart(strings.TrimSuffix(parts[1], ".git")) {
|
||||
return "", "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
|
||||
}
|
||||
return parts[0], strings.TrimSuffix(parts[1], ".git"), nil
|
||||
}
|
||||
|
||||
func validRepositoryPart(value string) bool {
|
||||
return value != "" && !strings.Contains(value, "..") && regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`).MatchString(value)
|
||||
}
|
||||
|
||||
func isChildPath(parent, child string) bool {
|
||||
relative, err := filepath.Rel(filepath.Clean(parent), filepath.Clean(child))
|
||||
return err == nil && relative != "." && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
|
||||
}
|
||||
|
||||
func validateForgejoOrigin(value string) error {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") != "" {
|
||||
return errors.New("git baseUrl must be a credential-free HTTPS origin")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRepositoryURL(value string) error {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") == "" {
|
||||
return errors.New("template repository URL must be a credential-free HTTPS repository URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *Config) {
|
||||
if cfg.Delivery.BuildStrategy == "" {
|
||||
cfg.Delivery.BuildStrategy = "static"
|
||||
}
|
||||
if cfg.ClusterID == "" {
|
||||
cfg.ClusterID = cfg.Talos.Cluster.Name
|
||||
}
|
||||
|
|
@ -239,9 +91,6 @@ func applyDefaults(cfg *Config) {
|
|||
if cfg.Flux.ManifestsRepo == "" {
|
||||
cfg.Flux.ManifestsRepo = "cicd-deployment-manifests"
|
||||
}
|
||||
if cfg.Flux.TektonCatalogRepo == "" {
|
||||
cfg.Flux.TektonCatalogRepo = "tekton-pipelines"
|
||||
}
|
||||
if cfg.Templates.TalosRepoURL == "" {
|
||||
cfg.Templates.TalosRepoURL = "https://git.pingu.pw/Maidn/maidn-talos-proxmox.git"
|
||||
}
|
||||
|
|
@ -275,6 +124,24 @@ func applyDefaults(cfg *Config) {
|
|||
if cfg.DemocraticCSI.InitiatorGroup == "" {
|
||||
cfg.DemocraticCSI.InitiatorGroup = "1"
|
||||
}
|
||||
if cfg.Delivery.AppName == "" {
|
||||
cfg.Delivery.AppName = "easycsr-frontend"
|
||||
}
|
||||
if cfg.Delivery.AppRepoURL == "" {
|
||||
cfg.Delivery.AppRepoURL = strings.TrimRight(cfg.Git.BaseURL, "/") + "/" + cfg.Git.Owner + "/" + cfg.Delivery.AppName + ".git"
|
||||
}
|
||||
if cfg.Delivery.AppRepoRef == "" {
|
||||
cfg.Delivery.AppRepoRef = cfg.Flux.Branch
|
||||
}
|
||||
if cfg.Delivery.ImageRepository == "" {
|
||||
cfg.Delivery.ImageRepository = strings.TrimPrefix(strings.TrimPrefix(cfg.Git.BaseURL, "https://"), "http://") + "/" + strings.ToLower(cfg.Git.Owner) + "/" + cfg.Delivery.AppName
|
||||
}
|
||||
if cfg.Delivery.WebhookHostname == "" && cfg.Flux.ClusterDomain != "" {
|
||||
cfg.Delivery.WebhookHostname = "tekton." + cfg.Flux.ClusterDomain
|
||||
}
|
||||
if cfg.Delivery.WebhookPath == "" {
|
||||
cfg.Delivery.WebhookPath = "/"
|
||||
}
|
||||
if cfg.Talos.RepoDirName == "" {
|
||||
cfg.Talos.RepoDirName = "maidn-talos-proxmox"
|
||||
}
|
||||
|
|
@ -319,27 +186,6 @@ func applyDefaults(cfg *Config) {
|
|||
}
|
||||
}
|
||||
|
||||
func applyDeliveryDefaults(cfg *Config) {
|
||||
if cfg.Delivery.BuildStrategy == "" {
|
||||
cfg.Delivery.BuildStrategy = "static"
|
||||
}
|
||||
if cfg.Delivery.AppRepoRef == "" {
|
||||
cfg.Delivery.AppRepoRef = cfg.Flux.Branch
|
||||
}
|
||||
if cfg.Delivery.BuildOutputDirectory == "" {
|
||||
cfg.Delivery.BuildOutputDirectory = "dist"
|
||||
}
|
||||
if cfg.Delivery.BuildConfiguration == "" {
|
||||
cfg.Delivery.BuildConfiguration = "production"
|
||||
}
|
||||
if cfg.Delivery.WebhookHostname == "" && cfg.Flux.ClusterDomain != "" {
|
||||
cfg.Delivery.WebhookHostname = "tekton." + cfg.Flux.ClusterDomain
|
||||
}
|
||||
if cfg.Delivery.WebhookPath == "" {
|
||||
cfg.Delivery.WebhookPath = "/"
|
||||
}
|
||||
}
|
||||
|
||||
func Validate(cfg Config) error {
|
||||
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`).MatchString(cfg.ClusterID) {
|
||||
return errors.New("clusterId must be a lowercase DNS label")
|
||||
|
|
@ -356,25 +202,14 @@ func Validate(cfg Config) error {
|
|||
if cfg.Git.Token == "" {
|
||||
return errors.New("git token is required; SSH bootstrap is not implemented")
|
||||
}
|
||||
if RedactURL(cfg.Git.BaseURL) != cfg.Git.BaseURL {
|
||||
return errors.New("git baseUrl must not contain credentials, a query, or a fragment")
|
||||
if cfg.Flux.RepoName == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.ClusterDomain == "" {
|
||||
return errors.New("flux repoName, clusterDomain, manifestsRepo, and clusterPath are required")
|
||||
}
|
||||
if cfg.Flux.RepoName == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.TektonCatalogRepo == "" || cfg.Flux.ClusterDomain == "" {
|
||||
return errors.New("flux repoName, clusterDomain, manifestsRepo, tektonCatalogRepo, and clusterPath are required")
|
||||
if cfg.Delivery.AppName == "" || cfg.Delivery.AppRepoURL == "" || cfg.Delivery.AppRepoRef == "" || cfg.Delivery.ImageRepository == "" || cfg.Delivery.WebhookHostname == "" || cfg.Delivery.WebhookPath == "" {
|
||||
return errors.New("delivery appName, appRepoUrl, appRepoRef, imageRepository, webhookHostname, and webhookPath are required")
|
||||
}
|
||||
if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`).MatchString(cfg.Flux.TektonCatalogRepo) || strings.Contains(cfg.Flux.TektonCatalogRepo, "..") {
|
||||
return errors.New("flux tektonCatalogRepo must be a repository name")
|
||||
}
|
||||
if len(cfg.Flux.ClusterDomain) > 253 || !regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?(\.[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?)*$`).MatchString(cfg.Flux.ClusterDomain) {
|
||||
return errors.New("flux clusterDomain must be a lowercase DNS subdomain")
|
||||
}
|
||||
if cfg.Delivery.Configured() {
|
||||
if err := ValidateDelivery(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := ValidateSecretGrants(cfg.SecretGrants); err != nil {
|
||||
return err
|
||||
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")
|
||||
}
|
||||
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")
|
||||
|
|
@ -385,10 +220,6 @@ func Validate(cfg Config) error {
|
|||
if cfg.Talos.TerraformDir == "" || cfg.Talos.GeneratedDir == "" || cfg.Talos.ConfigFileName == "" {
|
||||
return errors.New("talos terraformDir, generatedDir, and configFileName are required")
|
||||
}
|
||||
generatedDir := filepath.Clean(cfg.Talos.GeneratedDir)
|
||||
if filepath.IsAbs(generatedDir) || generatedDir == "." || generatedDir == ".." || strings.HasPrefix(generatedDir, ".."+string(filepath.Separator)) {
|
||||
return errors.New("talos generatedDir must be a child directory")
|
||||
}
|
||||
if cfg.Talos.Proxmox.APIURL == "" || cfg.Talos.Proxmox.APITokenID == "" || cfg.Talos.Proxmox.APITokenSecret == "" {
|
||||
return errors.New("talos proxmox apiUrl, apiTokenId, and apiTokenSecret are required")
|
||||
}
|
||||
|
|
@ -497,104 +328,6 @@ func Validate(cfg Config) error {
|
|||
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.
|
||||
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 == "" {
|
||||
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) {
|
||||
return errors.New("delivery appName must be a lowercase DNS label of at most 47 characters")
|
||||
}
|
||||
if cfg.Delivery.BuildStrategy != "static" && cfg.Delivery.BuildStrategy != "runtime" {
|
||||
return errors.New("delivery buildStrategy must be static or runtime")
|
||||
}
|
||||
if cfg.Delivery.ProductionBranch == cfg.Delivery.AppRepoRef || !validDeliveryBranch(cfg.Delivery.ProductionBranch) {
|
||||
return errors.New("delivery productionBranch must be a valid branch distinct from appRepoRef")
|
||||
}
|
||||
if RedactURL(cfg.Delivery.AppRepoURL) != cfg.Delivery.AppRepoURL {
|
||||
return errors.New("delivery appRepoUrl must not contain credentials, a query, or a fragment")
|
||||
}
|
||||
if err := validateDeliveryRepositoryOrigin(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL); err != nil {
|
||||
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, "?#") {
|
||||
return errors.New("delivery webhookHostname must be a hostname and webhookPath must be an absolute path")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDeliveryRepositoryOrigin(baseURL, repositoryURL string) error {
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil || base.Scheme != "https" || base.Host == "" || base.User != nil || base.RawPath != "" || base.RawQuery != "" || base.Fragment != "" || strings.Trim(base.Path, "/") != "" {
|
||||
return errors.New("git baseUrl must be a credential-free HTTPS origin")
|
||||
}
|
||||
repository, err := url.Parse(repositoryURL)
|
||||
if err != nil || repository.Scheme != "https" || repository.Host == "" || repository.User != nil || repository.RawPath != "" || repository.RawQuery != "" || repository.Fragment != "" || !strings.EqualFold(repository.Scheme+"://"+repository.Host, base.Scheme+"://"+base.Host) {
|
||||
return errors.New("delivery appRepoUrl must be a credential-free HTTPS repository on the configured Forgejo origin")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validDeliveryBranch(value string) bool {
|
||||
if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`).MatchString(value) || strings.Contains(value, "..") || strings.Contains(value, "//") || strings.HasSuffix(value, ".") || strings.HasSuffix(value, ".lock") {
|
||||
return false
|
||||
}
|
||||
for _, part := range strings.Split(value, "/") {
|
||||
if strings.HasPrefix(part, ".") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func Preflight(cfg Config) error {
|
||||
return talos.RequireCLICompatibility(cfg.Talos.Image.TalosVersion)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ 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"},
|
||||
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"},
|
||||
Delivery: DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"},
|
||||
Talos: TalosConfig{
|
||||
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
|
||||
Proxmox: TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
|
||||
|
|
@ -28,217 +27,19 @@ func validConfig(t *testing.T) Config {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryStates(t *testing.T) {
|
||||
platformOnly := validConfig(t)
|
||||
platformOnly.Delivery = DeliveryConfig{}
|
||||
partialLegacy := platformOnly
|
||||
partialLegacy.Delivery = DeliveryConfig{AppName: "legacy-app", AppRepoURL: "https://git.example.test/test-org/legacy-app.git", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/legacy-app"}
|
||||
partialLegacy.Talos.AutoBootstrapFlux = true
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
cfg Config
|
||||
configured bool
|
||||
}{
|
||||
{name: "absent", cfg: platformOnly},
|
||||
{name: "partial legacy", cfg: partialLegacy},
|
||||
{name: "complete", cfg: validConfig(t), configured: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
resolved, err := Resolve(test.cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Delivery.Configured() != test.configured {
|
||||
t.Fatalf("Configured() = %t, want %t", resolved.Delivery.Configured(), test.configured)
|
||||
}
|
||||
if !test.configured && resolved.Delivery.AppRepoRef != "" {
|
||||
t.Fatal("Resolve() defaulted delivery configuration treated as absent")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryAppliesDefaultsOnlyForExplicitAppOperations(t *testing.T) {
|
||||
cfg := validConfig(t)
|
||||
cfg.Delivery.AppRepoRef = ""
|
||||
cfg.Delivery.BuildOutputDirectory = ""
|
||||
cfg.Delivery.BuildConfiguration = ""
|
||||
cfg.Delivery.BuildStrategy = ""
|
||||
cfg.Delivery.WebhookHostname = ""
|
||||
cfg.Delivery.WebhookPath = ""
|
||||
|
||||
platform, err := Resolve(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if platform.Delivery.Configured() {
|
||||
t.Fatal("platform resolution treated incomplete delivery as configured")
|
||||
}
|
||||
delivery, err := ResolveDelivery(platform)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !delivery.Delivery.Configured() || delivery.Delivery.BuildStrategy != "static" || delivery.Delivery.AppRepoRef != "main" || delivery.Delivery.BuildOutputDirectory != "dist" || delivery.Delivery.BuildConfiguration != "production" || delivery.Delivery.WebhookURL() != "https://tekton.example.test/" {
|
||||
t.Fatalf("ResolveDelivery() did not apply the complete delivery contract: %#v", delivery.Delivery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeliveryBuildStrategy(t *testing.T) {
|
||||
cfg := validConfig(t)
|
||||
for _, strategy := range []string{"static", "runtime"} {
|
||||
cfg.Delivery.BuildStrategy = strategy
|
||||
if err := ValidateDelivery(cfg); err != nil {
|
||||
t.Fatalf("ValidateDelivery() rejected %q: %v", strategy, err)
|
||||
}
|
||||
}
|
||||
cfg.Delivery.BuildStrategy = "container"
|
||||
if err := ValidateDelivery(cfg); err == nil || !strings.Contains(err.Error(), "buildStrategy") {
|
||||
t.Fatalf("ValidateDelivery() accepted invalid build strategy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) {
|
||||
cfg := validConfig(t)
|
||||
cfg.Delivery.ImageRepository = ""
|
||||
if err := ValidateDelivery(cfg); err == nil || !strings.Contains(err.Error(), "imageRepository") {
|
||||
t.Fatalf("ValidateDelivery() error = %v, want incomplete delivery error", err)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
cfg := validConfig(t)
|
||||
cfg.Delivery.AppRepoURL = "https://reader:token@git.example.test/test-org/web-ui.git"
|
||||
if _, err := Resolve(cfg); err == nil || !strings.Contains(err.Error(), "credentials") {
|
||||
t.Fatalf("Resolve() error = %v, want credential-free app repository error", err)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
cfg := validConfig(t)
|
||||
cfg.Delivery.AppRepoURL = "https://attacker.example.test/test-org/web-ui.git"
|
||||
if _, err := Resolve(cfg); err == nil || !strings.Contains(err.Error(), "configured Forgejo origin") {
|
||||
t.Fatalf("Resolve() error = %v, want Forgejo origin error", err)
|
||||
}
|
||||
cfg.Delivery.AppRepoURL = "https://git.example.test/test-org/web-ui.git"
|
||||
if _, err := Resolve(cfg); err != nil {
|
||||
t.Fatalf("Resolve() rejected delivery repository on configured Forgejo origin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequiresDistinctValidProductionBranch(t *testing.T) {
|
||||
cfg := validConfig(t)
|
||||
cfg.Delivery.ProductionBranch = cfg.Delivery.AppRepoRef
|
||||
if _, err := Resolve(cfg); err == nil || !strings.Contains(err.Error(), "productionBranch") {
|
||||
t.Fatalf("Resolve() error = %v, want distinct production branch error", err)
|
||||
}
|
||||
cfg.Delivery.ProductionBranch = "production/../unsafe"
|
||||
if _, err := Resolve(cfg); err == nil || !strings.Contains(err.Error(), "productionBranch") {
|
||||
t.Fatalf("Resolve() error = %v, want valid production branch error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDefaultsWebhookEndpoint(t *testing.T) {
|
||||
input := validConfig(t)
|
||||
input.Delivery.WebhookHostname = ""
|
||||
input.Delivery.WebhookPath = ""
|
||||
platform, err := Resolve(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveDelivery(platform)
|
||||
cfg, err := Resolve(validConfig(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Delivery.WebhookURL() != "https://tekton.example.test/" {
|
||||
t.Fatalf("WebhookURL() = %q", cfg.Delivery.WebhookURL())
|
||||
}
|
||||
if cfg.Templates.TektonCatalogRepoURL != "https://git.pingu.pw/Maidn/tekton-pipelines.git" || cfg.Templates.TektonCatalogRepoRef != "main" || cfg.Flux.TektonCatalogRepo != "tekton-pipelines" {
|
||||
if cfg.Templates.TektonCatalogRepoURL != "https://git.pingu.pw/Maidn/tekton-pipelines.git" || cfg.Templates.TektonCatalogRepoRef != "main" {
|
||||
t.Fatalf("Tekton catalog defaults = %q@%q", cfg.Templates.TektonCatalogRepoURL, cfg.Templates.TektonCatalogRepoRef)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("workspaceDir: test\nunknown: value\n"), 0600); err != nil {
|
||||
|
|
@ -261,9 +62,6 @@ func TestLoadRawAllowsCompletionBeforeValidation(t *testing.T) {
|
|||
|
||||
func TestWriteRedactedOmitsCredentials(t *testing.T) {
|
||||
cfg := validConfig(t)
|
||||
cfg.Git.BaseURL = "https://reader:git-token@git.example.test"
|
||||
cfg.Templates.CICDRepoURL = "https://reader:template-token@git.example.test/template.git?access_token=query-token"
|
||||
cfg.Delivery.ImageRepository = "reader:registry-token@registry.example.test/team/app"
|
||||
path := filepath.Join(t.TempDir(), "resolved.yaml")
|
||||
if err := WriteRedacted(path, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -272,8 +70,8 @@ func TestWriteRedactedOmitsCredentials(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "token: token") || strings.Contains(string(data), "apiTokenSecret: secret") || strings.Contains(string(data), "truenasApiKey: api-key") || strings.Contains(string(data), "git-token") || strings.Contains(string(data), "template-token") || strings.Contains(string(data), "query-token") || strings.Contains(string(data), "registry-token") {
|
||||
t.Fatal("redacted config contains credentials")
|
||||
if strings.Contains(string(data), "token: token") || strings.Contains(string(data), "apiTokenSecret: secret") || strings.Contains(string(data), "truenasApiKey: api-key") {
|
||||
t.Fatalf("redacted config contains credentials: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,20 +12,9 @@ type Config struct {
|
|||
Cilium CiliumConfig `yaml:"cilium"`
|
||||
DemocraticCSI DemocraticCSIConfig `yaml:"democraticCsi"`
|
||||
Delivery DeliveryConfig `yaml:"delivery"`
|
||||
SecretGrants []SecretGrant `yaml:"secretGrants,omitempty"`
|
||||
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 {
|
||||
TrueNASAPIKey string `yaml:"truenasApiKey"`
|
||||
TrueNASHost string `yaml:"truenasHost"`
|
||||
|
|
@ -43,21 +32,11 @@ type DeliveryConfig struct {
|
|||
AppName string `yaml:"appName"`
|
||||
AppRepoURL string `yaml:"appRepoUrl"`
|
||||
AppRepoRef string `yaml:"appRepoRef"`
|
||||
ProductionBranch string `yaml:"productionBranch"`
|
||||
ImageRepository string `yaml:"imageRepository"`
|
||||
BuildStrategy string `yaml:"buildStrategy"`
|
||||
BuildOutputDirectory string `yaml:"buildOutputDirectory"`
|
||||
BuildConfiguration string `yaml:"buildConfiguration"`
|
||||
TektonDashboardURL string `yaml:"tektonDashboardUrl,omitempty"`
|
||||
WebhookHostname string `yaml:"webhookHostname"`
|
||||
WebhookPath string `yaml:"webhookPath"`
|
||||
}
|
||||
|
||||
// Configured reports whether the app-delivery rendering contract is complete.
|
||||
func (c DeliveryConfig) Configured() bool {
|
||||
return c.AppName != "" && c.AppRepoURL != "" && c.AppRepoRef != "" && c.ProductionBranch != "" && c.ImageRepository != "" && c.BuildOutputDirectory != "" && c.BuildConfiguration != "" && c.WebhookHostname != "" && c.WebhookPath != ""
|
||||
}
|
||||
|
||||
func (c DeliveryConfig) WebhookURL() string {
|
||||
return (&url.URL{Scheme: "https", Host: c.WebhookHostname, Path: c.WebhookPath}).String()
|
||||
}
|
||||
|
|
@ -88,7 +67,6 @@ type FluxConfig struct {
|
|||
ClusterPath string `yaml:"clusterPath"`
|
||||
ClusterDomain string `yaml:"clusterDomain"`
|
||||
ManifestsRepo string `yaml:"manifestsRepo"`
|
||||
TektonCatalogRepo string `yaml:"tektonCatalogRepo"`
|
||||
}
|
||||
|
||||
type TemplateConfig struct {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ package forgejo
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
|
@ -24,7 +23,7 @@ type RepoManager struct {
|
|||
FluxRepoName string
|
||||
Branch string
|
||||
MigrationBranch string
|
||||
MigrationRepositories []string
|
||||
MigrationPending bool
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
|
|
@ -36,10 +35,6 @@ type createRepoRequest struct {
|
|||
DefaultBranch string `json:"default_branch"`
|
||||
}
|
||||
|
||||
type createOrganizationRequest struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type pullRequestRequest struct {
|
||||
Title string `json:"title"`
|
||||
Head string `json:"head"`
|
||||
|
|
@ -57,7 +52,6 @@ type mergePullRequestRequest struct {
|
|||
type hook struct {
|
||||
ID int64 `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
|
||||
type hookRequest struct {
|
||||
|
|
@ -68,20 +62,6 @@ type hookRequest struct {
|
|||
Events []string `json:"events"`
|
||||
}
|
||||
|
||||
type branchProtection struct {
|
||||
ID int64 `json:"id"`
|
||||
BranchName string `json:"branch_name"`
|
||||
RuleName string `json:"rule_name"`
|
||||
EnablePush bool `json:"enable_push"`
|
||||
EnablePushWhitelist bool `json:"enable_push_whitelist"`
|
||||
}
|
||||
|
||||
type branchProtectionRequest struct {
|
||||
BranchName string `json:"branch_name"`
|
||||
EnablePush bool `json:"enable_push"`
|
||||
EnablePushWhitelist bool `json:"enable_push_whitelist"`
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Status string
|
||||
|
|
@ -96,9 +76,6 @@ type accessToken struct {
|
|||
SHA1 string `json:"sha1"`
|
||||
}
|
||||
|
||||
var copyGit = runGit
|
||||
var hasRemoteBranch = (*RepoManager).HasRemoteBranch
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("forgejo returned %s", e.Status)
|
||||
}
|
||||
|
|
@ -123,24 +100,10 @@ func CreateRegistryToken(baseURL, username, password, otp, name string) (string,
|
|||
}
|
||||
|
||||
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) == "" {
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -164,10 +127,10 @@ func createToken(client *http.Client, baseURL, username, password, otp, name str
|
|||
}
|
||||
var token accessToken
|
||||
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 == "" {
|
||||
return "", fmt.Errorf("Forgejo did not return a token")
|
||||
return "", fmt.Errorf("Forgejo did not return a registry token")
|
||||
}
|
||||
return token.SHA1, nil
|
||||
}
|
||||
|
|
@ -179,53 +142,6 @@ func (rm *RepoManager) InitializeAll(createRepo func(string) error, createFlux f
|
|||
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 {
|
||||
exists, err := rm.repoExists(name)
|
||||
if err != nil {
|
||||
|
|
@ -255,11 +171,15 @@ func (rm *RepoManager) repoExists(name string) (bool, error) {
|
|||
}
|
||||
|
||||
func (rm *RepoManager) createRepo(name, description string, autoInit bool) error {
|
||||
createURL := fmt.Sprintf("%s/api/v1/orgs/%s/repos", rm.BaseURL, rm.Owner)
|
||||
if rm.Owner == rm.Username {
|
||||
createURL = fmt.Sprintf("%s/api/v1/user/repos", rm.BaseURL)
|
||||
}
|
||||
body, err := json.Marshal(createRepoRequest{Name: name, Description: description, Private: true, AutoInit: autoInit, DefaultBranch: rm.Branch})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := rm.apiRequest(http.MethodPost, rm.repositoryCreateURL(), body)
|
||||
status, err := rm.apiRequest(http.MethodPost, createURL, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -270,93 +190,6 @@ func (rm *RepoManager) createRepo(name, description string, autoInit bool) error
|
|||
}
|
||||
|
||||
func (rm *RepoManager) EnsureRepository(name, description string) (bool, error) {
|
||||
return rm.ensureRepository(name, description, false)
|
||||
}
|
||||
|
||||
// EnsureInitializedRepository creates a base branch without application content.
|
||||
func (rm *RepoManager) EnsureInitializedRepository(name, description string) (bool, error) {
|
||||
return rm.ensureRepository(name, description, true)
|
||||
}
|
||||
|
||||
// EnsureRepositoryCopy creates an independent, user-owned copy of source.
|
||||
// Existing repositories are left untouched so user-managed catalog changes are never overwritten.
|
||||
func (rm *RepoManager) EnsureRepositoryCopy(name, description, source string) (bool, error) {
|
||||
sourceURL, err := catalogSourceURL(source)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
exists, err := rm.repoExists(name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if exists {
|
||||
return false, rm.ensureRepositoryCopyRef(name)
|
||||
}
|
||||
if err := rm.createRepositoryCopy(name, description); err != nil {
|
||||
return false, err
|
||||
}
|
||||
temporary, err := os.MkdirTemp("", "maidn-catalog-*")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer os.RemoveAll(temporary)
|
||||
cleanupAskPass, environment, err := rm.gitEnvironment()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer cleanupAskPass()
|
||||
if err := copyGit("", environment, "clone", "--mirror", sourceURL, temporary); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := copyGit(temporary, environment, "push", "--mirror", CloneURL(rm.BaseURL, rm.Owner, name)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, rm.ensureRepositoryCopyRef(name)
|
||||
}
|
||||
|
||||
func (rm *RepoManager) ensureRepositoryCopyRef(name string) error {
|
||||
cleanupAskPass, environment, err := rm.gitEnvironment()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupAskPass()
|
||||
if err := copyGit("", environment, "ls-remote", "--exit-code", CloneURL(rm.BaseURL, rm.Owner, name), "refs/heads/"+rm.Branch); err != nil {
|
||||
return errors.New("Tekton catalog repository does not contain the configured catalog ref; refusing to use ambiguous state")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rm *RepoManager) createRepositoryCopy(name, description string) error {
|
||||
body, err := json.Marshal(createRepoRequest{Name: name, Description: description, Private: true, AutoInit: false, DefaultBranch: rm.Branch})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := rm.apiRequest(http.MethodPost, rm.repositoryCreateURL(), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != http.StatusCreated {
|
||||
return fmt.Errorf("Forgejo catalog repository creation returned status %d; refusing to copy into an ambiguous existing repository", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rm *RepoManager) repositoryCreateURL() string {
|
||||
if rm.Owner == rm.Username {
|
||||
return fmt.Sprintf("%s/api/v1/user/repos", rm.BaseURL)
|
||||
}
|
||||
return fmt.Sprintf("%s/api/v1/orgs/%s/repos", rm.BaseURL, rm.Owner)
|
||||
}
|
||||
|
||||
func catalogSourceURL(source string) (string, error) {
|
||||
parsed, err := url.Parse(source)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") == "" {
|
||||
return "", fmt.Errorf("Tekton catalog source must be a credential-free HTTPS repository URL")
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func (rm *RepoManager) ensureRepository(name, description string, autoInit bool) (bool, error) {
|
||||
exists, err := rm.repoExists(name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
|
@ -364,7 +197,7 @@ func (rm *RepoManager) ensureRepository(name, description string, autoInit bool)
|
|||
if exists {
|
||||
return false, nil
|
||||
}
|
||||
return true, rm.createRepo(name, description, autoInit)
|
||||
return true, rm.createRepo(name, description, false)
|
||||
}
|
||||
|
||||
func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool, createStructure func(string) error) error {
|
||||
|
|
@ -373,7 +206,7 @@ func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool,
|
|||
return err
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
cleanupAskPass, environment, err := rm.gitEnvironment()
|
||||
cleanupAskPass, environment, err := rm.gitEnvironment(tempDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -395,12 +228,7 @@ func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool,
|
|||
if err != nil || !changed || !existing {
|
||||
return err
|
||||
}
|
||||
for _, repository := range rm.MigrationRepositories {
|
||||
if repository == repoName {
|
||||
return rm.createMigrationPullRequest(repoName, targetBranch)
|
||||
}
|
||||
}
|
||||
rm.MigrationRepositories = append(rm.MigrationRepositories, repoName)
|
||||
rm.MigrationPending = true
|
||||
return rm.createMigrationPullRequest(repoName, targetBranch)
|
||||
}
|
||||
|
||||
|
|
@ -409,9 +237,6 @@ func (rm *RepoManager) createMigrationPullRequest(repo, branch string) error {
|
|||
}
|
||||
|
||||
func (rm *RepoManager) CreatePullRequest(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")
|
||||
}
|
||||
body, err := json.Marshal(pullRequestRequest{Title: title, Head: head, Base: base})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -420,57 +245,12 @@ func (rm *RepoManager) CreatePullRequest(repo, title, head, base string) error {
|
|||
if err != nil {
|
||||
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 nil
|
||||
}
|
||||
|
||||
// EnsurePullRequest creates one pull request or returns the one already open
|
||||
// for the exact head branch. It refuses duplicate or otherwise ambiguous state.
|
||||
func (rm *RepoManager) EnsurePullRequest(repo, title, head, base string) error {
|
||||
if head == "" || base == "" || head == base {
|
||||
return fmt.Errorf("Forgejo pull request head and base must be different non-empty branches")
|
||||
}
|
||||
open, err := rm.HasOpenPullRequest(repo, head)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if open {
|
||||
return nil
|
||||
}
|
||||
body, err := json.Marshal(pullRequestRequest{Title: title, Head: head, Base: base})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", rm.BaseURL, rm.Owner, repo), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != http.StatusCreated {
|
||||
return fmt.Errorf("unexpected Forgejo pull request status %d", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasOpenPullRequest reports whether exactly one pull request is open for head.
|
||||
func (rm *RepoManager) HasOpenPullRequest(repo, head string) (bool, error) {
|
||||
values := url.Values{"state": {"open"}, "head": {head}}
|
||||
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode())
|
||||
var pullRequests []pullRequest
|
||||
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &pullRequests)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return false, fmt.Errorf("unexpected Forgejo pull request lookup status %d", status)
|
||||
}
|
||||
if len(pullRequests) > 1 {
|
||||
return false, fmt.Errorf("multiple open Forgejo pull requests exist for branch %q", head)
|
||||
}
|
||||
return len(pullRequests) == 1, nil
|
||||
}
|
||||
|
||||
func (rm *RepoManager) MergePullRequest(repo, head string) error {
|
||||
values := url.Values{"state": {"open"}, "head": {head}}
|
||||
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode())
|
||||
|
|
@ -501,7 +281,7 @@ func (rm *RepoManager) PushBranch(dir, repoURL, branch string) error {
|
|||
}
|
||||
|
||||
func (rm *RepoManager) PushRef(dir, repoURL, sourceRef, targetBranch string) error {
|
||||
cleanupAskPass, environment, err := rm.gitEnvironment()
|
||||
cleanupAskPass, environment, err := rm.gitEnvironment(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -509,89 +289,8 @@ func (rm *RepoManager) PushRef(dir, repoURL, sourceRef, targetBranch string) err
|
|||
return runGit(dir, environment, "push", repoURL, sourceRef+":refs/heads/"+targetBranch)
|
||||
}
|
||||
|
||||
// PublishRepositoryPullRequest applies a managed change on a dedicated branch.
|
||||
// An existing branch is accepted only when it has exactly one open pull request.
|
||||
func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base string, change func(string) error) (bool, error) {
|
||||
if repo == "" || branch == "" || base == "" || branch == base {
|
||||
return false, errors.New("repository pull request requires distinct non-empty branches")
|
||||
}
|
||||
repoURL := CloneURL(rm.BaseURL, rm.Owner, repo)
|
||||
hasBranch, err := hasRemoteBranch(rm, repoURL, branch)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if hasBranch {
|
||||
open, err := rm.HasOpenPullRequest(repo, branch)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !open {
|
||||
return false, fmt.Errorf("Forgejo branch %q exists without exactly one open pull request; refusing to reuse it", branch)
|
||||
}
|
||||
}
|
||||
temporary, err := os.MkdirTemp("", "maidn-registration-*")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer os.RemoveAll(temporary)
|
||||
cleanupAskPass, environment, err := rm.gitEnvironment()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer cleanupAskPass()
|
||||
checkout := base
|
||||
if hasBranch {
|
||||
checkout = branch
|
||||
}
|
||||
if err := runGit("", environment, "clone", "--branch", checkout, repoURL, temporary); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !hasBranch {
|
||||
if err := runGit(temporary, environment, "checkout", "-B", branch, "origin/"+base); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if err := change(temporary); err != nil {
|
||||
return false, err
|
||||
}
|
||||
changed, err := commitAndPush(temporary, repo, branch, environment)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !changed {
|
||||
if !hasBranch {
|
||||
return false, nil
|
||||
}
|
||||
open, err := rm.HasOpenPullRequest(repo, branch)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !open {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func gitDiffQuiet(dir string, environment []string, args ...string) (bool, error) {
|
||||
command := exec.Command("git", append([]string{"diff", "--quiet"}, args...)...)
|
||||
command.Dir = dir
|
||||
command.Env = environment
|
||||
if err := command.Run(); err != nil {
|
||||
if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (rm *RepoManager) HasRemoteBranch(repoURL, branch string) (bool, error) {
|
||||
cleanupAskPass, environment, err := rm.gitEnvironment()
|
||||
cleanupAskPass, environment, err := rm.gitEnvironment("")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
|
@ -607,43 +306,6 @@ func (rm *RepoManager) HasRemoteBranch(repoURL, branch string) (bool, error) {
|
|||
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) {
|
||||
command := exec.Command("git", "branch", "--show-current")
|
||||
command.Dir = dir
|
||||
|
|
@ -657,35 +319,6 @@ func CurrentBranch(dir string) (string, error) {
|
|||
return "", fmt.Errorf("source repository is in detached HEAD state")
|
||||
}
|
||||
|
||||
// EnsureCleanCheckout refuses to publish an ambiguous local worktree.
|
||||
func EnsureCleanCheckout(dir string) error {
|
||||
command := exec.Command("git", "status", "--porcelain=v1", "--untracked-files=all")
|
||||
command.Dir = dir
|
||||
status, err := command.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect application checkout: %w", err)
|
||||
}
|
||||
if len(status) != 0 {
|
||||
return errors.New("application checkout has uncommitted changes; commit or discard them before publishing delivery")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckoutOrigin returns the credential-free origin identity used before publishing.
|
||||
func CheckoutOrigin(dir string) (string, error) {
|
||||
command := exec.Command("git", "remote", "get-url", "origin")
|
||||
command.Dir = dir
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read application checkout origin: %w", err)
|
||||
}
|
||||
origin := strings.TrimSpace(string(output))
|
||||
if origin == "" {
|
||||
return "", errors.New("application checkout origin is empty")
|
||||
}
|
||||
return origin, nil
|
||||
}
|
||||
|
||||
func RepositoryFromURL(repoURL string) (string, string, error) {
|
||||
parsed, err := url.Parse(repoURL)
|
||||
if err != nil {
|
||||
|
|
@ -719,7 +352,7 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err
|
|||
Events: []string{"push", "pull_request"},
|
||||
}
|
||||
for _, existing := range hooks {
|
||||
if hookURL(existing) != webhookURL {
|
||||
if existing.URL != webhookURL {
|
||||
continue
|
||||
}
|
||||
request, err := json.Marshal(hookRequest{Active: createRequest.Active, AuthorizationHeader: createRequest.AuthorizationHeader, Config: createRequest.Config, Events: createRequest.Events})
|
||||
|
|
@ -749,90 +382,6 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err
|
|||
return nil
|
||||
}
|
||||
|
||||
func hookURL(existing hook) string {
|
||||
if existing.URL != "" {
|
||||
return existing.URL
|
||||
}
|
||||
return existing.Config["url"]
|
||||
}
|
||||
|
||||
// TriggerWebhookTest asks Forgejo to deliver a test push for the managed hook.
|
||||
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 {
|
||||
if repo == "" || branch == "" {
|
||||
return errors.New("Forgejo repository and branch are required")
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/branch_protections", rm.BaseURL, rm.Owner, repo)
|
||||
var protections []branchProtection
|
||||
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &protections)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("unexpected Forgejo branch protection lookup status %d", status)
|
||||
}
|
||||
var matching []branchProtection
|
||||
for _, protection := range protections {
|
||||
if protection.BranchName == branch || protection.RuleName == branch {
|
||||
matching = append(matching, protection)
|
||||
}
|
||||
}
|
||||
if len(matching) > 1 {
|
||||
return fmt.Errorf("multiple Forgejo branch protections match branch %q", branch)
|
||||
}
|
||||
if len(matching) == 1 {
|
||||
if matching[0].EnablePush || matching[0].EnablePushWhitelist {
|
||||
return fmt.Errorf("Forgejo branch %q permits direct pushes", branch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
body, err := json.Marshal(branchProtectionRequest{BranchName: branch})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, err = rm.apiRequest(http.MethodPost, endpoint, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != http.StatusCreated {
|
||||
return fmt.Errorf("unexpected Forgejo branch protection create status %d", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func commitAndPush(tempDir, repoName, branch string, environment []string) (bool, error) {
|
||||
for _, args := range [][]string{{"config", "user.name", "Maidn"}, {"config", "user.email", "maidn@free-maidn.com"}, {"add", "."}} {
|
||||
if err := runGit(tempDir, environment, args...); err != nil {
|
||||
|
|
@ -859,13 +408,8 @@ func commitAndPush(tempDir, repoName, branch string, environment []string) (bool
|
|||
return true, nil
|
||||
}
|
||||
|
||||
func (rm *RepoManager) gitEnvironment() (func(), []string, error) {
|
||||
return GitEnvironment(rm.Username, rm.Token)
|
||||
}
|
||||
|
||||
// GitEnvironment returns a temporary Git askpass environment without persisting credentials.
|
||||
func GitEnvironment(username, token string) (func(), []string, error) {
|
||||
if token == "" {
|
||||
func (rm *RepoManager) gitEnvironment(tempDir string) (func(), []string, error) {
|
||||
if rm.Token == "" {
|
||||
return func() {}, append(os.Environ(), "GIT_TERMINAL_PROMPT=0"), nil
|
||||
}
|
||||
askPassDir, err := os.MkdirTemp("", "maidn-askpass-*")
|
||||
|
|
@ -882,7 +426,7 @@ func GitEnvironment(username, token string) (func(), []string, error) {
|
|||
_ = os.RemoveAll(askPassDir)
|
||||
return nil, nil, err
|
||||
}
|
||||
environment := append(os.Environ(), "GIT_ASKPASS="+path, "GIT_TERMINAL_PROMPT=0", "MAIDN_GIT_USERNAME="+username, "MAIDN_GIT_TOKEN="+token)
|
||||
environment := append(os.Environ(), "GIT_ASKPASS="+path, "GIT_TERMINAL_PROMPT=0", "MAIDN_GIT_USERNAME="+rm.Username, "MAIDN_GIT_TOKEN="+rm.Token)
|
||||
return func() { _ = os.RemoveAll(askPassDir) }, environment, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@ package forgejo
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -41,122 +39,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) {
|
||||
original := copyGit
|
||||
t.Cleanup(func() { copyGit = original })
|
||||
var commands []string
|
||||
copyGit = func(_ string, _ []string, args ...string) error {
|
||||
commands = append(commands, strings.Join(args, " "))
|
||||
return nil
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.Method {
|
||||
case http.MethodGet:
|
||||
if request.URL.Path != "/api/v1/repos/owner/catalog" {
|
||||
t.Fatalf("unexpected lookup %q", request.URL.Path)
|
||||
}
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
case http.MethodPost:
|
||||
if request.URL.Path != "/api/v1/orgs/owner/repos" {
|
||||
t.Fatalf("unexpected create %q", request.URL.Path)
|
||||
}
|
||||
var body createRepoRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.AutoInit || body.DefaultBranch != "release" {
|
||||
t.Fatalf("unexpected catalog create request: %#v, %v", body, err)
|
||||
}
|
||||
writer.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
t.Fatalf("unexpected request method %q", request.Method)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
manager := NewRepoManager(server.URL, "test-token", "owner", "bot", "", "", "release", "")
|
||||
manager.HTTPClient = server.Client()
|
||||
if created, err := manager.EnsureRepositoryCopy("catalog", "catalog", "https://catalog.example.test/tekton.git"); err != nil || !created {
|
||||
t.Fatalf("EnsureRepositoryCopy() = (%t, %v)", created, err)
|
||||
}
|
||||
joined := strings.Join(commands, "\n")
|
||||
if !strings.Contains(joined, "clone --mirror https://catalog.example.test/tekton.git") || !strings.Contains(joined, "push --mirror "+CloneURL(server.URL, "owner", "catalog")) || !strings.Contains(joined, "ls-remote --exit-code "+CloneURL(server.URL, "owner", "catalog")+" refs/heads/release") || strings.Contains(joined, "test-token") {
|
||||
t.Fatalf("catalog copy command boundary is unsafe: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRepositoryCopyRejectsMissingConfiguredRefAfterCopy(t *testing.T) {
|
||||
original := copyGit
|
||||
t.Cleanup(func() { copyGit = original })
|
||||
var commands []string
|
||||
copyGit = func(_ string, _ []string, args ...string) error {
|
||||
commands = append(commands, strings.Join(args, " "))
|
||||
if args[0] == "ls-remote" {
|
||||
return errors.New("missing ref")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.Method {
|
||||
case http.MethodGet:
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
case http.MethodPost:
|
||||
writer.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
t.Fatalf("unexpected request method %q", request.Method)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
manager := NewRepoManager(server.URL, "test-token", "owner", "bot", "", "", "release", "")
|
||||
manager.HTTPClient = server.Client()
|
||||
if created, err := manager.EnsureRepositoryCopy("catalog", "catalog", "https://catalog.example.test/tekton.git"); !created || err == nil || !strings.Contains(err.Error(), "configured catalog ref") {
|
||||
t.Fatalf("EnsureRepositoryCopy() = (%t, %v), want copied catalog ref error", created, err)
|
||||
}
|
||||
joined := strings.Join(commands, "\n")
|
||||
if !strings.Contains(joined, "push --mirror "+CloneURL(server.URL, "owner", "catalog")) || !strings.Contains(joined, "ls-remote --exit-code "+CloneURL(server.URL, "owner", "catalog")+" refs/heads/release") {
|
||||
t.Fatalf("catalog copy did not verify the missing non-default ref: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRepositoryCopyRejectsCredentialBearingSourceBeforeHTTP(t *testing.T) {
|
||||
manager := NewRepoManager("https://git.example.test", "test-token", "owner", "bot", "", "", "main", "")
|
||||
manager.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
t.Fatal("credential-bearing source reached Forgejo HTTP")
|
||||
return nil, nil
|
||||
})}
|
||||
if _, err := manager.EnsureRepositoryCopy("catalog", "catalog", "https://reader:secret@catalog.example.test/tekton.git"); err == nil || strings.Contains(err.Error(), "secret") {
|
||||
t.Fatalf("EnsureRepositoryCopy() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
|
||||
|
||||
func TestRepoExistsOnlyCreatesOnNotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
|
|
@ -169,40 +51,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) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
|
|
@ -212,7 +60,7 @@ func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) {
|
|||
if request.URL.Path != "/api/v1/repos/owner/app/hooks" {
|
||||
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:
|
||||
if request.URL.Path != "/api/v1/repos/owner/app/hooks/7" {
|
||||
t.Fatalf("unexpected update path %q", request.URL.Path)
|
||||
|
|
@ -266,94 +114,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) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/api/v1/repos/owner/app/branch_protections" {
|
||||
t.Fatalf("unexpected branch protection path %q", request.URL.Path)
|
||||
}
|
||||
switch request.Method {
|
||||
case http.MethodGet:
|
||||
_ = json.NewEncoder(writer).Encode([]branchProtection{})
|
||||
case http.MethodPost:
|
||||
var body branchProtectionRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.BranchName != "production" || body.EnablePush || body.EnablePushWhitelist {
|
||||
t.Fatalf("unexpected branch protection request: %#v", body)
|
||||
}
|
||||
writer.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
t.Fatalf("unexpected method %q", request.Method)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
manager := NewRepoManager(server.URL, "token", "owner", "user", "", "", "main", "")
|
||||
manager.HTTPClient = server.Client()
|
||||
if err := manager.EnsureProtectedBranch("app", "production"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet {
|
||||
t.Fatal("existing unsafe protection must not be changed")
|
||||
}
|
||||
_ = json.NewEncoder(writer).Encode([]branchProtection{{ID: 1, BranchName: "production", EnablePush: true}})
|
||||
}))
|
||||
defer server.Close()
|
||||
manager := NewRepoManager(server.URL, "token", "owner", "user", "", "", "main", "")
|
||||
manager.HTTPClient = server.Client()
|
||||
if err := manager.EnsureProtectedBranch("app", "production"); err == nil || !strings.Contains(err.Error(), "permits direct pushes") {
|
||||
t.Fatalf("EnsureProtectedBranch() error = %v, want unsafe rule error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryFromURL(t *testing.T) {
|
||||
owner, repo, err := RepositoryFromURL("https://git.example.test/team/app.git")
|
||||
if err != nil || owner != "team" || repo != "app" {
|
||||
|
|
@ -400,34 +160,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) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.Method {
|
||||
|
|
@ -454,49 +186,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) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
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,12 +2,8 @@ package openbao
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -16,21 +12,15 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type AppSecretIdentityTokens struct {
|
||||
Admin string
|
||||
E2E string
|
||||
}
|
||||
|
||||
type status struct {
|
||||
Initialized bool `json:"initialized"`
|
||||
Sealed bool `json:"sealed"`
|
||||
}
|
||||
|
||||
type RecoveryMaterial struct {
|
||||
type recovery struct {
|
||||
UnsealKeysB64 []string `json:"unseal_keys_b64"`
|
||||
UnsealThreshold int `json:"unseal_threshold"`
|
||||
RootToken string `json:"root_token"`
|
||||
|
|
@ -40,26 +30,6 @@ type operationalSecrets struct {
|
|||
Secrets map[string]map[string]string `yaml:"secrets"`
|
||||
}
|
||||
|
||||
var decryptRecovery = func(identityPath, bundlePath string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "age", "-d", "-i", identityPath, bundlePath)
|
||||
output, err := cmd.Output()
|
||||
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 output, nil
|
||||
}
|
||||
|
||||
var openBaoStatus = getStatus
|
||||
|
||||
var commandTimeout = time.Minute
|
||||
|
||||
var openBaoMutationTimeout = 5 * time.Minute
|
||||
|
||||
func EnsureRecoveryIdentity(identityPath string) (string, error) {
|
||||
if _, err := os.Stat(identityPath); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(filepath.Dir(identityPath), 0700); err != nil {
|
||||
|
|
@ -82,76 +52,57 @@ func EnsureRecoveryIdentity(identityPath 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 {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "OpenBao: wait for pod")
|
||||
if err := waitForPod(kubeconfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "OpenBao: read status")
|
||||
current, err := getStatus(kubeconfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var material RecoveryMaterial
|
||||
var material recovery
|
||||
if !current.Initialized {
|
||||
fmt.Fprintln(os.Stderr, "OpenBao: initialize")
|
||||
output, err := execInPod(kubeconfig, nil, "bao", "operator", "init", "-format=json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize OpenBao: %w", err)
|
||||
}
|
||||
material, err = parseRecoveryMaterial(output)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if err := json.Unmarshal(output, &material); err != nil {
|
||||
return nil, fmt.Errorf("parse OpenBao recovery material: %w", err)
|
||||
}
|
||||
if err := encryptRecovery(recipient, bundlePath, output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "OpenBao: decrypt recovery material")
|
||||
material, err = ReadRecoveryMaterial(identityPath, bundlePath)
|
||||
output, err := decryptRecovery(identityPath, bundlePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(output, &material); err != nil {
|
||||
return nil, fmt.Errorf("parse encrypted OpenBao recovery material: %w", err)
|
||||
}
|
||||
}
|
||||
if current.Sealed {
|
||||
fmt.Fprintln(os.Stderr, "OpenBao: unseal")
|
||||
if err := unseal(kubeconfig, material); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
reviewerToken, err := kubectlOutput(kubeconfig, "-n", "openbao", "create", "token", "openbao-auth", "--duration=8760h")
|
||||
reviewerToken, err := kubectlOutput(kubeconfig, "-n", "openbao", "create", "token", "openbao-auth")
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "OpenBao: seed operational secrets")
|
||||
secrets, err := seedOperationalSecrets(kubeconfig, material.RootToken, ageKeyPath, operationalSecretsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "OpenBao: refresh External Secrets")
|
||||
if err := refreshExternalSecrets(kubeconfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return secrets, nil
|
||||
return seedOperationalSecrets(kubeconfig, material.RootToken, ageKeyPath, operationalSecretsPath)
|
||||
}
|
||||
|
||||
func seedOperationalSecrets(kubeconfig, rootToken, ageKeyPath, path string) (map[string]map[string]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "sops", "--decrypt", "--output-type", "yaml", path)
|
||||
cmd := exec.Command("sops", "--decrypt", "--output-type", "yaml", path)
|
||||
cmd.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+ageKeyPath)
|
||||
plaintext, err := cmd.Output()
|
||||
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)
|
||||
}
|
||||
var document operationalSecrets
|
||||
|
|
@ -188,60 +139,14 @@ func validateRecoveryRecipient(recipient, bundlePath string) error {
|
|||
return err
|
||||
}
|
||||
defer os.Remove(probePath)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "age", "-r", recipient, "-o", probePath)
|
||||
cmd.Stdin = bytes.NewReader(nil)
|
||||
cmd := exec.Command("age", "-r", recipient, "-o", probePath)
|
||||
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 nil
|
||||
}
|
||||
|
||||
// ReadRecoveryMaterial decrypts and validates the local OpenBao recovery bundle.
|
||||
func ReadRecoveryMaterial(identityPath, bundlePath string) (RecoveryMaterial, error) {
|
||||
plaintext, err := decryptRecovery(identityPath, bundlePath)
|
||||
if err != nil {
|
||||
return RecoveryMaterial{}, err
|
||||
}
|
||||
return parseRecoveryMaterial(plaintext)
|
||||
}
|
||||
|
||||
func parseRecoveryMaterial(plaintext []byte) (RecoveryMaterial, error) {
|
||||
var material RecoveryMaterial
|
||||
decoder := json.NewDecoder(bytes.NewReader(plaintext))
|
||||
if err := decoder.Decode(&material); err != nil {
|
||||
return RecoveryMaterial{}, fmt.Errorf("parse OpenBao recovery material: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material must contain one JSON document")
|
||||
}
|
||||
if material.RootToken == "" || strings.TrimSpace(material.RootToken) != material.RootToken {
|
||||
return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material requires a root token")
|
||||
}
|
||||
if material.UnsealThreshold < 1 {
|
||||
return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material requires a positive unseal threshold")
|
||||
}
|
||||
if len(material.UnsealKeysB64) < material.UnsealThreshold {
|
||||
return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material contains fewer than %d unseal keys", material.UnsealThreshold)
|
||||
}
|
||||
seen := make(map[string]bool, len(material.UnsealKeysB64))
|
||||
for _, key := range material.UnsealKeysB64 {
|
||||
if key == "" || strings.TrimSpace(key) != key {
|
||||
return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material contains an invalid unseal key")
|
||||
}
|
||||
if seen[key] {
|
||||
return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material contains duplicate unseal keys")
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
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 {
|
||||
return fmt.Errorf("invalid OpenBao secret path %q", secretPath)
|
||||
}
|
||||
|
|
@ -255,53 +160,38 @@ func writeSecret(kubeconfig, token, secretPath string, values map[string]string)
|
|||
sort.Strings(keys)
|
||||
arguments := make([]string, 0, len(keys))
|
||||
input := strings.Builder{}
|
||||
input.WriteString(token)
|
||||
input.WriteString(rootToken)
|
||||
input.WriteByte('\n')
|
||||
for _, key := range keys {
|
||||
arguments = append(arguments, fmt.Sprintf("%s=\"$value%d\"", key, len(arguments)))
|
||||
input.WriteString(base64.StdEncoding.EncodeToString([]byte(values[key])))
|
||||
input.WriteString(values[key])
|
||||
input.WriteByte('\n')
|
||||
}
|
||||
reads := make([]string, 0, len(keys))
|
||||
decodes := make([]string, 0, len(keys))
|
||||
for index := range keys {
|
||||
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))
|
||||
reads = append(reads, fmt.Sprintf("read -r value%d", 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"
|
||||
_, err := execInPodMutation(kubeconfig, []byte(input.String()), "sh", "-ec", script)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write OpenBao secret %q", secretPath)
|
||||
}
|
||||
return nil
|
||||
script := "read -r root_token\n" + strings.Join(reads, "\n") + "\nexport BAO_TOKEN=\"$root_token\"\nbao kv put secret/" + secretPath + " " + strings.Join(arguments, " ") + " >/dev/null"
|
||||
_, err := execInPod(kubeconfig, []byte(input.String()), "sh", "-ec", script)
|
||||
return err
|
||||
}
|
||||
|
||||
func waitForPod(kubeconfig string) error {
|
||||
deadline := time.Now().Add(10 * time.Minute)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := getStatus(kubeconfig); err == nil {
|
||||
if _, err := kubectlOutput(kubeconfig, "-n", "openbao", "get", "pod", "openbao-0"); err == nil {
|
||||
if output, _ := execInPod(kubeconfig, nil, "bao", "status", "-format=json"); len(output) > 0 {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
func getStatus(kubeconfig string) (status, error) {
|
||||
command := []string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "openbao-0", "--", "bao", "status", "-format=json"}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||
defer cancel()
|
||||
output, err := exec.CommandContext(ctx, "kubectl", command...).Output()
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return status{}, fmt.Errorf("get OpenBao status timed out after %s", commandTimeout)
|
||||
}
|
||||
if err != nil && !json.Valid(output) {
|
||||
output, err := execInPod(kubeconfig, nil, "bao", "status", "-format=json")
|
||||
if err != nil && len(output) == 0 {
|
||||
return status{}, fmt.Errorf("get OpenBao status: %w", err)
|
||||
}
|
||||
var current status
|
||||
|
|
@ -311,205 +201,44 @@ func getStatus(kubeconfig string) (status, error) {
|
|||
return current, nil
|
||||
}
|
||||
|
||||
func unseal(kubeconfig string, material RecoveryMaterial) error {
|
||||
if material.UnsealThreshold < 1 || len(material.UnsealKeysB64) < material.UnsealThreshold {
|
||||
return fmt.Errorf("OpenBao recovery material has insufficient unseal keys")
|
||||
func unseal(kubeconfig string, material recovery) error {
|
||||
threshold := material.UnsealThreshold
|
||||
if threshold == 0 {
|
||||
threshold = 3
|
||||
}
|
||||
for _, key := range material.UnsealKeysB64 {
|
||||
_, _ = execInPod(kubeconfig, []byte(key+"\n"), "sh", "-ec", "read -r key; bao operator unseal \"$key\" >/dev/null")
|
||||
if len(material.UnsealKeysB64) < threshold {
|
||||
return fmt.Errorf("OpenBao recovery bundle contains fewer than %d unseal keys", threshold)
|
||||
}
|
||||
current, err := openBaoStatus(kubeconfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify OpenBao unseal: %w", err)
|
||||
for _, key := range material.UnsealKeysB64[:threshold] {
|
||||
if _, err := execInPod(kubeconfig, []byte(key+"\n"), "sh", "-ec", "read -r key; bao operator unseal \"$key\" >/dev/null"); err != nil {
|
||||
return fmt.Errorf("unseal OpenBao: %w", err)
|
||||
}
|
||||
if current.Sealed {
|
||||
return unsealWithControllerSecret(kubeconfig)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unsealWithControllerSecret(kubeconfig string) error {
|
||||
const script = `bao operator unseal -reset >/dev/null 2>&1 || true
|
||||
for share in /unseal/unseal-*; do
|
||||
[ -f "$share" ] || continue
|
||||
cat "$share" | bao operator unseal >/dev/null 2>&1 || true
|
||||
done`
|
||||
if _, err := execInUnsealController(kubeconfig, script); err != nil {
|
||||
return fmt.Errorf("unseal OpenBao with controller secret: %w", err)
|
||||
}
|
||||
current, err := openBaoStatus(kubeconfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify OpenBao controller unseal: %w", err)
|
||||
}
|
||||
if current.Sealed {
|
||||
return errors.New("OpenBao remains sealed after controller unseal")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func configureKubernetesAuth(kubeconfig, rootToken, reviewerToken string) error {
|
||||
const script = `fail() { printf '%s\n' "$1" >&2; exit 1; }
|
||||
read -r root_token
|
||||
const script = `read -r root_token
|
||||
read -r reviewer_token
|
||||
export BAO_TOKEN="$root_token"
|
||||
bao secrets enable -path=secret kv-v2 >/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'
|
||||
path "secret/data/platform/*" {
|
||||
path "secret/data/*" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
path "secret/data/cicd/*" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
path "secret/metadata/platform/*" {
|
||||
capabilities = ["list", "read"]
|
||||
}
|
||||
path "secret/metadata/cicd/*" {
|
||||
path "secret/metadata/*" {
|
||||
capabilities = ["list", "read"]
|
||||
}
|
||||
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
|
||||
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")
|
||||
output, err := execInPodMutation(kubeconfig, input, "sh", "-ec", script)
|
||||
if err != nil {
|
||||
return openBaoMutationError("configure Kubernetes auth", err, output, rootToken, reviewerToken)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigureSecretGrants creates only OpenBao policies and Kubernetes auth
|
||||
// roles. GitOps manifests create the matching SecretStores and ExternalSecrets.
|
||||
func ConfigureSecretGrants(kubeconfig, identityPath, bundlePath string, grants []config.SecretGrant) error {
|
||||
if err := config.ValidateSecretGrants(grants); err != nil {
|
||||
_, err := execInPod(kubeconfig, input, "sh", "-ec", script)
|
||||
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 {
|
||||
available, err := kubectlOutput(kubeconfig, "--request-timeout=30s", "-n", "external-secrets", "get", "deployment/external-secrets", "-o=jsonpath={.status.conditions[?(@.type==\"Available\")].status}")
|
||||
if err != nil || strings.TrimSpace(string(available)) != "True" {
|
||||
return nil
|
||||
}
|
||||
timestamp := time.Now().UnixNano()
|
||||
fmt.Fprintln(os.Stderr, "OpenBao: refresh OpenBao secret store")
|
||||
_, err = kubectlOutput(kubeconfig, "--request-timeout=30s", "annotate", "clustersecretstore", "openbao", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite")
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh OpenBao secret store after seed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encryptRecovery(recipient, bundlePath string, plaintext []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil {
|
||||
|
|
@ -517,44 +246,29 @@ func encryptRecovery(recipient, bundlePath string, plaintext []byte) error {
|
|||
}
|
||||
cmd := exec.Command("age", "-r", recipient, "-o", bundlePath)
|
||||
cmd.Stdin = bytes.NewReader(plaintext)
|
||||
if _, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("encrypt OpenBao recovery material: %w", err)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("encrypt OpenBao recovery material: %w: %s", err, bytes.TrimSpace(output))
|
||||
}
|
||||
return os.Chmod(bundlePath, 0600)
|
||||
}
|
||||
|
||||
var execInPod = func(kubeconfig string, input []byte, args ...string) ([]byte, error) {
|
||||
func decryptRecovery(identityPath, bundlePath string) ([]byte, error) {
|
||||
cmd := exec.Command("age", "-d", "-i", identityPath, bundlePath)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt OpenBao recovery material: %w", err)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func execInPod(kubeconfig string, input []byte, args ...string) ([]byte, error) {
|
||||
command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...)
|
||||
return commandOutput(input, "kubectl", command...)
|
||||
}
|
||||
|
||||
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) {
|
||||
command := []string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "deployment/openbao-unseal", "--", "sh", "-ec", script}
|
||||
return commandOutput(nil, "kubectl", command...)
|
||||
}
|
||||
|
||||
var kubectlOutput = func(kubeconfig string, args ...string) ([]byte, error) {
|
||||
command := append([]string{"--kubeconfig", kubeconfig}, args...)
|
||||
return commandOutput(nil, "kubectl", command...)
|
||||
}
|
||||
|
||||
func commandOutput(input []byte, name string, args ...string) ([]byte, error) {
|
||||
return commandOutputWithTimeout(input, commandTimeout, name, args...)
|
||||
}
|
||||
|
||||
func commandOutputWithTimeout(input []byte, timeout time.Duration, name string, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd := exec.Command("kubectl", command...)
|
||||
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 cmd.CombinedOutput()
|
||||
}
|
||||
return output, err
|
||||
|
||||
func kubectlOutput(kubeconfig string, args ...string) ([]byte, error) {
|
||||
command := append([]string{"--kubeconfig", kubeconfig}, args...)
|
||||
return exec.Command("kubectl", command...).Output()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,10 @@
|
|||
package openbao
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||
)
|
||||
|
||||
func TestEnsureRecoveryIdentity(t *testing.T) {
|
||||
|
|
@ -24,221 +19,3 @@ func TestEnsureRecoveryIdentity(t *testing.T) {
|
|||
t.Fatalf("invalid recovery recipient")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsealUsesPromptedStdinOnly(t *testing.T) {
|
||||
originalExec, originalStatus := execInPod, openBaoStatus
|
||||
t.Cleanup(func() { execInPod, openBaoStatus = originalExec, originalStatus })
|
||||
calls := 0
|
||||
execInPod = func(_ string, input []byte, args ...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" {
|
||||
t.Fatal("unseal share was not submitted through prompted stdin")
|
||||
}
|
||||
calls++
|
||||
return nil, 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 {
|
||||
t.Fatalf("unseal calls:%d err:%v", calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsealFallsBackToControllerSecret(t *testing.T) {
|
||||
originalExec, originalStatus, originalController := execInPod, openBaoStatus, execInUnsealController
|
||||
t.Cleanup(func() {
|
||||
execInPod, openBaoStatus, execInUnsealController = originalExec, originalStatus, originalController
|
||||
})
|
||||
shares := 0
|
||||
execInPod = func(_ string, _ []byte, _ ...string) ([]byte, error) { shares++; return nil, nil }
|
||||
statusChecks := 0
|
||||
openBaoStatus = func(string) (status, error) {
|
||||
statusChecks++
|
||||
return status{Initialized: true, Sealed: statusChecks == 1}, nil
|
||||
}
|
||||
controllerCalled := false
|
||||
execInUnsealController = func(_ string, script string) ([]byte, error) {
|
||||
controllerCalled = strings.Contains(script, "/unseal/unseal-*")
|
||||
return nil, nil
|
||||
}
|
||||
if err := unseal("kubeconfig", RecoveryMaterial{UnsealKeysB64: []string{"share-1", "share-2", "share-3"}, UnsealThreshold: 2}); err != nil || shares != 3 || !controllerCalled {
|
||||
t.Fatalf("unseal fallback = shares:%d controller:%t err:%v", shares, controllerCalled, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSecretFramesMultilineValues(t *testing.T) {
|
||||
original := execInPodMutation
|
||||
t.Cleanup(func() { execInPodMutation = original })
|
||||
var input, script string
|
||||
execInPodMutation = func(_ string, contents []byte, args ...string) ([]byte, error) {
|
||||
input, script = string(contents), args[len(args)-1]
|
||||
return nil, nil
|
||||
}
|
||||
value := "tunnel: tunnel\ningress:\n - service: http_status:404\n"
|
||||
if err := writeSecret("kubeconfig", "root", "platform/cloudflare-tunnel", map[string]string{"config": value}); err != nil || input != "root\n"+base64.StdEncoding.EncodeToString([]byte(value))+"\n" || !strings.Contains(script, "base64 -d") {
|
||||
t.Fatalf("multiline secret boundary was not framed safely: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
original := kubectlOutput
|
||||
t.Cleanup(func() { kubectlOutput = original })
|
||||
var calls []string
|
||||
kubectlOutput = func(_ string, args ...string) ([]byte, error) {
|
||||
calls = append(calls, strings.Join(args, " "))
|
||||
if len(calls) == 1 {
|
||||
return []byte("True"), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 2 || !strings.Contains(calls[0], "get deployment/external-secrets") || !strings.Contains(calls[1], "annotate clustersecretstore openbao") || strings.Contains(calls[1], "--all") {
|
||||
t.Fatalf("ExternalSecret refresh was not readiness-gated and scoped: %q, %v", calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
original := decryptRecovery
|
||||
t.Cleanup(func() { decryptRecovery = original })
|
||||
called := false
|
||||
decryptRecovery = func(identityPath, bundlePath string) ([]byte, error) {
|
||||
called = identityPath == "recovery-identity" && bundlePath == "recovery-bundle"
|
||||
return []byte(`{"unseal_keys_b64":["test-share-1","test-share-2"],"unseal_threshold":2,"root_token":"test-root"}`), nil
|
||||
}
|
||||
|
||||
material, err := ReadRecoveryMaterial("recovery-identity", "recovery-bundle")
|
||||
if err != nil || !called || material.UnsealThreshold != 2 || len(material.UnsealKeysB64) != 2 || material.RootToken == "" {
|
||||
t.Fatal("valid OpenBao recovery material was not read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRecoveryMaterialRejectsMalformedOrInsufficientBundle(t *testing.T) {
|
||||
original := decryptRecovery
|
||||
t.Cleanup(func() { decryptRecovery = original })
|
||||
for _, plaintext := range [][]byte{
|
||||
[]byte(`{"unseal_keys_b64":`),
|
||||
[]byte(`{"unseal_keys_b64":["test-share"],"unseal_threshold":2,"root_token":"test-root"}`),
|
||||
} {
|
||||
decryptRecovery = func(string, string) ([]byte, error) { return plaintext, nil }
|
||||
if _, err := ReadRecoveryMaterial("recovery-identity", "recovery-bundle"); err == nil {
|
||||
t.Fatal("invalid OpenBao recovery material was accepted")
|
||||
}
|
||||
}
|
||||
decryptRecovery = func(string, string) ([]byte, error) { return nil, errors.New("unavailable") }
|
||||
if _, err := ReadRecoveryMaterial("recovery-identity", "recovery-bundle"); err == nil {
|
||||
t.Fatal("recovery decryption failure was accepted")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,11 +91,6 @@ type StorageInfo struct {
|
|||
SupportsDisk bool
|
||||
}
|
||||
|
||||
type VMInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
func New(baseURL, tokenID, tokenSecret string, insecure bool) *Client {
|
||||
transport := &http.Transport{}
|
||||
if insecure {
|
||||
|
|
@ -136,20 +131,6 @@ func (c *Client) Discover() ([]NodeInfo, error) {
|
|||
return result, nil
|
||||
}
|
||||
|
||||
// GetVM returns the identity fields used to verify a configured VM before import.
|
||||
func (c *Client) GetVM(node string, vmid int) (VMInfo, error) {
|
||||
var response struct {
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := c.getJSON("/nodes/"+url.PathEscape(node)+"/qemu/"+fmt.Sprint(vmid)+"/config", &response); err != nil {
|
||||
return VMInfo{}, err
|
||||
}
|
||||
return VMInfo{Name: response.Data.Name, Description: response.Data.Description}, nil
|
||||
}
|
||||
|
||||
func (c *Client) describeNode(node string) (NodeInfo, error) {
|
||||
var status nodeStatusResponse
|
||||
if err := c.getJSON("/nodes/"+node+"/status", &status); err != nil {
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
package proxmox
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetVMReadsIdentity(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.EscapedPath() != "/api2/json/nodes/node%201/qemu/100/config" {
|
||||
t.Fatalf("path = %q", request.URL.EscapedPath())
|
||||
}
|
||||
_, _ = writer.Write([]byte(`{"data":{"name":"cp-01","description":"Talos Control Plane Node - Managed by Terraform"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
vm, err := New(server.URL, "token-id", "token-secret", false).GetVM("node 1", 100)
|
||||
if err != nil || vm.Name != "cp-01" || vm.Description != "Talos Control Plane Node - Managed by Terraform" {
|
||||
t.Fatalf("GetVM() = %#v, %v", vm, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -98,17 +98,6 @@ func PromptForgejoRegistryToken() (password, otp, name string, err error) {
|
|||
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) {
|
||||
token := promptSecret(reader, prompt, "")
|
||||
if token == "" {
|
||||
|
|
@ -167,13 +156,6 @@ func promptHiddenRequired(prompt string) (string, error) {
|
|||
|
||||
func promptHiddenOptional(prompt string) (string, error) {
|
||||
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))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -33,13 +33,6 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) {
|
|||
cfg.Flux.ManifestsRepo = prompt(reader, "Manifests repo name", fallback(cfg.Flux.ManifestsRepo, "cicd-deployment-manifests"))
|
||||
cfg.Flux.Branch = "main"
|
||||
cfg.Flux.ClusterPath = "./clusters/maidn-cd-0"
|
||||
cfg.Delivery.AppName = prompt(reader, "Application name", fallback(cfg.Delivery.AppName, "app"))
|
||||
cfg.Delivery.AppRepoURL = prompt(reader, "Application repository URL", fallback(cfg.Delivery.AppRepoURL, strings.TrimRight(cfg.Git.BaseURL, "/")+"/"+cfg.Git.Owner+"/"+cfg.Delivery.AppName+".git"))
|
||||
cfg.Delivery.AppRepoRef = prompt(reader, "Application base branch", fallback(cfg.Delivery.AppRepoRef, cfg.Flux.Branch))
|
||||
cfg.Delivery.ProductionBranch = prompt(reader, "Application production branch", fallback(cfg.Delivery.ProductionBranch, "production"))
|
||||
cfg.Delivery.ImageRepository = prompt(reader, "Application image repository", fallback(cfg.Delivery.ImageRepository, strings.TrimPrefix(strings.TrimPrefix(cfg.Git.BaseURL, "https://"), "http://")+"/"+strings.ToLower(cfg.Git.Owner)+"/"+cfg.Delivery.AppName))
|
||||
cfg.Delivery.BuildOutputDirectory = prompt(reader, "Application build output directory", fallback(cfg.Delivery.BuildOutputDirectory, "dist"))
|
||||
cfg.Delivery.BuildConfiguration = prompt(reader, "Application build configuration", fallback(cfg.Delivery.BuildConfiguration, "production"))
|
||||
cfg.Templates.TalosRepoURL = prompt(reader, "Talos template repo URL", fallback(cfg.Templates.TalosRepoURL, "https://git.pingu.pw/Maidn/maidn-talos-proxmox.git"))
|
||||
cfg.Templates.CICDRepoURL = prompt(reader, "CI/CD template repo URL", fallback(cfg.Templates.CICDRepoURL, "https://git.pingu.pw/Maidn/maidn-cicd-cluster-template.git"))
|
||||
cfg.Templates.ManifestsRepoURL = prompt(reader, "Manifests template repo URL", fallback(cfg.Templates.ManifestsRepoURL, "https://git.pingu.pw/Maidn/cicd-deployment-manifests-template.git"))
|
||||
|
|
@ -137,11 +130,10 @@ func PromptOperationalSecrets(cfg config.Config) (map[string]map[string]string,
|
|||
return nil, errors.New("git username, token, and delivery imageRepository are required")
|
||||
}
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
piholeServer := prompt(reader, "Pi-hole server", "")
|
||||
piholePassword := promptSecret(reader, "Pi-hole password", "")
|
||||
cloudflareAPIToken := promptSecret(reader, "Cloudflare API token", "")
|
||||
if piholeServer == "" || piholePassword == "" || cloudflareAPIToken == "" {
|
||||
return nil, errors.New("Pi-hole server, password, and Cloudflare API token are required")
|
||||
cloudflareTunnelToken := promptSecret(reader, "Cloudflare Tunnel token", "")
|
||||
if cloudflareAPIToken == "" || cloudflareTunnelToken == "" {
|
||||
return nil, errors.New("Cloudflare API and Tunnel tokens are required")
|
||||
}
|
||||
registryHost := strings.Split(cfg.Delivery.ImageRepository, "/")[0]
|
||||
dockerConfig, err := json.Marshal(map[string]map[string]map[string]string{
|
||||
|
|
@ -155,8 +147,8 @@ func PromptOperationalSecrets(cfg config.Config) (map[string]map[string]string,
|
|||
return map[string]map[string]string{
|
||||
"cicd/forgejo": {"username": cfg.Git.Username, "token": cfg.Git.Token},
|
||||
"cicd/forgejo-registry": {"dockerconfigjson": string(dockerConfig)},
|
||||
"platform/pihole": {"server": piholeServer, "password": piholePassword},
|
||||
"platform/cloudflare": {"api-token": cloudflareAPIToken},
|
||||
"platform/cloudflare-tunnel": {"token": cloudflareTunnelToken},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue