From 7b64f8fbdb60f10b737cde1b5587b153978721eb Mon Sep 17 00:00:00 2001 From: eding Date: Sat, 22 Aug 2026 13:58:28 +0200 Subject: [PATCH] feat: harden bootstrap and generic delivery --- cmd/bootstrap.go | 50 +- internal/assets/assets.go | 3 + .../templates/delivery-pipeline.yaml.tmpl | 326 ++++++++++ internal/bootstrap/bootstrap.go | 559 +++++++++++++++++- internal/bootstrap/bootstrap_test.go | 102 +++- internal/bootstrap/template_revisions_test.go | 100 +++- internal/config/config.go | 73 ++- internal/config/config_test.go | 45 +- internal/config/types.go | 26 +- internal/forgejo/repo.go | 284 ++++++++- internal/forgejo/repo_test.go | 134 +++++ internal/openbao/bootstrap.go | 77 ++- internal/openbao/bootstrap_test.go | 67 +++ internal/proxmox/client.go | 29 +- internal/proxmox/client_test.go | 22 + internal/ui/wizard.go | 15 +- 16 files changed, 1783 insertions(+), 129 deletions(-) create mode 100644 internal/assets/templates/delivery-pipeline.yaml.tmpl create mode 100644 internal/proxmox/client_test.go diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 564c5a2..c375efc 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "path/filepath" "github.com/Pingu-Studio/MaidnCLI/internal/bootstrap" "github.com/Pingu-Studio/MaidnCLI/internal/config" @@ -123,13 +122,27 @@ func runBootstrap(cmd *cobra.Command, args []string) error { if err != nil { return err } - if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { + if err := forgejo.EnsureCleanCheckout(bootstrapPublishAppFrom); err != nil { return err } + origin, err := forgejo.CheckoutOrigin(bootstrapPublishAppFrom) + if err != nil { + return err + } + if config.RedactURL(origin) != cfg.Delivery.AppRepoURL { + return fmt.Errorf("--publish-app-from origin does not match delivery appRepoUrl") + } branch, err := forgejo.CurrentBranch(bootstrapPublishAppFrom) if err != nil { return err } + deliveryBranch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef) + if err != nil { + return err + } + if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { + return err + } owner, repo, err := forgejo.RepositoryFromURL(cfg.Delivery.AppRepoURL) if err != nil { return err @@ -139,33 +152,22 @@ func runBootstrap(cmd *cobra.Command, args []string) error { if err != nil { return err } - hasDeliveryBranch, err := manager.HasRemoteBranch(cfg.Delivery.AppRepoURL, cfg.Delivery.AppRepoRef) - if err != nil { + if created && branch != cfg.Delivery.AppRepoRef { + return fmt.Errorf("new application repository requires the checkout branch to match delivery appRepoRef") + } + if err := manager.EnsureProtectedBranch(repo, cfg.Delivery.ProductionBranch); err != nil { + return fmt.Errorf("protect Forgejo production branch: %w", err) + } + if err := manager.PushRef(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, branch, branch); err != nil { return err } - if 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 { + if err := manager.PublishDeliveryBranch(bootstrapPublishAppFrom, branch, cfg.Delivery.AppRepoURL, deliveryBranch, func(dir string) error { + return bootstrap.GenerateAppDelivery(dir, cfg) + }); err != nil { return err } - return manager.CreatePullRequest(repo, "feat: migrate delivery to Tekton", branch, cfg.Delivery.AppRepoRef) + return manager.CreatePullRequest(repo, "feat: migrate delivery to Tekton", deliveryBranch, cfg.Delivery.AppRepoRef) } - if bootstrapInitializeOpenBao { - if bootstrapConfigPath == "" { - return fmt.Errorf("--initialize-openbao requires --config") - } - cfg, err = config.Load(bootstrapConfigPath) - if err != nil { - return err - } - generatedDir := filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir) - _, err = openbao.Initialize(filepath.Join(generatedDir, "kubeconfig"), cfg.SOPS.RecoveryRecipient, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, cfg.SOPS.AgeKeyPath, cfg.SOPS.OperationalSecretsPath) - return err - } - if bootstrapConfigPath != "" { if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery || bootstrapManageNetworkBridges { cfg, err = config.LoadRaw(bootstrapConfigPath) diff --git a/internal/assets/assets.go b/internal/assets/assets.go index 743e232..3c69859 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -7,3 +7,6 @@ var ManifestsReadmeTmpl string //go:embed templates/flux.md.tmpl var FluxReadmeTmpl string + +//go:embed templates/delivery-pipeline.yaml.tmpl +var DeliveryPipelineTmpl string diff --git a/internal/assets/templates/delivery-pipeline.yaml.tmpl b/internal/assets/templates/delivery-pipeline.yaml.tmpl new file mode 100644 index 0000000..ac325b5 --- /dev/null +++ b/internal/assets/templates/delivery-pipeline.yaml.tmpl @@ -0,0 +1,326 @@ +apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: {{ .AppName }}-update-manifest + namespace: tekton-pipelines +spec: + stepTemplate: + 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: app-url + default: "" + - name: app-revision + 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: APP_URL + value: $(params.app-url) + - name: APP_REVISION + value: $(params.app-revision) + 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_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" + git clone --branch "$MANIFESTS_BRANCH" "$MANIFESTS_URL" /tmp/manifests + cd /tmp/manifests + if [ "$ENVIRONMENT" = preview ]; then + valid_pr_number "$PR_NUMBER" + valid_url "$APP_URL" + valid_revision "$APP_REVISION" + 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" < "$marker" < "$app_dir/namespace.yaml" < "$app_dir/release.yaml" < "$app_dir/kustomization.yaml" </dev/null || printf ' - %s-pr-%s\n' "$APP_NAME" "$PR_NUMBER" >> "$root" + elif [ "$ENVIRONMENT" = staging ] || [ "$ENVIRONMENT" = production ]; then + app_dir="apps/$ENVIRONMENT/$APP_NAME" + [ -f "$app_dir/release.yaml" ] || fail + sed -i -E "s|^([[:space:]]*tag:).*|\1 $TAG|" "$app_dir/release.yaml" + 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" + git push origin "$MANIFESTS_BRANCH" +--- +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 }} + workspaces: + - name: source + taskRunTemplate: + serviceAccountName: tekton-delivery + tasks: + - name: build-and-push + when: + - input: $(params.event-action) + operator: notin + values: [closed] + taskRef: + name: maidn-node-static-image + params: + - name: url + value: {{ quote .AppRepoURL }} + - name: revision + value: $(params.git-revision) + - name: image + value: $(params.image) + - name: output-directory + value: {{ quote .BuildOutputDirectory }} + - name: build-configuration + value: {{ quote .BuildConfiguration }} + workspaces: + - name: source + workspace: source + - name: update-preview + runAfter: [build-and-push] + 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: app-url + value: {{ quote .AppRepoURL }} + - name: app-revision + value: $(params.git-revision) + - name: update-staging + runAfter: [build-and-push] + 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: promote-production + runAfter: [build-and-push] + 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: 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 }} diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 05bd3e3..1cac01d 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -9,19 +9,25 @@ import ( "errors" "fmt" "io" + "net/url" "os" "os/exec" "path/filepath" "reflect" + "regexp" "sort" + "strconv" "strings" + "text/template" "time" + "github.com/Pingu-Studio/MaidnCLI/internal/assets" "github.com/Pingu-Studio/MaidnCLI/internal/cloudflare" "github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" ghrepo "github.com/Pingu-Studio/MaidnCLI/internal/github" "github.com/Pingu-Studio/MaidnCLI/internal/openbao" + "github.com/Pingu-Studio/MaidnCLI/internal/proxmox" "github.com/Pingu-Studio/MaidnCLI/internal/utils" "gopkg.in/yaml.v3" ) @@ -71,6 +77,30 @@ var runGit = func(dir string, args ...string) ([]byte, error) { return command.Output() } +var runGitEnvironment = func(dir string, environment []string, args ...string) ([]byte, error) { + command := exec.Command("git", args...) + command.Dir = dir + command.Env = environment + return command.Output() +} + +var verifyTalosVMs = verifyConfiguredTalosVMs + +var terraformStateResources = func(terraformDir string, environment []string) ([]string, error) { + command := exec.Command("terraform", "state", "list") + command.Dir = terraformDir + command.Env = append(os.Environ(), environment...) + state, err := command.Output() + if err != nil { + return nil, fmt.Errorf("list Terraform state: %w", err) + } + return strings.Fields(string(state)), nil +} + +var destroyTalosVMs = func(terraformDir string, environment []string) error { + return utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "destroy", "-auto-approve", "-input=false", "-target=proxmox_virtual_environment_vm.vm") +} + var webhookTargetTimeout = 70 * time.Minute var webhookTargetPollInterval = 2 * time.Second @@ -79,7 +109,7 @@ var templateBaseComponents = []string{"snapshot-crds", "democratic-csi", "cert-m var generatedTemplateFiles = map[string]map[string]bool{ "democratic-csi": {"secret.sops.yaml": true}, - "openbao": {"unseal.sops.yaml": true}, + "openbao": {"unseal.sops.yaml": true}, } var requiredClusterKustomizations = []string{"snapshot-crds-kustomization.yaml", "democratic-csi-kustomization.yaml", "cert-manager-kustomization.yaml", "cluster-issuers-kustomization.yaml", "gateway-api-kustomization.yaml", "cilium-kustomization.yaml", "cilium-config-kustomization.yaml", "openbao-kustomization.yaml", "external-secrets-kustomization.yaml", "cnpg-kustomization.yaml", "gateway-kustomization.yaml", "external-dns-kustomization.yaml", "cloudflare-tunnel-kustomization.yaml", "monitoring-kustomization.yaml", "tekton-kustomization.yaml", "tekton-triggers-kustomization.yaml", "cicd-manifests-repo.yaml"} @@ -129,6 +159,10 @@ func (r Runner) Run() error { if err := EnsureTemplateRevisions(r.Config); err != nil { return err } + catalogManager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, "", "", r.Config.Templates.TektonCatalogRepoRef, "") + if _, err := catalogManager.EnsureRepositoryCopy(r.Config.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", r.Config.Templates.TektonCatalogRepoURL); err != nil { + return fmt.Errorf("initialize Tekton catalog repository: %w", err) + } if err := config.WriteRedacted(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil { return err } @@ -325,15 +359,14 @@ func renderDeliveryConfig(dir string, cfg config.Config) error { "${APP_NAME}", cfg.Delivery.AppName, "${APP_REPO_URL}", cfg.Delivery.AppRepoURL, "${APP_REPO_REF}", cfg.Delivery.AppRepoRef, + "${PRODUCTION_BRANCH}", cfg.Delivery.ProductionBranch, "${IMAGE_REPOSITORY}", cfg.Delivery.ImageRepository, "${FORGEJO_BASE_URL}", cfg.Git.BaseURL, "${CLUSTER_DOMAIN}", cfg.Flux.ClusterDomain, - "${TEKTON_CATALOG_REPO_URL}", cfg.Templates.TektonCatalogRepoURL, + "${TEKTON_CATALOG_REPO_URL}", forgejo.CloneURL(cfg.Git.BaseURL, cfg.Git.Owner, cfg.Flux.TektonCatalogRepo), "${TEKTON_CATALOG_REPO_REF}", cfg.Templates.TektonCatalogRepoRef, "${WEBHOOK_HOSTNAME}", cfg.Delivery.WebhookHostname, "${WEBHOOK_PATH}", cfg.Delivery.WebhookPath, - "${TEKTON_CATALOG_REPO_URL}", cfg.Templates.TektonCatalogRepoURL, - "${TEKTON_CATALOG_REPO_REF}", cfg.Templates.TektonCatalogRepoRef, ) return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { @@ -361,7 +394,177 @@ func copyAndRenderDeliveryBases(templateDir, repoDir string, cfg config.Config) return err } } - return nil + return writePreviewDeliveryConfig(filepath.Join(repoDir, "base", "tekton"), cfg) +} + +func writePreviewDeliveryConfig(dir string, cfg config.Config) error { + origin, err := canonicalForgejoOrigin(cfg.Git.BaseURL) + if err != nil { + return err + } + manifestsURL := forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo) + content, err := yaml.Marshal(struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata map[string]string `yaml:"metadata"` + Data map[string]string `yaml:"data"` + }{ + APIVersion: "v1", + Kind: "ConfigMap", + Metadata: map[string]string{"name": "maidn-preview-delivery-config", "namespace": "tekton-pipelines"}, + Data: map[string]string{ + "forgejo-origin": origin, + "manifests-url": manifestsURL, + "manifests-branch": cfg.Flux.Branch, + }, + }) + if err != nil { + return err + } + path := filepath.Join(dir, "kustomization.yaml") + data, err := os.ReadFile(path) + if err != nil { + return err + } + if !strings.Contains("\n"+string(data), "\nresources:") { + return errors.New("Tekton Kustomization must define resources before adding preview delivery configuration") + } + if err := os.WriteFile(filepath.Join(dir, "maidn-preview-delivery-config.yaml"), content, 0644); err != nil { + return err + } + if strings.Contains(string(data), "maidn-preview-delivery-config.yaml") { + return nil + } + return os.WriteFile(path, append(data, []byte(" - maidn-preview-delivery-config.yaml\n")...), 0644) +} + +func canonicalForgejoOrigin(value string) (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("Forgejo base URL must be a credential-free HTTPS origin") + } + return parsed.Scheme + "://" + parsed.Host, nil +} + +type appDeliveryTemplateConfig struct { + AppName string + AppRepository string + AppRepoURL string + AppRepoRef string + ProductionBranch string + ImageRepository string + BuildOutputDirectory string + BuildConfiguration string + ForgejoBaseURL string + ManifestsURL string + ManifestsBranch string +} + +var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) + +// GenerateAppDelivery writes the source-owned Tekton delivery contract for an app checkout. +func GenerateAppDelivery(dir string, cfg config.Config) error { + content, err := renderAppDelivery(cfg) + if err != nil { + return err + } + files := map[string][]byte{ + "kustomization.yaml": []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - pipeline.yaml\n"), + "pipeline.yaml": content, + } + target := filepath.Join(dir, ".tekton") + info, err := os.Lstat(target) + if err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errors.New("app delivery .tekton path must be a directory") + } + entries, err := os.ReadDir(target) + if err != nil { + return err + } + if len(entries) != len(files) { + return errors.New("app delivery .tekton contains unmanaged files") + } + for name, want := range files { + got, err := os.ReadFile(filepath.Join(target, name)) + if err != nil || !bytes.Equal(got, want) { + return errors.New("app delivery .tekton differs from Maidn generated content") + } + } + return nil + } + if !os.IsNotExist(err) { + return err + } + temporary, err := os.MkdirTemp(dir, ".maidn-tekton-") + if err != nil { + return err + } + defer os.RemoveAll(temporary) + for name, content := range files { + if err := os.WriteFile(filepath.Join(temporary, name), content, 0644); err != nil { + return err + } + } + return os.Rename(temporary, target) +} + +func renderAppDelivery(cfg config.Config) ([]byte, error) { + if !deliveryAppName.MatchString(cfg.Delivery.AppName) { + return nil, errors.New("delivery appName must be a lowercase DNS label") + } + appRepository, err := deliveryRepository(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL) + if err != nil { + return nil, err + } + origin, err := canonicalForgejoOrigin(cfg.Git.BaseURL) + if err != nil { + return nil, err + } + values := appDeliveryTemplateConfig{ + AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL, + AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository, + BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration, + ForgejoBaseURL: origin, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsBranch: cfg.Flux.Branch, + } + for name, value := range map[string]string{"appRepository": values.AppRepository, "appRepoUrl": values.AppRepoURL, "appRepoRef": values.AppRepoRef, "productionBranch": values.ProductionBranch, "imageRepository": values.ImageRepository, "buildOutputDirectory": values.BuildOutputDirectory, "buildConfiguration": values.BuildConfiguration, "forgejoBaseUrl": values.ForgejoBaseURL, "manifestsUrl": values.ManifestsURL, "manifestsBranch": values.ManifestsBranch} { + if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value { + return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name) + } + } + tmpl, err := template.New("delivery-pipeline").Funcs(template.FuncMap{"quote": strconv.Quote}).Parse(assets.DeliveryPipelineTmpl) + if err != nil { + return nil, err + } + var rendered bytes.Buffer + if err := tmpl.Execute(&rendered, values); err != nil { + return nil, err + } + return rendered.Bytes(), nil +} + +func deliveryRepository(baseURL, repositoryURL string) (string, error) { + base, err := canonicalForgejoOrigin(baseURL) + if err != nil { + return "", err + } + 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) { + return "", errors.New("delivery appRepoUrl must be a credential-free HTTPS repository on the configured Forgejo origin") + } + parts := strings.Split(strings.Trim(repository.Path, "/"), "/") + if len(parts) != 2 || !strings.HasSuffix(parts[1], ".git") { + return "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git") + } + name := strings.TrimSuffix(parts[1], ".git") + if !deliveryRepositoryPart(parts[0]) || !deliveryRepositoryPart(name) { + return "", errors.New("delivery appRepoUrl has an invalid Forgejo owner or repository") + } + return parts[0] + "/" + name, nil +} + +func deliveryRepositoryPart(value string) bool { + return value != "" && !strings.Contains(value, "..") && regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`).MatchString(value) } func copyAndRenderCiliumBases(templateDir, repoDir string, cfg config.Config) error { @@ -374,7 +577,108 @@ func copyAndRenderCiliumBases(templateDir, repoDir string, cfg config.Config) er return err } } - return nil + return renderCiliumHubblePeerService(filepath.Join(repoDir, "base", "cilium", "release.yaml"), cfg.Flux.ClusterDomain) +} + +func renderCiliumHubblePeerService(path, clusterDomain string) error { + content, err := os.ReadFile(path) + if err != nil { + return err + } + decoder := yaml.NewDecoder(bytes.NewReader(content)) + var document yaml.Node + if err := decoder.Decode(&document); err != nil { + return fmt.Errorf("parse Cilium HelmRelease: %w", err) + } + if err := decoder.Decode(&yaml.Node{}); !errors.Is(err, io.EOF) { + return errors.New("Cilium HelmRelease must contain one YAML document") + } + if document.Kind != yaml.DocumentNode || len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode { + return errors.New("Cilium HelmRelease must be a YAML mapping") + } + spec, err := requiredYAMLMapping(document.Content[0], "spec") + if err != nil { + return fmt.Errorf("Cilium HelmRelease: %w", err) + } + values, err := requiredYAMLMapping(spec, "values") + if err != nil { + return fmt.Errorf("Cilium HelmRelease spec: %w", err) + } + hubble, err := ensureYAMLMapping(values, "hubble") + if err != nil { + return fmt.Errorf("Cilium HelmRelease values: %w", err) + } + peerService, err := ensureYAMLMapping(hubble, "peerService") + if err != nil { + return fmt.Errorf("Cilium HelmRelease hubble: %w", err) + } + clusterDomainNode, err := yamlMappingValue(peerService, "clusterDomain") + if err != nil { + return fmt.Errorf("Cilium HelmRelease hubble peerService: %w", err) + } + if clusterDomainNode != nil { + if clusterDomainNode.Kind != yaml.ScalarNode { + return errors.New("Cilium HelmRelease hubble peerService clusterDomain must be a scalar") + } + if clusterDomainNode.Value == clusterDomain { + return nil + } + clusterDomainNode.Value = clusterDomain + } else { + peerService.Content = append(peerService.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: "clusterDomain"}, &yaml.Node{Kind: yaml.ScalarNode, Value: clusterDomain}) + } + var rendered bytes.Buffer + encoder := yaml.NewEncoder(&rendered) + encoder.SetIndent(2) + if err := encoder.Encode(&document); err != nil { + return fmt.Errorf("render Cilium HelmRelease: %w", err) + } + return os.WriteFile(path, rendered.Bytes(), 0644) +} + +func requiredYAMLMapping(node *yaml.Node, key string) (*yaml.Node, error) { + value, err := yamlMappingValue(node, key) + if err != nil { + return nil, err + } + if value == nil || value.Kind != yaml.MappingNode { + return nil, fmt.Errorf("%s must be a mapping", key) + } + return value, nil +} + +func ensureYAMLMapping(node *yaml.Node, key string) (*yaml.Node, error) { + value, err := yamlMappingValue(node, key) + if err != nil { + return nil, err + } + if value != nil { + if value.Kind != yaml.MappingNode { + return nil, fmt.Errorf("%s must be a mapping", key) + } + return value, nil + } + node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, &yaml.Node{Kind: yaml.MappingNode}) + return node.Content[len(node.Content)-1], nil +} + +func yamlMappingValue(node *yaml.Node, key string) (*yaml.Node, error) { + if node.Kind != yaml.MappingNode || len(node.Content)%2 != 0 { + return nil, errors.New("must be a YAML mapping") + } + var value *yaml.Node + for index := 0; index < len(node.Content); index += 2 { + if node.Content[index].Kind != yaml.ScalarNode { + return nil, errors.New("contains a non-scalar key") + } + if node.Content[index].Value == key { + if value != nil { + return nil, fmt.Errorf("contains duplicate %s", key) + } + value = node.Content[index+1] + } + } + return value, nil } func writeDemocraticCSISecret(path string, csi config.DemocraticCSIConfig, ageKeyPath string) error { @@ -894,7 +1198,7 @@ type lifecycle struct { func ensureLifecycleIdentity(terraformDir string, cfg config.Config) error { statePath := filepath.Join(terraformDir, "terraform.tfstate") - lifecyclePath := filepath.Join(terraformDir, ".maidn", "lifecycle.yaml") + lifecyclePath := filepath.Join(filepath.Dir(terraformDir), cfg.Talos.GeneratedDir, ".maidn", "lifecycle.yaml") data, err := os.ReadFile(lifecyclePath) if err == nil { var current lifecycle @@ -929,8 +1233,11 @@ func (r Runner) reconcileTerraform(terraformDir string) error { if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "init", "-input=false"); err != nil { return err } + if err := importNetworkBridges(terraformDir, environment, r.Config); err != nil { + return err + } if r.Mode == Rebuild { - if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "destroy", "-auto-approve", "-input=false", "-target=proxmox_virtual_environment_vm.vm"); err != nil { + if err := r.rebuildTalosVMs(terraformDir, environment); err != nil { return err } } @@ -945,6 +1252,139 @@ func (r Runner) reconcileTerraform(terraformDir string) error { return utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "apply", "-input=false", "-auto-approve", planPath) } +func (r Runner) rebuildTalosVMs(terraformDir string, environment []string) error { + if err := verifyTalosVMs(r.Config); err != nil { + return err + } + resources, err := terraformStateResources(terraformDir, environment) + if err != nil { + return err + } + if err := verifyStateTalosVMs(resources, r.Config, false); err != nil { + return err + } + if err := importTalosVMs(terraformDir, environment, r.Config); err != nil { + return err + } + resources, err = terraformStateResources(terraformDir, environment) + if err != nil { + return err + } + if err := verifyStateTalosVMs(resources, r.Config, true); err != nil { + return err + } + return destroyTalosVMs(terraformDir, environment) +} + +type terraformImport struct { + Address string + ID string +} + +func importNetworkBridges(terraformDir string, environment []string, cfg config.Config) error { + if !cfg.Talos.Cluster.ManageNetworkBridges { + return nil + } + return importTerraformResources(terraformDir, environment, networkBridgeImports(cfg)) +} + +func importTalosVMs(terraformDir string, environment []string, cfg config.Config) error { + return importTerraformResources(terraformDir, environment, talosVMImports(cfg)) +} + +func verifyConfiguredTalosVMs(cfg config.Config) error { + client := proxmox.New(cfg.Talos.Proxmox.APIURL, cfg.Talos.Proxmox.APITokenID, cfg.Talos.Proxmox.APITokenSecret, cfg.Talos.Proxmox.Insecure) + for _, node := range cfg.Talos.Nodes { + vm, err := client.GetVM(node.ProxmoxNode, node.VMID) + if err != nil { + return fmt.Errorf("inspect configured Talos VM %d: %w", node.VMID, err) + } + if vm.Name != node.Name || vm.Description != expectedTalosVMDescription(node.Role) { + return fmt.Errorf("configured Talos VM %d does not match the expected Maidn Talos VM", node.VMID) + } + } + return nil +} + +func verifyStateTalosVMs(resources []string, cfg config.Config, requireAll bool) error { + configured := make(map[string]bool, len(cfg.Talos.Nodes)) + for _, vm := range talosVMImports(cfg) { + configured[vm.Address] = false + } + const vmAddress = "proxmox_virtual_environment_vm.vm" + for _, resource := range resources { + if resource == vmAddress || strings.HasPrefix(resource, vmAddress+"[") { + if _, ok := configured[resource]; !ok { + return fmt.Errorf("Terraform state contains unconfigured Talos VM address %q; refusing targeted destroy", resource) + } + configured[resource] = true + } + } + if requireAll { + for address, present := range configured { + if !present { + return fmt.Errorf("Terraform state is missing configured Talos VM address %q; refusing targeted destroy", address) + } + } + } + return nil +} + +func expectedTalosVMDescription(role string) string { + if role == "controlplane" { + return "Talos Control Plane Node - Managed by Terraform" + } + return "Talos Worker Node - Managed by Terraform" +} + +func importTerraformResources(terraformDir string, environment []string, imports []terraformImport) error { + state, err := terraformStateResources(terraformDir, environment) + if err != nil { + return err + } + resources := make(map[string]bool, len(state)) + for _, resource := range state { + resources[resource] = true + } + for _, resource := range imports { + if resources[resource.Address] { + continue + } + if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "import", "-input=false", resource.Address, resource.ID); err != nil { + return fmt.Errorf("import Terraform resource: %w", err) + } + } + return nil +} + +func networkBridgeImports(cfg config.Config) []terraformImport { + imports := map[string]terraformImport{} + for _, node := range cfg.Talos.Nodes { + for _, network := range node.Networks { + key := fmt.Sprintf("%s-%d", node.ProxmoxNode, network.VLANID) + imports[key] = terraformImport{Address: fmt.Sprintf(`proxmox_virtual_environment_network_linux_bridge.cluster_bridge["%s"]`, key), ID: fmt.Sprintf("%s:vmbr%d", node.ProxmoxNode, network.VLANID)} + } + } + keys := make([]string, 0, len(imports)) + for key := range imports { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]terraformImport, 0, len(keys)) + for _, key := range keys { + result = append(result, imports[key]) + } + return result +} + +func talosVMImports(cfg config.Config) []terraformImport { + imports := make([]terraformImport, 0, len(cfg.Talos.Nodes)) + for _, node := range cfg.Talos.Nodes { + imports = append(imports, terraformImport{Address: fmt.Sprintf(`proxmox_virtual_environment_vm.vm["%s"]`, node.Name), ID: fmt.Sprintf("%s/%d", node.ProxmoxNode, node.VMID)}) + } + return imports +} + func terraformPlanPath(terraformDir, clusterID string) (string, error) { return filepath.Abs(filepath.Join(terraformDir, clusterID+".tfplan")) } @@ -983,17 +1423,18 @@ type templateRevisionLock struct { } type templateCheckout struct { - Dir string - Repository string - Ref string + Dir string + Repository string + Ref string + GeneratedDir string } func ensureTemplateRevisions(workspace string, cfg config.Config) error { lockPath := filepath.Join(workspace, "maidn-template-revisions.yaml") checkouts := []templateCheckout{ - {filepath.Join(workspace, "maidn-cicd-cluster-template"), cfg.Templates.CICDRepoURL, cfg.Templates.CICDRepoRef}, - {filepath.Join(workspace, "cicd-deployment-manifests-template"), cfg.Templates.ManifestsRepoURL, cfg.Templates.ManifestsRepoRef}, - {filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName), cfg.Templates.TalosRepoURL, cfg.Templates.TalosRepoRef}, + {Dir: filepath.Join(workspace, "maidn-cicd-cluster-template"), Repository: cfg.Templates.CICDRepoURL, Ref: cfg.Templates.CICDRepoRef}, + {Dir: filepath.Join(workspace, "cicd-deployment-manifests-template"), Repository: cfg.Templates.ManifestsRepoURL, Ref: cfg.Templates.ManifestsRepoRef}, + {Dir: filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName), Repository: cfg.Templates.TalosRepoURL, Ref: cfg.Templates.TalosRepoRef, GeneratedDir: cfg.Talos.GeneratedDir}, } lock, err := readTemplateRevisionLock(lockPath) if err == nil { @@ -1002,7 +1443,7 @@ func ensureTemplateRevisions(workspace string, cfg config.Config) error { if !sameTemplateSource(revisions[index], checkout) { return errors.New("configured template source or ref differs from its workspace revision lock; use a new empty workspaceDir to intentionally refresh templates") } - if _, err := checkoutTemplateRevision(checkout, revisions[index].Commit); err != nil { + if _, err := checkoutTemplateRevision(cfg, checkout, revisions[index].Commit); err != nil { return errors.New("locked template revision cannot be resolved; restore the locked commit or use a new empty workspaceDir to intentionally refresh templates") } } @@ -1017,7 +1458,7 @@ func ensureTemplateRevisions(workspace string, cfg config.Config) error { if config.RedactURL(checkout.Repository) == "" { return errors.New("configured template source cannot be safely recorded; use a standard repository URL without embedded query credentials") } - commit, err := checkoutTemplateRevision(checkout, "") + commit, err := checkoutTemplateRevision(cfg, checkout, "") if err != nil { return errors.New("configured template revision cannot be resolved; correct the template source or ref, then rerun bootstrap") } @@ -1089,28 +1530,34 @@ func validTemplateRevision(revision templateRevision) bool { return true } -func checkoutTemplateRevision(checkout templateCheckout, lockedCommit string) (string, error) { +func checkoutTemplateRevision(cfg config.Config, checkout templateCheckout, lockedCommit string) (string, error) { repository := config.RedactURL(checkout.Repository) if repository == "" { return "", errors.New("template source cannot be safely used") } + run, cleanup, err := templateGitRunner(cfg, checkout.Repository, repository) + if err != nil { + return "", err + } + defer cleanup() if info, err := os.Stat(checkout.Dir); os.IsNotExist(err) { - if _, err := runGit("", "clone", "--no-checkout", repository, checkout.Dir); err != nil { + if _, err := run("", "clone", "--no-checkout", repository, checkout.Dir); err != nil { return "", err } } else if err != nil || !info.IsDir() { return "", errors.New("template checkout is not a directory") } else { - inside, err := runGit(checkout.Dir, "rev-parse", "--is-inside-work-tree") + inside, err := run(checkout.Dir, "rev-parse", "--is-inside-work-tree") if err != nil || strings.TrimSpace(string(inside)) != "true" { return "", errors.New("template checkout is not a Git work tree") } - origin, err := runGit(checkout.Dir, "remote", "get-url", "origin") - if err != nil || config.RedactURL(strings.TrimSpace(string(origin))) != repository { + origin, err := run(checkout.Dir, "remote", "get-url", "origin") + originValue := strings.TrimSpace(string(origin)) + if err != nil || config.RedactURL(originValue) != originValue || originValue != repository { return "", errors.New("template checkout source does not match configuration") } - status, err := runGit(checkout.Dir, "status", "--porcelain") - if err != nil || strings.TrimSpace(string(status)) != "" { + status, err := run(checkout.Dir, "status", "--porcelain=v1", "--untracked-files=all", "-z") + if err != nil || hasUnexpectedTemplateChanges(status, checkout.GeneratedDir) { return "", errors.New("template checkout has uncommitted changes") } } @@ -1119,10 +1566,10 @@ func checkoutTemplateRevision(checkout templateCheckout, lockedCommit string) (s if lockedCommit != "" { target = lockedCommit } - if _, err := runGit(checkout.Dir, "fetch", "origin", target); err != nil { + if _, err := run(checkout.Dir, "fetch", "origin", target); err != nil { return "", err } - commit, err := runGit(checkout.Dir, "rev-parse", "--verify", "FETCH_HEAD^{commit}") + commit, err := run(checkout.Dir, "rev-parse", "--verify", "FETCH_HEAD^{commit}") if err != nil { return "", err } @@ -1130,16 +1577,74 @@ func checkoutTemplateRevision(checkout templateCheckout, lockedCommit string) (s if !validTemplateRevision(templateRevision{Repository: "source", Ref: "ref", Commit: commitID}) || (lockedCommit != "" && commitID != lockedCommit) { return "", errors.New("template ref did not resolve to the expected commit") } - if _, err := runGit(checkout.Dir, "checkout", "--detach", commitID); err != nil { + if _, err := run(checkout.Dir, "checkout", "--detach", commitID); err != nil { return "", err } - head, err := runGit(checkout.Dir, "rev-parse", "--verify", "HEAD^{commit}") + head, err := run(checkout.Dir, "rev-parse", "--verify", "HEAD^{commit}") if err != nil || strings.TrimSpace(string(head)) != commitID { return "", errors.New("template checkout did not reach the expected commit") } return commitID, nil } +func hasUnexpectedTemplateChanges(status []byte, generatedDir string) bool { + generatedDir = filepath.ToSlash(filepath.Clean(generatedDir)) + if generatedDir == "." || generatedDir == ".." || strings.HasPrefix(generatedDir, "../") || filepath.IsAbs(generatedDir) { + return len(status) != 0 + } + if len(status) != 0 && status[len(status)-1] != 0 { + return true + } + records := bytes.Split(status, []byte{0}) + for index := 0; index < len(records)-1; index++ { + record := records[index] + if len(record) < 4 || record[2] != ' ' || !generatedTemplatePath(string(record[3:]), generatedDir) { + return true + } + if record[0] == 'R' || record[0] == 'C' { + index++ + if index >= len(records)-1 || !generatedTemplatePath(string(records[index]), generatedDir) { + return true + } + } + } + return false +} + +func generatedTemplatePath(path, generatedDir string) bool { + path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) + return path == generatedDir || strings.HasPrefix(path, generatedDir+"/") +} + +func templateGitRunner(cfg config.Config, source, repository string) (func(string, ...string) ([]byte, error), func(), error) { + sourceURL, sourceErr := url.Parse(source) + username, token := cfg.Git.Username, cfg.Git.Token + hasSourceCredentials := false + if sourceErr == nil && sourceURL.User != nil { + if password, hasPassword := sourceURL.User.Password(); hasPassword { + username, token = sourceURL.User.Username(), password + hasSourceCredentials = true + } + } + if !hasSourceCredentials { + repositoryURL, repositoryErr := url.Parse(repository) + gitURL, gitErr := url.Parse(config.RedactURL(cfg.Git.BaseURL)) + if repositoryErr != nil || gitErr != nil || !strings.EqualFold(repositoryURL.Host, gitURL.Host) { + return runGit, func() {}, nil + } + } + if token == "" { + return runGit, func() {}, nil + } + cleanup, environment, err := forgejo.GitEnvironment(username, token) + if err != nil { + return nil, nil, err + } + return func(dir string, args ...string) ([]byte, error) { + return runGitEnvironment(dir, environment, args...) + }, cleanup, nil +} + func ensureTalosConfig(generatedDir string, cfg config.Config) error { talhelperPath := "talhelper" secretPath := filepath.Join(generatedDir, "talsecret.yaml") diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index d560c5d..348c591 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -13,8 +13,8 @@ import ( "testing" "time" - "github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/cloudflare" + "github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" "github.com/Pingu-Studio/MaidnCLI/internal/openbao" "gopkg.in/yaml.v3" @@ -42,10 +42,10 @@ func TestRenderCiliumConfig(t *testing.T) { func TestRenderDeliveryConfig(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "webhook.yaml") - if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil { + if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\nproduction: ${PRODUCTION_BRANCH}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil { t.Fatal(err) } - cfg := config.Config{Delivery: config.DeliveryConfig{WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"}, Templates: config.TemplateConfig{TektonCatalogRepoURL: "https://catalog.example.test/tekton.git", TektonCatalogRepoRef: "release"}} + cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog"}, Delivery: config.DeliveryConfig{ProductionBranch: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}} if err := renderDeliveryConfig(dir, cfg); err != nil { t.Fatal(err) } @@ -53,11 +53,57 @@ func TestRenderDeliveryConfig(t *testing.T) { if err != nil { t.Fatal(err) } - if strings.Contains(string(content), "${") || !strings.Contains(string(content), "/hooks/forgejo") || !strings.Contains(string(content), "https://catalog.example.test/tekton.git") || !strings.Contains(string(content), "ref: release") { + if strings.Contains(string(content), "${") || !strings.Contains(string(content), "/hooks/forgejo") || !strings.Contains(string(content), "production: production") || !strings.Contains(string(content), "https://git.example.test/user-org/my-tekton-catalog.git") || !strings.Contains(string(content), "ref: release") { t.Fatalf("delivery configuration was not rendered: %s", content) } } +func TestGeneratedDeliveryIsGenericAndUsesSafePreviewCleanupContract(t *testing.T) { + cfg := config.Config{ + Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"}, + Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"}, + Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"}, + } + content, err := renderAppDelivery(cfg) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"maidn-node-static-image", "maidn-preview-orphan-reconciler", "valid_pr_number()", "valid_commit()", "values: [promotion]", "values: [\"production\"]", "environment\n value: production", "cmp -s \"$expected_marker\" \"$marker\"", "values: [closed]"} { + if !strings.Contains(string(content), expected) { + t.Fatalf("generated delivery does not contain %q", expected) + } + } + if strings.Contains(string(content), "easycsr") || strings.Contains(string(content), "test-org") || strings.Contains(string(content), "git rm -r") { + t.Fatal("generated delivery contains a non-generic or unsafe literal") + } +} + +func TestWritePreviewDeliveryConfigIsTrustedAndNonSecret(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil { + t.Fatal(err) + } + cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform", Token: "secret"}, Flux: config.FluxConfig{ManifestsRepo: "manifests", Branch: "main"}, Delivery: config.DeliveryConfig{AppName: "dynamic-app"}} + if err := writePreviewDeliveryConfig(dir, cfg); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(dir, "maidn-preview-delivery-config.yaml")) + if err != nil || !strings.Contains(string(content), "forgejo-origin: https://git.example.test") || strings.Contains(string(content), "forgejo-base-url") || !strings.Contains(string(content), "manifests-url: https://git.example.test/platform/manifests.git") || strings.Contains(string(content), "secret") || strings.Contains(string(content), "dynamic-app") { + t.Fatalf("preview delivery config is not trusted and non-secret: %q, %v", content, err) + } +} + +func TestWritePreviewDeliveryConfigRejectsAmbiguousKustomization(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n"), 0644); err != nil { + t.Fatal(err) + } + err := writePreviewDeliveryConfig(dir, config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"}, Flux: config.FluxConfig{ManifestsRepo: "manifests", Branch: "main"}}) + if err == nil || !strings.Contains(err.Error(), "must define resources") { + t.Fatalf("ambiguous Tekton Kustomization was accepted: %v", err) + } +} + func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing.T) { templateDir := t.TempDir() repoDir := t.TempDir() @@ -65,7 +111,7 @@ func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing. base, name, template, want string }{ {"gateway", "route.yaml", "host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\n", "host: tekton.example.test\npath: /hooks/forgejo\n"}, - {"tekton", "catalog-source.yaml", "url: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n", "url: https://catalog.example.test/tekton.git\nref: release\n"}, + {"tekton", "catalog-source.yaml", "url: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n", "url: https://git.example.test/user-org/my-tekton-catalog.git\nref: release\n"}, {"tekton-triggers", "trigger.yaml", "app: ${APP_NAME}\nrepo: ${APP_REPO_URL}\n", "app: demo\nrepo: https://git.example.test/demo.git\n"}, } for _, file := range files { @@ -84,14 +130,19 @@ func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing. t.Fatal(err) } } + if err := os.WriteFile(filepath.Join(templateDir, "base", "tekton", "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil { + t.Fatal(err) + } customGatewayFile := filepath.Join(repoDir, "base", "gateway", "custom.yaml") if err := os.WriteFile(customGatewayFile, []byte("custom: route\n"), 0644); err != nil { t.Fatal(err) } cfg := config.Config{ + Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, + Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"}, Delivery: config.DeliveryConfig{AppName: "demo", AppRepoURL: "https://git.example.test/demo.git", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"}, - Templates: config.TemplateConfig{TektonCatalogRepoURL: "https://catalog.example.test/tekton.git", TektonCatalogRepoRef: "release"}, + Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}, } if err := copyAndRenderDeliveryBases(templateDir, repoDir, cfg); err != nil { t.Fatal(err) @@ -185,7 +236,7 @@ func TestCopyAndRenderCiliumBasesRefreshesTemplateWithoutLeavingPlaceholders(t * templateDir := t.TempDir() repoDir := t.TempDir() files := map[string]string{ - "cilium/release.yaml": "generation: current\n", + "cilium/release.yaml": "apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nspec:\n values:\n ipam:\n mode: kubernetes\n", "cilium-config/load-balancer-pool.yaml": "start: ${CILIUM_LB_START}\nstop: ${CILIUM_LB_END}\n", "cilium-config/l2-policy.yaml": "interface: ${CILIUM_TRAFFIC_INTERFACE}\n", } @@ -208,12 +259,11 @@ func TestCopyAndRenderCiliumBasesRefreshesTemplateWithoutLeavingPlaceholders(t * t.Fatal(err) } - cfg := config.Config{Cilium: config.CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"}} + cfg := config.Config{Flux: config.FluxConfig{ClusterDomain: "dev02.nid3.com"}, Cilium: config.CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"}} if err := copyAndRenderCiliumBases(templateDir, repoDir, cfg); err != nil { t.Fatal(err) } for name, want := range map[string]string{ - "cilium/release.yaml": "generation: current\n", "cilium-config/load-balancer-pool.yaml": "start: 192.168.45.19\nstop: 192.168.45.30\n", "cilium-config/l2-policy.yaml": "interface: eth1\n", } { @@ -222,6 +272,10 @@ func TestCopyAndRenderCiliumBasesRefreshesTemplateWithoutLeavingPlaceholders(t * t.Fatalf("Cilium file %q was not refreshed and rendered: %q, %v", name, content, err) } } + release, err := os.ReadFile(filepath.Join(repoDir, "base", "cilium", "release.yaml")) + if err != nil || !strings.Contains(string(release), "clusterDomain: dev02.nid3.com") || strings.Contains(string(release), "cluster.local") { + t.Fatalf("Cilium Hubble peer service domain was not rendered: %q, %v", release, err) + } } func TestRenderDemocraticCSISecret(t *testing.T) { @@ -635,7 +689,7 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) { Templates: config.TemplateConfig{TalosRepoURL: "https://git.example.test/talos.git", TalosRepoRef: "main", CICDRepoURL: "https://git.example.test/template.git", CICDRepoRef: "main", ManifestsRepoURL: "https://git.example.test/manifests.git", ManifestsRepoRef: "main"}, Cilium: config.CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"}, DemocraticCSI: config.DemocraticCSIConfig{TrueNASAPIKey: "key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"}, - Delivery: config.DeliveryConfig{AppName: "app", AppRepoURL: "https://git.example.test/app.git", AppRepoRef: "main", ImageRepository: "registry.example.test/test/app", WebhookHostname: "tekton.example.test", WebhookPath: "/"}, + Delivery: config.DeliveryConfig{AppName: "app", AppRepoURL: "https://git.example.test/app.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test/app", WebhookHostname: "tekton.example.test", WebhookPath: "/"}, SOPS: config.SOPSConfig{AgeKeyPath: ageKeyPath}, Talos: config.TalosConfig{ RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars", @@ -794,6 +848,34 @@ func TestTerraformPlanPathIsAbsolute(t *testing.T) { } } +func TestVerifyStateTalosVMsRejectsForeignAndMissingVMs(t *testing.T) { + cfg := config.Config{Talos: config.TalosConfig{Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100}, {Name: "worker-01", ProxmoxNode: "pve", VMID: 101}}}} + if err := verifyStateTalosVMs([]string{`proxmox_virtual_environment_vm.vm["cp-01"]`, `proxmox_virtual_environment_vm.vm["foreign"]`}, cfg, false); err == nil || !strings.Contains(err.Error(), "unconfigured") { + t.Fatalf("foreign VM state was accepted: %v", err) + } + if err := verifyStateTalosVMs([]string{`proxmox_virtual_environment_vm.vm["cp-01"]`}, cfg, true); err == nil || !strings.Contains(err.Error(), "missing configured") { + t.Fatalf("incomplete VM state was accepted: %v", err) + } +} + +func TestEnsureLifecycleIdentityStoresMetadataUnderGeneratedDirectory(t *testing.T) { + repo := t.TempDir() + terraformDir := filepath.Join(repo, "terraform") + if err := os.MkdirAll(terraformDir, 0755); err != nil { + t.Fatal(err) + } + cfg := config.Config{ClusterID: "test", Talos: config.TalosConfig{GeneratedDir: "generated", Cluster: config.TalosClusterConfig{Name: "test"}}} + if err := ensureLifecycleIdentity(terraformDir, cfg); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(repo, "generated", ".maidn", "lifecycle.yaml")); err != nil { + t.Fatalf("lifecycle metadata was not generated: %v", err) + } + if _, err := os.Stat(filepath.Join(terraformDir, ".maidn", "lifecycle.yaml")); !os.IsNotExist(err) { + t.Fatal("lifecycle metadata dirtied the Terraform template directory") + } +} + func TestReconcileCloudflareTunnelValidatesManagedState(t *testing.T) { originalRead := readCloudflareOperationalSecrets t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead }) diff --git a/internal/bootstrap/template_revisions_test.go b/internal/bootstrap/template_revisions_test.go index 531e5f3..a8c181a 100644 --- a/internal/bootstrap/template_revisions_test.go +++ b/internal/bootstrap/template_revisions_test.go @@ -12,9 +12,7 @@ import ( func TestEnsureTemplateRevisionsLocksFirstCheckout(t *testing.T) { workspace, cfg, git := templateRevisionTestConfig(t) - original := runGit - runGit = git.run - t.Cleanup(func() { runGit = original }) + useTemplateRevisionGit(t, git) if err := ensureTemplateRevisions(workspace, cfg); err != nil { t.Fatal(err) @@ -37,9 +35,7 @@ func TestEnsureTemplateRevisionsLocksFirstCheckout(t *testing.T) { func TestEnsureTemplateRevisionsReusesLockedCommitAfterBranchDrift(t *testing.T) { workspace, cfg, git := templateRevisionTestConfig(t) - original := runGit - runGit = git.run - t.Cleanup(func() { runGit = original }) + useTemplateRevisionGit(t, git) if err := ensureTemplateRevisions(workspace, cfg); err != nil { t.Fatal(err) @@ -60,9 +56,7 @@ func TestEnsureTemplateRevisionsReusesLockedCommitAfterBranchDrift(t *testing.T) func TestEnsureTemplateRevisionsRejectsChangedRefWithoutGit(t *testing.T) { workspace, cfg, git := templateRevisionTestConfig(t) - original := runGit - runGit = git.run - t.Cleanup(func() { runGit = original }) + useTemplateRevisionGit(t, git) if err := ensureTemplateRevisions(workspace, cfg); err != nil { t.Fatal(err) @@ -80,9 +74,7 @@ func TestEnsureTemplateRevisionsRejectsChangedRefWithoutGit(t *testing.T) { func TestEnsureTemplateRevisionsHidesSourceWhenLockedCommitIsUnavailable(t *testing.T) { workspace, cfg, git := templateRevisionTestConfig(t) - original := runGit - runGit = git.run - t.Cleanup(func() { runGit = original }) + useTemplateRevisionGit(t, git) if err := ensureTemplateRevisions(workspace, cfg); err != nil { t.Fatal(err) @@ -94,6 +86,71 @@ func TestEnsureTemplateRevisionsHidesSourceWhenLockedCommitIsUnavailable(t *test } } +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() @@ -110,11 +167,12 @@ func templateRevisionTestConfig(t *testing.T) (string, config.Config, *fakeTempl 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"}, + Talos: config.TalosConfig{RepoDirName: "talos", GeneratedDir: "generated"}, Templates: config.TemplateConfig{ CICDRepoURL: "https://reader:template-password@git.example.test/templates/cicd.git", CICDRepoRef: "main", @@ -126,11 +184,24 @@ func templateRevisionTestConfig(t *testing.T) (string, config.Config, *fakeTempl }, 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 } @@ -151,6 +222,9 @@ func (git *fakeTemplateGit) run(dir string, args ...string) ([]byte, error) { 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 { diff --git a/internal/config/config.go b/internal/config/config.go index c7ad921..d8bc515 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -122,6 +122,9 @@ 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" } @@ -155,17 +158,14 @@ 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.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 @@ -233,11 +233,32 @@ func Validate(cfg Config) error { if cfg.Git.Token == "" { return errors.New("git token is required; SSH bootstrap is not implemented") } - 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 RedactURL(cfg.Git.BaseURL) != cfg.Git.BaseURL { + return errors.New("git baseUrl must not contain credentials, a query, or a fragment") } - 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 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 !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.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,61}[a-z0-9])?$`).MatchString(cfg.Delivery.AppName) { + return errors.New("delivery appName must be a lowercase DNS label") + } + 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 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") @@ -251,6 +272,10 @@ 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") } @@ -359,6 +384,30 @@ func Validate(cfg Config) error { 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) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5d72fda..c817d07 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -17,6 +17,7 @@ func validConfig(t *testing.T) Config { Templates: TemplateConfig{TalosRepoURL: "https://git.example.test/talos.git", TalosRepoRef: "main", CICDRepoURL: "https://git.example.test/template.git", CICDRepoRef: "main", ManifestsRepoURL: "https://git.example.test/manifests.git", ManifestsRepoRef: "main"}, 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", 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"}, @@ -27,6 +28,48 @@ func validConfig(t *testing.T) Config { } } +func TestResolveDoesNotInventAnApplication(t *testing.T) { + cfg := validConfig(t) + cfg.Delivery.AppName = "" + cfg.Delivery.AppRepoURL = "" + cfg.Delivery.ImageRepository = "" + if _, err := Resolve(cfg); err == nil { + t.Fatal("Resolve() accepted a configuration without an explicit application") + } +} + +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 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) { cfg, err := Resolve(validConfig(t)) if err != nil { @@ -35,7 +78,7 @@ func TestResolveDefaultsWebhookEndpoint(t *testing.T) { 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" { + if cfg.Templates.TektonCatalogRepoURL != "https://git.pingu.pw/Maidn/tekton-pipelines.git" || cfg.Templates.TektonCatalogRepoRef != "main" || cfg.Flux.TektonCatalogRepo != "tekton-pipelines" { t.Fatalf("Tekton catalog defaults = %q@%q", cfg.Templates.TektonCatalogRepoURL, cfg.Templates.TektonCatalogRepoRef) } } diff --git a/internal/config/types.go b/internal/config/types.go index 34aae8c..428e821 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -29,12 +29,15 @@ type DemocraticCSIConfig struct { } type DeliveryConfig struct { - AppName string `yaml:"appName"` - AppRepoURL string `yaml:"appRepoUrl"` - AppRepoRef string `yaml:"appRepoRef"` - ImageRepository string `yaml:"imageRepository"` - WebhookHostname string `yaml:"webhookHostname"` - WebhookPath string `yaml:"webhookPath"` + AppName string `yaml:"appName"` + AppRepoURL string `yaml:"appRepoUrl"` + AppRepoRef string `yaml:"appRepoRef"` + ProductionBranch string `yaml:"productionBranch"` + ImageRepository string `yaml:"imageRepository"` + BuildOutputDirectory string `yaml:"buildOutputDirectory"` + BuildConfiguration string `yaml:"buildConfiguration"` + WebhookHostname string `yaml:"webhookHostname"` + WebhookPath string `yaml:"webhookPath"` } func (c DeliveryConfig) WebhookURL() string { @@ -62,11 +65,12 @@ type GitConfig struct { } type FluxConfig struct { - RepoName string `yaml:"repoName"` - Branch string `yaml:"branch"` - ClusterPath string `yaml:"clusterPath"` - ClusterDomain string `yaml:"clusterDomain"` - ManifestsRepo string `yaml:"manifestsRepo"` + RepoName string `yaml:"repoName"` + Branch string `yaml:"branch"` + ClusterPath string `yaml:"clusterPath"` + ClusterDomain string `yaml:"clusterDomain"` + ManifestsRepo string `yaml:"manifestsRepo"` + TektonCatalogRepo string `yaml:"tektonCatalogRepo"` } type TemplateConfig struct { diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go index 6ef2242..f1b4d6d 100644 --- a/internal/forgejo/repo.go +++ b/internal/forgejo/repo.go @@ -3,6 +3,7 @@ package forgejo import ( "bytes" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -62,6 +63,20 @@ 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 @@ -76,6 +91,8 @@ type accessToken struct { SHA1 string `json:"sha1"` } +var copyGit = runGit + func (e *APIError) Error() string { return fmt.Sprintf("forgejo returned %s", e.Status) } @@ -171,15 +188,11 @@ 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, createURL, body) + status, err := rm.apiRequest(http.MethodPost, rm.repositoryCreateURL(), body) if err != nil { return err } @@ -190,6 +203,93 @@ 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 @@ -197,7 +297,7 @@ func (rm *RepoManager) EnsureRepository(name, description string) (bool, error) if exists { return false, nil } - return true, rm.createRepo(name, description, false) + return true, rm.createRepo(name, description, autoInit) } func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool, createStructure func(string) error) error { @@ -206,7 +306,7 @@ func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool, return err } defer os.RemoveAll(tempDir) - cleanupAskPass, environment, err := rm.gitEnvironment(tempDir) + cleanupAskPass, environment, err := rm.gitEnvironment() if err != nil { return err } @@ -237,6 +337,9 @@ 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 @@ -281,7 +384,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(dir) + cleanupAskPass, environment, err := rm.gitEnvironment() if err != nil { return err } @@ -289,8 +392,88 @@ func (rm *RepoManager) PushRef(dir, repoURL, sourceRef, targetBranch string) err return runGit(dir, environment, "push", repoURL, sourceRef+":refs/heads/"+targetBranch) } +// DeliveryBranch returns the dedicated branch that carries generated delivery content. +func DeliveryBranch(appName, baseBranch string) (string, error) { + branch := "maidn/delivery-" + appName + if appName == "" || branch == baseBranch { + return "", fmt.Errorf("delivery branch and configured base branch must differ") + } + return branch, nil +} + +// PublishDeliveryBranch generates and commits delivery content in a temporary clone. +func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, deliveryBranch string, generate func(string) error) error { + if sourceDir == "" || sourceBranch == "" || deliveryBranch == "" || deliveryBranch == rm.Branch { + return fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch") + } + temporary, err := os.MkdirTemp("", "maidn-delivery-*") + if err != nil { + return err + } + defer os.RemoveAll(temporary) + if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil { + return err + } + if err := generate(temporary); err != nil { + return err + } + cleanupAskPass, environment, err := rm.gitEnvironment() + if err != nil { + return err + } + defer cleanupAskPass() + if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil { + return err + } + if err := runGit(temporary, environment, "add", ".tekton"); err != nil { + return err + } + changed, err := gitDiffQuiet(temporary, environment, "--cached") + if err != nil { + return err + } + if changed { + for _, args := range [][]string{{"config", "user.name", "Maidn"}, {"config", "user.email", "maidn@free-maidn.com"}, {"commit", "-m", "feat: add Maidn delivery pipeline"}} { + if err := runGit(temporary, environment, args...); err != nil { + return err + } + } + } + hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch) + if err != nil { + return err + } + if hasBranch { + if err := runGit(temporary, environment, "fetch", repoURL, "refs/heads/"+deliveryBranch); err != nil { + return err + } + different, err := gitDiffQuiet(temporary, environment, "HEAD", "FETCH_HEAD") + if err != nil { + return err + } + if !different { + return nil + } + return fmt.Errorf("dedicated delivery branch %q differs from generated content; refusing to overwrite it", deliveryBranch) + } + return rm.PushBranch(temporary, repoURL, deliveryBranch) +} + +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 } @@ -319,6 +502,35 @@ 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 { @@ -382,6 +594,49 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err return nil } +// EnsureProtectedBranch disables direct pushes to the configured production branch. +func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error { + if repo == "" || branch == "" { + return errors.New("Forgejo repository and production branch are required") + } + endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/branch_protections", rm.BaseURL, rm.Owner, repo) + 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 production branch %q", branch) + } + if len(matching) == 1 { + if matching[0].EnablePush || matching[0].EnablePushWhitelist { + return fmt.Errorf("Forgejo production 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 { @@ -408,8 +663,13 @@ func commitAndPush(tempDir, repoName, branch string, environment []string) (bool return true, nil } -func (rm *RepoManager) gitEnvironment(tempDir string) (func(), []string, error) { - if rm.Token == "" { +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 == "" { return func() {}, append(os.Environ(), "GIT_TERMINAL_PROMPT=0"), nil } askPassDir, err := os.MkdirTemp("", "maidn-askpass-*") @@ -426,7 +686,7 @@ func (rm *RepoManager) gitEnvironment(tempDir 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="+rm.Username, "MAIDN_GIT_TOKEN="+rm.Token) + environment := append(os.Environ(), "GIT_ASKPASS="+path, "GIT_TERMINAL_PROMPT=0", "MAIDN_GIT_USERNAME="+username, "MAIDN_GIT_TOKEN="+token) return func() { _ = os.RemoveAll(askPassDir) }, environment, nil } diff --git a/internal/forgejo/repo_test.go b/internal/forgejo/repo_test.go index b8ab937..1092730 100644 --- a/internal/forgejo/repo_test.go +++ b/internal/forgejo/repo_test.go @@ -2,9 +2,11 @@ package forgejo import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "reflect" + "strings" "testing" ) @@ -39,6 +41,94 @@ func TestCreateRegistryTokenUsesBasicAuthAndPackageScopes(t *testing.T) { } } +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) @@ -114,6 +204,50 @@ func TestEnsureWebhookCreatesMissingWebhook(t *testing.T) { } } +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 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" { diff --git a/internal/openbao/bootstrap.go b/internal/openbao/bootstrap.go index aaacc8b..ee97efe 100644 --- a/internal/openbao/bootstrap.go +++ b/internal/openbao/bootstrap.go @@ -2,6 +2,7 @@ package openbao import ( "bytes" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -41,6 +42,8 @@ var decryptRecovery = func(identityPath, bundlePath string) ([]byte, error) { return output, nil } +var openBaoStatus = getStatus + func EnsureRecoveryIdentity(identityPath string) (string, error) { if _, err := os.Stat(identityPath); os.IsNotExist(err) { if err := os.MkdirAll(filepath.Dir(identityPath), 0700); err != nil { @@ -104,7 +107,14 @@ func Initialize(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, ope if err := configureKubernetesAuth(kubeconfig, material.RootToken, string(bytes.TrimSpace(reviewerToken))); err != nil { return nil, err } - return seedOperationalSecrets(kubeconfig, material.RootToken, ageKeyPath, operationalSecretsPath) + secrets, err := seedOperationalSecrets(kubeconfig, material.RootToken, ageKeyPath, operationalSecretsPath) + if err != nil { + return nil, err + } + if err := refreshExternalSecrets(kubeconfig); err != nil { + return nil, err + } + return secrets, nil } func seedOperationalSecrets(kubeconfig, rootToken, ageKeyPath, path string) (map[string]map[string]string, error) { @@ -213,14 +223,16 @@ func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]str input.WriteByte('\n') for _, key := range keys { arguments = append(arguments, fmt.Sprintf("%s=\"$value%d\"", key, len(arguments))) - input.WriteString(values[key]) + input.WriteString(base64.StdEncoding.EncodeToString([]byte(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", index)) + reads = append(reads, fmt.Sprintf("read -r value%d_b64", index)) + decodes = append(decodes, fmt.Sprintf("value%d=$(printf '%%s' \"$value%d_b64\" | base64 -d; printf x)\nvalue%d=${value%d%%x}", index, index, index, index)) } - 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" + script := "read -r root_token\n" + strings.Join(reads, "\n") + "\n" + strings.Join(decodes, "\n") + "\nexport BAO_TOKEN=\"$root_token\"\nbao kv put secret/" + secretPath + " " + strings.Join(arguments, " ") + " >/dev/null" _, err := execInPod(kubeconfig, []byte(input.String()), "sh", "-ec", script) return err } @@ -253,10 +265,34 @@ 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") } - for _, key := range material.UnsealKeysB64[:material.UnsealThreshold] { - 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) - } + for _, key := range material.UnsealKeysB64 { + _, _ = execInPod(kubeconfig, []byte(key+"\n"), "sh", "-ec", "read -r key; bao operator unseal \"$key\" >/dev/null") + } + current, err := openBaoStatus(kubeconfig) + if err != nil { + return fmt.Errorf("verify OpenBao unseal: %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 } @@ -284,6 +320,22 @@ bao write auth/kubernetes/role/external-secrets bound_service_account_names=exte return err } +func refreshExternalSecrets(kubeconfig string) error { + available, err := kubectlOutput(kubeconfig, "-n", "external-secrets", "get", "deployment/external-secrets", "-o=jsonpath={.status.conditions[?(@.type==\"Available\")].status}") + if err != nil || strings.TrimSpace(string(available)) != "True" { + return nil + } + _, err = kubectlOutput(kubeconfig, externalSecretRefreshArgs(time.Now().UnixNano())...) + if err != nil { + return fmt.Errorf("refresh ExternalSecrets after OpenBao seed: %w", err) + } + return nil +} + +func externalSecretRefreshArgs(timestamp int64) []string { + return []string{"annotate", "externalsecret", "forgejo-webhook", "-n", "tekton-pipelines", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite"} +} + func encryptRecovery(recipient, bundlePath string, plaintext []byte) error { if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil { return err @@ -296,14 +348,19 @@ func encryptRecovery(recipient, bundlePath string, plaintext []byte) error { return os.Chmod(bundlePath, 0600) } -func execInPod(kubeconfig string, input []byte, args ...string) ([]byte, error) { +var execInPod = func(kubeconfig string, input []byte, args ...string) ([]byte, error) { command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...) cmd := exec.Command("kubectl", command...) cmd.Stdin = bytes.NewReader(input) return cmd.CombinedOutput() } -func kubectlOutput(kubeconfig string, args ...string) ([]byte, error) { +var execInUnsealController = func(kubeconfig, script string) ([]byte, error) { + command := []string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "deployment/openbao-unseal", "--", "sh", "-ec", script} + return exec.Command("kubectl", command...).CombinedOutput() +} + +var kubectlOutput = func(kubeconfig string, args ...string) ([]byte, error) { command := append([]string{"--kubeconfig", kubeconfig}, args...) return exec.Command("kubectl", command...).Output() } diff --git a/internal/openbao/bootstrap_test.go b/internal/openbao/bootstrap_test.go index dd6caf5..9bc5224 100644 --- a/internal/openbao/bootstrap_test.go +++ b/internal/openbao/bootstrap_test.go @@ -1,6 +1,7 @@ package openbao import ( + "encoding/base64" "errors" "os/exec" "path/filepath" @@ -21,6 +22,72 @@ func TestEnsureRecoveryIdentity(t *testing.T) { } } +func TestUnsealSubmitsAllSharesAndVerifiesResult(t *testing.T) { + originalExec, originalStatus := execInPod, openBaoStatus + t.Cleanup(func() { execInPod, openBaoStatus = originalExec, originalStatus }) + var shares []string + execInPod = func(_ string, input []byte, _ ...string) ([]byte, error) { + shares = append(shares, strings.TrimSpace(string(input))) + 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 || strings.Join(shares, ",") != "share-1,share-2,share-3" { + t.Fatalf("unseal = shares:%q err:%v", shares, 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 := execInPod + t.Cleanup(func() { execInPod = original }) + var input, script string + execInPod = 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 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 externalsecret forgejo-webhook") || strings.Contains(calls[1], "--all") { + t.Fatalf("ExternalSecret refresh was not readiness-gated and scoped: %q, %v", calls, err) + } +} + func TestReadRecoveryMaterialDecryptsAndValidatesBundle(t *testing.T) { original := decryptRecovery t.Cleanup(func() { decryptRecovery = original }) diff --git a/internal/proxmox/client.go b/internal/proxmox/client.go index 12b6f50..33218c9 100644 --- a/internal/proxmox/client.go +++ b/internal/proxmox/client.go @@ -84,11 +84,16 @@ type NodeInfo struct { } type StorageInfo struct { - Name string - Type string - FreeGB int - SupportsISO bool - SupportsDisk bool + Name string + Type string + FreeGB int + SupportsISO bool + SupportsDisk bool +} + +type VMInfo struct { + Name string + Description string } func New(baseURL, tokenID, tokenSecret string, insecure bool) *Client { @@ -131,6 +136,20 @@ 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 { diff --git a/internal/proxmox/client_test.go b/internal/proxmox/client_test.go new file mode 100644 index 0000000..e583a98 --- /dev/null +++ b/internal/proxmox/client_test.go @@ -0,0 +1,22 @@ +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) + } +} diff --git a/internal/ui/wizard.go b/internal/ui/wizard.go index 44d16c5..6f0bdad 100644 --- a/internal/ui/wizard.go +++ b/internal/ui/wizard.go @@ -33,6 +33,13 @@ 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")) @@ -146,10 +153,10 @@ func PromptOperationalSecrets(cfg config.Config) (map[string]map[string]string, return nil, err } 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}, + "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}, }, nil }