Merge pull request 'feat: harden bootstrap and generic delivery' (#13) from feat/recovery-delivery-hardening into main

Reviewed-on: #13
This commit is contained in:
eding 2026-08-22 14:03:56 +02:00
commit ba79613064
16 changed files with 1783 additions and 129 deletions

View file

@ -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 {
if err := manager.PublishDeliveryBranch(bootstrapPublishAppFrom, branch, cfg.Delivery.AppRepoURL, deliveryBranch, func(dir string) error {
return bootstrap.GenerateAppDelivery(dir, cfg)
}); err != nil {
return err
}
return manager.CreatePullRequest(repo, "feat: migrate delivery to Tekton", deliveryBranch, cfg.Delivery.AppRepoRef)
}
if err := manager.PushBranch(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, branch); err != nil {
return err
}
return manager.CreatePullRequest(repo, "feat: migrate delivery to Tekton", branch, cfg.Delivery.AppRepoRef)
}
if 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)

View file

@ -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

View file

@ -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" <<EOF
apiVersion: v1
kind: ConfigMap
metadata:
name: maidn-preview-owner
namespace: $APP_NAME-pr-$PR_NUMBER
labels:
maidn.io/preview-owner: "true"
maidn.io/preview-app: "$APP_NAME"
maidn.io/preview-pr: "$PR_NUMBER"
annotations:
maidn.io/preview-repository: "$APP_REPOSITORY"
data:
app: "$APP_NAME"
repository: "$APP_REPOSITORY"
pr-number: "$PR_NUMBER"
EOF
cmp -s "$expected_marker" "$marker" || fail
fi
git clone "$APP_URL" /tmp/app
git -C /tmp/app checkout "$APP_REVISION"
[ -f /tmp/app/preview/values.yaml ] || fail
mkdir -p "$app_dir"
cp /tmp/app/preview/values.yaml "$app_dir/values.yaml"
sed -i "s/PLACEHOLDER_PR/$PR_NUMBER/g" "$app_dir/values.yaml"
cat > "$marker" <<EOF
apiVersion: v1
kind: ConfigMap
metadata:
name: maidn-preview-owner
namespace: $APP_NAME-pr-$PR_NUMBER
labels:
maidn.io/preview-owner: "true"
maidn.io/preview-app: "$APP_NAME"
maidn.io/preview-pr: "$PR_NUMBER"
annotations:
maidn.io/preview-repository: "$APP_REPOSITORY"
data:
app: "$APP_NAME"
repository: "$APP_REPOSITORY"
pr-number: "$PR_NUMBER"
EOF
cat > "$app_dir/namespace.yaml" <<EOF
apiVersion: v1
kind: Namespace
metadata:
name: $APP_NAME-pr-$PR_NUMBER
EOF
cat > "$app_dir/release.yaml" <<EOF
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: $APP_NAME
namespace: $APP_NAME-pr-$PR_NUMBER
spec:
interval: 5m
chart:
spec:
chart: ./charts/$APP_NAME
sourceRef:
kind: GitRepository
name: $APP_NAME
namespace: flux-system
values:
image:
repository: $IMAGE
tag: $TAG
EOF
cat > "$app_dir/kustomization.yaml" <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: $APP_NAME-pr-$PR_NUMBER
resources:
- namespace.yaml
- ownership.yaml
- release.yaml
EOF
root=apps/previews/kustomization.yaml
grep -q '^resources:' "$root" || fail
grep -qxF " - $APP_NAME-pr-$PR_NUMBER" "$root" 2>/dev/null || printf ' - %s-pr-%s\n' "$APP_NAME" "$PR_NUMBER" >> "$root"
elif [ "$ENVIRONMENT" = staging ] || [ "$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 }}

View file

@ -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
@ -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,8 +394,178 @@ func copyAndRenderDeliveryBases(templateDir, repoDir string, cfg config.Config)
return err
}
}
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 {
for _, base := range []string{"cilium", "cilium-config"} {
@ -374,8 +577,109 @@ func copyAndRenderCiliumBases(templateDir, repoDir string, cfg config.Config) er
return err
}
}
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 {
plaintext, err := renderDemocraticCSISecret(csi)
@ -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"))
}
@ -986,14 +1426,15 @@ type templateCheckout struct {
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) == "<redacted>" {
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 == "<redacted>" {
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")

View file

@ -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 })

View file

@ -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 {

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -32,7 +32,10 @@ type DeliveryConfig struct {
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"`
}
@ -67,6 +70,7 @@ type FluxConfig struct {
ClusterPath string `yaml:"clusterPath"`
ClusterDomain string `yaml:"clusterDomain"`
ManifestsRepo string `yaml:"manifestsRepo"`
TektonCatalogRepo string `yaml:"tektonCatalogRepo"`
}
type TemplateConfig struct {

View file

@ -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
}

View file

@ -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" {

View file

@ -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()
}

View file

@ -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 })

View file

@ -91,6 +91,11 @@ type StorageInfo struct {
SupportsDisk bool
}
type VMInfo struct {
Name string
Description string
}
func New(baseURL, tokenID, tokenSecret string, insecure bool) *Client {
transport := &http.Transport{}
if insecure {
@ -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 {

View file

@ -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)
}
}

View file

@ -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"))