feat: harden bootstrap and generic delivery #13
|
|
@ -2,7 +2,6 @@ package cmd
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||
|
|
@ -123,13 +122,27 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
|
||||
if err := forgejo.EnsureCleanCheckout(bootstrapPublishAppFrom); err != nil {
|
||||
return err
|
||||
}
|
||||
origin, err := forgejo.CheckoutOrigin(bootstrapPublishAppFrom)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.RedactURL(origin) != cfg.Delivery.AppRepoURL {
|
||||
return fmt.Errorf("--publish-app-from origin does not match delivery appRepoUrl")
|
||||
}
|
||||
branch, err := forgejo.CurrentBranch(bootstrapPublishAppFrom)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deliveryBranch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
owner, repo, err := forgejo.RepositoryFromURL(cfg.Delivery.AppRepoURL)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -139,33 +152,22 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasDeliveryBranch, err := manager.HasRemoteBranch(cfg.Delivery.AppRepoURL, cfg.Delivery.AppRepoRef)
|
||||
if err != nil {
|
||||
if created && branch != cfg.Delivery.AppRepoRef {
|
||||
return fmt.Errorf("new application repository requires the checkout branch to match delivery appRepoRef")
|
||||
}
|
||||
if err := manager.EnsureProtectedBranch(repo, cfg.Delivery.ProductionBranch); err != nil {
|
||||
return fmt.Errorf("protect Forgejo production branch: %w", err)
|
||||
}
|
||||
if err := manager.PushRef(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, branch, branch); err != nil {
|
||||
return err
|
||||
}
|
||||
if created || !hasDeliveryBranch {
|
||||
if err := manager.PushRef(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, "HEAD", cfg.Delivery.AppRepoRef); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := manager.PushBranch(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, branch); err != nil {
|
||||
if err := manager.PublishDeliveryBranch(bootstrapPublishAppFrom, branch, cfg.Delivery.AppRepoURL, deliveryBranch, func(dir string) error {
|
||||
return bootstrap.GenerateAppDelivery(dir, cfg)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return manager.CreatePullRequest(repo, "feat: migrate delivery to Tekton", branch, cfg.Delivery.AppRepoRef)
|
||||
return manager.CreatePullRequest(repo, "feat: migrate delivery to Tekton", deliveryBranch, cfg.Delivery.AppRepoRef)
|
||||
}
|
||||
if bootstrapInitializeOpenBao {
|
||||
if bootstrapConfigPath == "" {
|
||||
return fmt.Errorf("--initialize-openbao requires --config")
|
||||
}
|
||||
cfg, err = config.Load(bootstrapConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
generatedDir := filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir)
|
||||
_, err = openbao.Initialize(filepath.Join(generatedDir, "kubeconfig"), cfg.SOPS.RecoveryRecipient, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, cfg.SOPS.AgeKeyPath, cfg.SOPS.OperationalSecretsPath)
|
||||
return err
|
||||
}
|
||||
|
||||
if bootstrapConfigPath != "" {
|
||||
if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery || bootstrapManageNetworkBridges {
|
||||
cfg, err = config.LoadRaw(bootstrapConfigPath)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
326
internal/assets/templates/delivery-pipeline.yaml.tmpl
Normal file
326
internal/assets/templates/delivery-pipeline.yaml.tmpl
Normal 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 }}
|
||||
|
|
@ -9,19 +9,25 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/assets"
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/cloudflare"
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
||||
ghrepo "github.com/Pingu-Studio/MaidnCLI/internal/github"
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/openbao"
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/proxmox"
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/utils"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
|
@ -71,6 +77,30 @@ var runGit = func(dir string, args ...string) ([]byte, error) {
|
|||
return command.Output()
|
||||
}
|
||||
|
||||
var runGitEnvironment = func(dir string, environment []string, args ...string) ([]byte, error) {
|
||||
command := exec.Command("git", args...)
|
||||
command.Dir = dir
|
||||
command.Env = environment
|
||||
return command.Output()
|
||||
}
|
||||
|
||||
var verifyTalosVMs = verifyConfiguredTalosVMs
|
||||
|
||||
var terraformStateResources = func(terraformDir string, environment []string) ([]string, error) {
|
||||
command := exec.Command("terraform", "state", "list")
|
||||
command.Dir = terraformDir
|
||||
command.Env = append(os.Environ(), environment...)
|
||||
state, err := command.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list Terraform state: %w", err)
|
||||
}
|
||||
return strings.Fields(string(state)), nil
|
||||
}
|
||||
|
||||
var destroyTalosVMs = func(terraformDir string, environment []string) error {
|
||||
return utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "destroy", "-auto-approve", "-input=false", "-target=proxmox_virtual_environment_vm.vm")
|
||||
}
|
||||
|
||||
var webhookTargetTimeout = 70 * time.Minute
|
||||
|
||||
var webhookTargetPollInterval = 2 * time.Second
|
||||
|
|
@ -79,7 +109,7 @@ var templateBaseComponents = []string{"snapshot-crds", "democratic-csi", "cert-m
|
|||
|
||||
var generatedTemplateFiles = map[string]map[string]bool{
|
||||
"democratic-csi": {"secret.sops.yaml": true},
|
||||
"openbao": {"unseal.sops.yaml": true},
|
||||
"openbao": {"unseal.sops.yaml": true},
|
||||
}
|
||||
|
||||
var requiredClusterKustomizations = []string{"snapshot-crds-kustomization.yaml", "democratic-csi-kustomization.yaml", "cert-manager-kustomization.yaml", "cluster-issuers-kustomization.yaml", "gateway-api-kustomization.yaml", "cilium-kustomization.yaml", "cilium-config-kustomization.yaml", "openbao-kustomization.yaml", "external-secrets-kustomization.yaml", "cnpg-kustomization.yaml", "gateway-kustomization.yaml", "external-dns-kustomization.yaml", "cloudflare-tunnel-kustomization.yaml", "monitoring-kustomization.yaml", "tekton-kustomization.yaml", "tekton-triggers-kustomization.yaml", "cicd-manifests-repo.yaml"}
|
||||
|
|
@ -129,6 +159,10 @@ func (r Runner) Run() error {
|
|||
if err := EnsureTemplateRevisions(r.Config); err != nil {
|
||||
return err
|
||||
}
|
||||
catalogManager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, "", "", r.Config.Templates.TektonCatalogRepoRef, "")
|
||||
if _, err := catalogManager.EnsureRepositoryCopy(r.Config.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", r.Config.Templates.TektonCatalogRepoURL); err != nil {
|
||||
return fmt.Errorf("initialize Tekton catalog repository: %w", err)
|
||||
}
|
||||
if err := config.WriteRedacted(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -325,15 +359,14 @@ func renderDeliveryConfig(dir string, cfg config.Config) error {
|
|||
"${APP_NAME}", cfg.Delivery.AppName,
|
||||
"${APP_REPO_URL}", cfg.Delivery.AppRepoURL,
|
||||
"${APP_REPO_REF}", cfg.Delivery.AppRepoRef,
|
||||
"${PRODUCTION_BRANCH}", cfg.Delivery.ProductionBranch,
|
||||
"${IMAGE_REPOSITORY}", cfg.Delivery.ImageRepository,
|
||||
"${FORGEJO_BASE_URL}", cfg.Git.BaseURL,
|
||||
"${CLUSTER_DOMAIN}", cfg.Flux.ClusterDomain,
|
||||
"${TEKTON_CATALOG_REPO_URL}", cfg.Templates.TektonCatalogRepoURL,
|
||||
"${TEKTON_CATALOG_REPO_URL}", forgejo.CloneURL(cfg.Git.BaseURL, cfg.Git.Owner, cfg.Flux.TektonCatalogRepo),
|
||||
"${TEKTON_CATALOG_REPO_REF}", cfg.Templates.TektonCatalogRepoRef,
|
||||
"${WEBHOOK_HOSTNAME}", cfg.Delivery.WebhookHostname,
|
||||
"${WEBHOOK_PATH}", cfg.Delivery.WebhookPath,
|
||||
"${TEKTON_CATALOG_REPO_URL}", cfg.Templates.TektonCatalogRepoURL,
|
||||
"${TEKTON_CATALOG_REPO_REF}", cfg.Templates.TektonCatalogRepoRef,
|
||||
)
|
||||
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
|
|
@ -361,7 +394,177 @@ func copyAndRenderDeliveryBases(templateDir, repoDir string, cfg config.Config)
|
|||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return writePreviewDeliveryConfig(filepath.Join(repoDir, "base", "tekton"), cfg)
|
||||
}
|
||||
|
||||
func writePreviewDeliveryConfig(dir string, cfg config.Config) error {
|
||||
origin, err := canonicalForgejoOrigin(cfg.Git.BaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifestsURL := forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo)
|
||||
content, err := yaml.Marshal(struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Kind string `yaml:"kind"`
|
||||
Metadata map[string]string `yaml:"metadata"`
|
||||
Data map[string]string `yaml:"data"`
|
||||
}{
|
||||
APIVersion: "v1",
|
||||
Kind: "ConfigMap",
|
||||
Metadata: map[string]string{"name": "maidn-preview-delivery-config", "namespace": "tekton-pipelines"},
|
||||
Data: map[string]string{
|
||||
"forgejo-origin": origin,
|
||||
"manifests-url": manifestsURL,
|
||||
"manifests-branch": cfg.Flux.Branch,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, "kustomization.yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains("\n"+string(data), "\nresources:") {
|
||||
return errors.New("Tekton Kustomization must define resources before adding preview delivery configuration")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "maidn-preview-delivery-config.yaml"), content, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(string(data), "maidn-preview-delivery-config.yaml") {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(path, append(data, []byte(" - maidn-preview-delivery-config.yaml\n")...), 0644)
|
||||
}
|
||||
|
||||
func canonicalForgejoOrigin(value string) (string, error) {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") != "" {
|
||||
return "", errors.New("Forgejo base URL must be a credential-free HTTPS origin")
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host, nil
|
||||
}
|
||||
|
||||
type appDeliveryTemplateConfig struct {
|
||||
AppName string
|
||||
AppRepository string
|
||||
AppRepoURL string
|
||||
AppRepoRef string
|
||||
ProductionBranch string
|
||||
ImageRepository string
|
||||
BuildOutputDirectory string
|
||||
BuildConfiguration string
|
||||
ForgejoBaseURL string
|
||||
ManifestsURL string
|
||||
ManifestsBranch string
|
||||
}
|
||||
|
||||
var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
|
||||
// GenerateAppDelivery writes the source-owned Tekton delivery contract for an app checkout.
|
||||
func GenerateAppDelivery(dir string, cfg config.Config) error {
|
||||
content, err := renderAppDelivery(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files := map[string][]byte{
|
||||
"kustomization.yaml": []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - pipeline.yaml\n"),
|
||||
"pipeline.yaml": content,
|
||||
}
|
||||
target := filepath.Join(dir, ".tekton")
|
||||
info, err := os.Lstat(target)
|
||||
if err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return errors.New("app delivery .tekton path must be a directory")
|
||||
}
|
||||
entries, err := os.ReadDir(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) != len(files) {
|
||||
return errors.New("app delivery .tekton contains unmanaged files")
|
||||
}
|
||||
for name, want := range files {
|
||||
got, err := os.ReadFile(filepath.Join(target, name))
|
||||
if err != nil || !bytes.Equal(got, want) {
|
||||
return errors.New("app delivery .tekton differs from Maidn generated content")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
temporary, err := os.MkdirTemp(dir, ".maidn-tekton-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(temporary)
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(filepath.Join(temporary, name), content, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.Rename(temporary, target)
|
||||
}
|
||||
|
||||
func renderAppDelivery(cfg config.Config) ([]byte, error) {
|
||||
if !deliveryAppName.MatchString(cfg.Delivery.AppName) {
|
||||
return nil, errors.New("delivery appName must be a lowercase DNS label")
|
||||
}
|
||||
appRepository, err := deliveryRepository(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
origin, err := canonicalForgejoOrigin(cfg.Git.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := appDeliveryTemplateConfig{
|
||||
AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL,
|
||||
AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository,
|
||||
BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration,
|
||||
ForgejoBaseURL: origin, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsBranch: cfg.Flux.Branch,
|
||||
}
|
||||
for name, value := range map[string]string{"appRepository": values.AppRepository, "appRepoUrl": values.AppRepoURL, "appRepoRef": values.AppRepoRef, "productionBranch": values.ProductionBranch, "imageRepository": values.ImageRepository, "buildOutputDirectory": values.BuildOutputDirectory, "buildConfiguration": values.BuildConfiguration, "forgejoBaseUrl": values.ForgejoBaseURL, "manifestsUrl": values.ManifestsURL, "manifestsBranch": values.ManifestsBranch} {
|
||||
if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value {
|
||||
return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name)
|
||||
}
|
||||
}
|
||||
tmpl, err := template.New("delivery-pipeline").Funcs(template.FuncMap{"quote": strconv.Quote}).Parse(assets.DeliveryPipelineTmpl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := tmpl.Execute(&rendered, values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rendered.Bytes(), nil
|
||||
}
|
||||
|
||||
func deliveryRepository(baseURL, repositoryURL string) (string, error) {
|
||||
base, err := canonicalForgejoOrigin(baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
repository, err := url.Parse(repositoryURL)
|
||||
if err != nil || repository.Scheme != "https" || repository.Host == "" || repository.User != nil || repository.RawPath != "" || repository.RawQuery != "" || repository.Fragment != "" || !strings.EqualFold(repository.Scheme+"://"+repository.Host, base) {
|
||||
return "", errors.New("delivery appRepoUrl must be a credential-free HTTPS repository on the configured Forgejo origin")
|
||||
}
|
||||
parts := strings.Split(strings.Trim(repository.Path, "/"), "/")
|
||||
if len(parts) != 2 || !strings.HasSuffix(parts[1], ".git") {
|
||||
return "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
|
||||
}
|
||||
name := strings.TrimSuffix(parts[1], ".git")
|
||||
if !deliveryRepositoryPart(parts[0]) || !deliveryRepositoryPart(name) {
|
||||
return "", errors.New("delivery appRepoUrl has an invalid Forgejo owner or repository")
|
||||
}
|
||||
return parts[0] + "/" + name, nil
|
||||
}
|
||||
|
||||
func deliveryRepositoryPart(value string) bool {
|
||||
return value != "" && !strings.Contains(value, "..") && regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`).MatchString(value)
|
||||
}
|
||||
|
||||
func copyAndRenderCiliumBases(templateDir, repoDir string, cfg config.Config) error {
|
||||
|
|
@ -374,7 +577,108 @@ func copyAndRenderCiliumBases(templateDir, repoDir string, cfg config.Config) er
|
|||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return renderCiliumHubblePeerService(filepath.Join(repoDir, "base", "cilium", "release.yaml"), cfg.Flux.ClusterDomain)
|
||||
}
|
||||
|
||||
func renderCiliumHubblePeerService(path, clusterDomain string) error {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(content))
|
||||
var document yaml.Node
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return fmt.Errorf("parse Cilium HelmRelease: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&yaml.Node{}); !errors.Is(err, io.EOF) {
|
||||
return errors.New("Cilium HelmRelease must contain one YAML document")
|
||||
}
|
||||
if document.Kind != yaml.DocumentNode || len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
|
||||
return errors.New("Cilium HelmRelease must be a YAML mapping")
|
||||
}
|
||||
spec, err := requiredYAMLMapping(document.Content[0], "spec")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Cilium HelmRelease: %w", err)
|
||||
}
|
||||
values, err := requiredYAMLMapping(spec, "values")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Cilium HelmRelease spec: %w", err)
|
||||
}
|
||||
hubble, err := ensureYAMLMapping(values, "hubble")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Cilium HelmRelease values: %w", err)
|
||||
}
|
||||
peerService, err := ensureYAMLMapping(hubble, "peerService")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Cilium HelmRelease hubble: %w", err)
|
||||
}
|
||||
clusterDomainNode, err := yamlMappingValue(peerService, "clusterDomain")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Cilium HelmRelease hubble peerService: %w", err)
|
||||
}
|
||||
if clusterDomainNode != nil {
|
||||
if clusterDomainNode.Kind != yaml.ScalarNode {
|
||||
return errors.New("Cilium HelmRelease hubble peerService clusterDomain must be a scalar")
|
||||
}
|
||||
if clusterDomainNode.Value == clusterDomain {
|
||||
return nil
|
||||
}
|
||||
clusterDomainNode.Value = clusterDomain
|
||||
} else {
|
||||
peerService.Content = append(peerService.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: "clusterDomain"}, &yaml.Node{Kind: yaml.ScalarNode, Value: clusterDomain})
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&rendered)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(&document); err != nil {
|
||||
return fmt.Errorf("render Cilium HelmRelease: %w", err)
|
||||
}
|
||||
return os.WriteFile(path, rendered.Bytes(), 0644)
|
||||
}
|
||||
|
||||
func requiredYAMLMapping(node *yaml.Node, key string) (*yaml.Node, error) {
|
||||
value, err := yamlMappingValue(node, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value == nil || value.Kind != yaml.MappingNode {
|
||||
return nil, fmt.Errorf("%s must be a mapping", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func ensureYAMLMapping(node *yaml.Node, key string) (*yaml.Node, error) {
|
||||
value, err := yamlMappingValue(node, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value != nil {
|
||||
if value.Kind != yaml.MappingNode {
|
||||
return nil, fmt.Errorf("%s must be a mapping", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, &yaml.Node{Kind: yaml.MappingNode})
|
||||
return node.Content[len(node.Content)-1], nil
|
||||
}
|
||||
|
||||
func yamlMappingValue(node *yaml.Node, key string) (*yaml.Node, error) {
|
||||
if node.Kind != yaml.MappingNode || len(node.Content)%2 != 0 {
|
||||
return nil, errors.New("must be a YAML mapping")
|
||||
}
|
||||
var value *yaml.Node
|
||||
for index := 0; index < len(node.Content); index += 2 {
|
||||
if node.Content[index].Kind != yaml.ScalarNode {
|
||||
return nil, errors.New("contains a non-scalar key")
|
||||
}
|
||||
if node.Content[index].Value == key {
|
||||
if value != nil {
|
||||
return nil, fmt.Errorf("contains duplicate %s", key)
|
||||
}
|
||||
value = node.Content[index+1]
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func writeDemocraticCSISecret(path string, csi config.DemocraticCSIConfig, ageKeyPath string) error {
|
||||
|
|
@ -894,7 +1198,7 @@ type lifecycle struct {
|
|||
|
||||
func ensureLifecycleIdentity(terraformDir string, cfg config.Config) error {
|
||||
statePath := filepath.Join(terraformDir, "terraform.tfstate")
|
||||
lifecyclePath := filepath.Join(terraformDir, ".maidn", "lifecycle.yaml")
|
||||
lifecyclePath := filepath.Join(filepath.Dir(terraformDir), cfg.Talos.GeneratedDir, ".maidn", "lifecycle.yaml")
|
||||
data, err := os.ReadFile(lifecyclePath)
|
||||
if err == nil {
|
||||
var current lifecycle
|
||||
|
|
@ -929,8 +1233,11 @@ func (r Runner) reconcileTerraform(terraformDir string) error {
|
|||
if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "init", "-input=false"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := importNetworkBridges(terraformDir, environment, r.Config); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Mode == Rebuild {
|
||||
if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "destroy", "-auto-approve", "-input=false", "-target=proxmox_virtual_environment_vm.vm"); err != nil {
|
||||
if err := r.rebuildTalosVMs(terraformDir, environment); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -945,6 +1252,139 @@ func (r Runner) reconcileTerraform(terraformDir string) error {
|
|||
return utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "apply", "-input=false", "-auto-approve", planPath)
|
||||
}
|
||||
|
||||
func (r Runner) rebuildTalosVMs(terraformDir string, environment []string) error {
|
||||
if err := verifyTalosVMs(r.Config); err != nil {
|
||||
return err
|
||||
}
|
||||
resources, err := terraformStateResources(terraformDir, environment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := verifyStateTalosVMs(resources, r.Config, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := importTalosVMs(terraformDir, environment, r.Config); err != nil {
|
||||
return err
|
||||
}
|
||||
resources, err = terraformStateResources(terraformDir, environment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := verifyStateTalosVMs(resources, r.Config, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return destroyTalosVMs(terraformDir, environment)
|
||||
}
|
||||
|
||||
type terraformImport struct {
|
||||
Address string
|
||||
ID string
|
||||
}
|
||||
|
||||
func importNetworkBridges(terraformDir string, environment []string, cfg config.Config) error {
|
||||
if !cfg.Talos.Cluster.ManageNetworkBridges {
|
||||
return nil
|
||||
}
|
||||
return importTerraformResources(terraformDir, environment, networkBridgeImports(cfg))
|
||||
}
|
||||
|
||||
func importTalosVMs(terraformDir string, environment []string, cfg config.Config) error {
|
||||
return importTerraformResources(terraformDir, environment, talosVMImports(cfg))
|
||||
}
|
||||
|
||||
func verifyConfiguredTalosVMs(cfg config.Config) error {
|
||||
client := proxmox.New(cfg.Talos.Proxmox.APIURL, cfg.Talos.Proxmox.APITokenID, cfg.Talos.Proxmox.APITokenSecret, cfg.Talos.Proxmox.Insecure)
|
||||
for _, node := range cfg.Talos.Nodes {
|
||||
vm, err := client.GetVM(node.ProxmoxNode, node.VMID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect configured Talos VM %d: %w", node.VMID, err)
|
||||
}
|
||||
if vm.Name != node.Name || vm.Description != expectedTalosVMDescription(node.Role) {
|
||||
return fmt.Errorf("configured Talos VM %d does not match the expected Maidn Talos VM", node.VMID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyStateTalosVMs(resources []string, cfg config.Config, requireAll bool) error {
|
||||
configured := make(map[string]bool, len(cfg.Talos.Nodes))
|
||||
for _, vm := range talosVMImports(cfg) {
|
||||
configured[vm.Address] = false
|
||||
}
|
||||
const vmAddress = "proxmox_virtual_environment_vm.vm"
|
||||
for _, resource := range resources {
|
||||
if resource == vmAddress || strings.HasPrefix(resource, vmAddress+"[") {
|
||||
if _, ok := configured[resource]; !ok {
|
||||
return fmt.Errorf("Terraform state contains unconfigured Talos VM address %q; refusing targeted destroy", resource)
|
||||
}
|
||||
configured[resource] = true
|
||||
}
|
||||
}
|
||||
if requireAll {
|
||||
for address, present := range configured {
|
||||
if !present {
|
||||
return fmt.Errorf("Terraform state is missing configured Talos VM address %q; refusing targeted destroy", address)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func expectedTalosVMDescription(role string) string {
|
||||
if role == "controlplane" {
|
||||
return "Talos Control Plane Node - Managed by Terraform"
|
||||
}
|
||||
return "Talos Worker Node - Managed by Terraform"
|
||||
}
|
||||
|
||||
func importTerraformResources(terraformDir string, environment []string, imports []terraformImport) error {
|
||||
state, err := terraformStateResources(terraformDir, environment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resources := make(map[string]bool, len(state))
|
||||
for _, resource := range state {
|
||||
resources[resource] = true
|
||||
}
|
||||
for _, resource := range imports {
|
||||
if resources[resource.Address] {
|
||||
continue
|
||||
}
|
||||
if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "import", "-input=false", resource.Address, resource.ID); err != nil {
|
||||
return fmt.Errorf("import Terraform resource: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func networkBridgeImports(cfg config.Config) []terraformImport {
|
||||
imports := map[string]terraformImport{}
|
||||
for _, node := range cfg.Talos.Nodes {
|
||||
for _, network := range node.Networks {
|
||||
key := fmt.Sprintf("%s-%d", node.ProxmoxNode, network.VLANID)
|
||||
imports[key] = terraformImport{Address: fmt.Sprintf(`proxmox_virtual_environment_network_linux_bridge.cluster_bridge["%s"]`, key), ID: fmt.Sprintf("%s:vmbr%d", node.ProxmoxNode, network.VLANID)}
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(imports))
|
||||
for key := range imports {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
result := make([]terraformImport, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
result = append(result, imports[key])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func talosVMImports(cfg config.Config) []terraformImport {
|
||||
imports := make([]terraformImport, 0, len(cfg.Talos.Nodes))
|
||||
for _, node := range cfg.Talos.Nodes {
|
||||
imports = append(imports, terraformImport{Address: fmt.Sprintf(`proxmox_virtual_environment_vm.vm["%s"]`, node.Name), ID: fmt.Sprintf("%s/%d", node.ProxmoxNode, node.VMID)})
|
||||
}
|
||||
return imports
|
||||
}
|
||||
|
||||
func terraformPlanPath(terraformDir, clusterID string) (string, error) {
|
||||
return filepath.Abs(filepath.Join(terraformDir, clusterID+".tfplan"))
|
||||
}
|
||||
|
|
@ -983,17 +1423,18 @@ type templateRevisionLock struct {
|
|||
}
|
||||
|
||||
type templateCheckout struct {
|
||||
Dir string
|
||||
Repository string
|
||||
Ref string
|
||||
Dir string
|
||||
Repository string
|
||||
Ref string
|
||||
GeneratedDir string
|
||||
}
|
||||
|
||||
func ensureTemplateRevisions(workspace string, cfg config.Config) error {
|
||||
lockPath := filepath.Join(workspace, "maidn-template-revisions.yaml")
|
||||
checkouts := []templateCheckout{
|
||||
{filepath.Join(workspace, "maidn-cicd-cluster-template"), cfg.Templates.CICDRepoURL, cfg.Templates.CICDRepoRef},
|
||||
{filepath.Join(workspace, "cicd-deployment-manifests-template"), cfg.Templates.ManifestsRepoURL, cfg.Templates.ManifestsRepoRef},
|
||||
{filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName), cfg.Templates.TalosRepoURL, cfg.Templates.TalosRepoRef},
|
||||
{Dir: filepath.Join(workspace, "maidn-cicd-cluster-template"), Repository: cfg.Templates.CICDRepoURL, Ref: cfg.Templates.CICDRepoRef},
|
||||
{Dir: filepath.Join(workspace, "cicd-deployment-manifests-template"), Repository: cfg.Templates.ManifestsRepoURL, Ref: cfg.Templates.ManifestsRepoRef},
|
||||
{Dir: filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName), Repository: cfg.Templates.TalosRepoURL, Ref: cfg.Templates.TalosRepoRef, GeneratedDir: cfg.Talos.GeneratedDir},
|
||||
}
|
||||
lock, err := readTemplateRevisionLock(lockPath)
|
||||
if err == nil {
|
||||
|
|
@ -1002,7 +1443,7 @@ func ensureTemplateRevisions(workspace string, cfg config.Config) error {
|
|||
if !sameTemplateSource(revisions[index], checkout) {
|
||||
return errors.New("configured template source or ref differs from its workspace revision lock; use a new empty workspaceDir to intentionally refresh templates")
|
||||
}
|
||||
if _, err := checkoutTemplateRevision(checkout, revisions[index].Commit); err != nil {
|
||||
if _, err := checkoutTemplateRevision(cfg, checkout, revisions[index].Commit); err != nil {
|
||||
return errors.New("locked template revision cannot be resolved; restore the locked commit or use a new empty workspaceDir to intentionally refresh templates")
|
||||
}
|
||||
}
|
||||
|
|
@ -1017,7 +1458,7 @@ func ensureTemplateRevisions(workspace string, cfg config.Config) error {
|
|||
if config.RedactURL(checkout.Repository) == "<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")
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,12 +29,15 @@ type DemocraticCSIConfig struct {
|
|||
}
|
||||
|
||||
type DeliveryConfig struct {
|
||||
AppName string `yaml:"appName"`
|
||||
AppRepoURL string `yaml:"appRepoUrl"`
|
||||
AppRepoRef string `yaml:"appRepoRef"`
|
||||
ImageRepository string `yaml:"imageRepository"`
|
||||
WebhookHostname string `yaml:"webhookHostname"`
|
||||
WebhookPath string `yaml:"webhookPath"`
|
||||
AppName string `yaml:"appName"`
|
||||
AppRepoURL string `yaml:"appRepoUrl"`
|
||||
AppRepoRef string `yaml:"appRepoRef"`
|
||||
ProductionBranch string `yaml:"productionBranch"`
|
||||
ImageRepository string `yaml:"imageRepository"`
|
||||
BuildOutputDirectory string `yaml:"buildOutputDirectory"`
|
||||
BuildConfiguration string `yaml:"buildConfiguration"`
|
||||
WebhookHostname string `yaml:"webhookHostname"`
|
||||
WebhookPath string `yaml:"webhookPath"`
|
||||
}
|
||||
|
||||
func (c DeliveryConfig) WebhookURL() string {
|
||||
|
|
@ -62,11 +65,12 @@ type GitConfig struct {
|
|||
}
|
||||
|
||||
type FluxConfig struct {
|
||||
RepoName string `yaml:"repoName"`
|
||||
Branch string `yaml:"branch"`
|
||||
ClusterPath string `yaml:"clusterPath"`
|
||||
ClusterDomain string `yaml:"clusterDomain"`
|
||||
ManifestsRepo string `yaml:"manifestsRepo"`
|
||||
RepoName string `yaml:"repoName"`
|
||||
Branch string `yaml:"branch"`
|
||||
ClusterPath string `yaml:"clusterPath"`
|
||||
ClusterDomain string `yaml:"clusterDomain"`
|
||||
ManifestsRepo string `yaml:"manifestsRepo"`
|
||||
TektonCatalogRepo string `yaml:"tektonCatalogRepo"`
|
||||
}
|
||||
|
||||
type TemplateConfig struct {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -84,11 +84,16 @@ type NodeInfo struct {
|
|||
}
|
||||
|
||||
type StorageInfo struct {
|
||||
Name string
|
||||
Type string
|
||||
FreeGB int
|
||||
SupportsISO bool
|
||||
SupportsDisk bool
|
||||
Name string
|
||||
Type string
|
||||
FreeGB int
|
||||
SupportsISO bool
|
||||
SupportsDisk bool
|
||||
}
|
||||
|
||||
type VMInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
func New(baseURL, tokenID, tokenSecret string, insecure bool) *Client {
|
||||
|
|
@ -131,6 +136,20 @@ func (c *Client) Discover() ([]NodeInfo, error) {
|
|||
return result, nil
|
||||
}
|
||||
|
||||
// GetVM returns the identity fields used to verify a configured VM before import.
|
||||
func (c *Client) GetVM(node string, vmid int) (VMInfo, error) {
|
||||
var response struct {
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := c.getJSON("/nodes/"+url.PathEscape(node)+"/qemu/"+fmt.Sprint(vmid)+"/config", &response); err != nil {
|
||||
return VMInfo{}, err
|
||||
}
|
||||
return VMInfo{Name: response.Data.Name, Description: response.Data.Description}, nil
|
||||
}
|
||||
|
||||
func (c *Client) describeNode(node string) (NodeInfo, error) {
|
||||
var status nodeStatusResponse
|
||||
if err := c.getJSON("/nodes/"+node+"/status", &status); err != nil {
|
||||
|
|
|
|||
22
internal/proxmox/client_test.go
Normal file
22
internal/proxmox/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,13 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) {
|
|||
cfg.Flux.ManifestsRepo = prompt(reader, "Manifests repo name", fallback(cfg.Flux.ManifestsRepo, "cicd-deployment-manifests"))
|
||||
cfg.Flux.Branch = "main"
|
||||
cfg.Flux.ClusterPath = "./clusters/maidn-cd-0"
|
||||
cfg.Delivery.AppName = prompt(reader, "Application name", fallback(cfg.Delivery.AppName, "app"))
|
||||
cfg.Delivery.AppRepoURL = prompt(reader, "Application repository URL", fallback(cfg.Delivery.AppRepoURL, strings.TrimRight(cfg.Git.BaseURL, "/")+"/"+cfg.Git.Owner+"/"+cfg.Delivery.AppName+".git"))
|
||||
cfg.Delivery.AppRepoRef = prompt(reader, "Application base branch", fallback(cfg.Delivery.AppRepoRef, cfg.Flux.Branch))
|
||||
cfg.Delivery.ProductionBranch = prompt(reader, "Application production branch", fallback(cfg.Delivery.ProductionBranch, "production"))
|
||||
cfg.Delivery.ImageRepository = prompt(reader, "Application image repository", fallback(cfg.Delivery.ImageRepository, strings.TrimPrefix(strings.TrimPrefix(cfg.Git.BaseURL, "https://"), "http://")+"/"+strings.ToLower(cfg.Git.Owner)+"/"+cfg.Delivery.AppName))
|
||||
cfg.Delivery.BuildOutputDirectory = prompt(reader, "Application build output directory", fallback(cfg.Delivery.BuildOutputDirectory, "dist"))
|
||||
cfg.Delivery.BuildConfiguration = prompt(reader, "Application build configuration", fallback(cfg.Delivery.BuildConfiguration, "production"))
|
||||
cfg.Templates.TalosRepoURL = prompt(reader, "Talos template repo URL", fallback(cfg.Templates.TalosRepoURL, "https://git.pingu.pw/Maidn/maidn-talos-proxmox.git"))
|
||||
cfg.Templates.CICDRepoURL = prompt(reader, "CI/CD template repo URL", fallback(cfg.Templates.CICDRepoURL, "https://git.pingu.pw/Maidn/maidn-cicd-cluster-template.git"))
|
||||
cfg.Templates.ManifestsRepoURL = prompt(reader, "Manifests template repo URL", fallback(cfg.Templates.ManifestsRepoURL, "https://git.pingu.pw/Maidn/cicd-deployment-manifests-template.git"))
|
||||
|
|
@ -146,10 +153,10 @@ func PromptOperationalSecrets(cfg config.Config) (map[string]map[string]string,
|
|||
return nil, err
|
||||
}
|
||||
return map[string]map[string]string{
|
||||
"cicd/forgejo": {"username": cfg.Git.Username, "token": cfg.Git.Token},
|
||||
"cicd/forgejo-registry": {"dockerconfigjson": string(dockerConfig)},
|
||||
"platform/pihole": {"server": piholeServer, "password": piholePassword},
|
||||
"platform/cloudflare": {"api-token": cloudflareAPIToken},
|
||||
"cicd/forgejo": {"username": cfg.Git.Username, "token": cfg.Git.Token},
|
||||
"cicd/forgejo-registry": {"dockerconfigjson": string(dockerConfig)},
|
||||
"platform/pihole": {"server": piholeServer, "password": piholePassword},
|
||||
"platform/cloudflare": {"api-token": cloudflareAPIToken},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue