1525 lines
67 KiB
Go
1525 lines
67 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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"
|
|
"github.com/Pingu-Studio/MaidnCLI/internal/proxmox"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func TestRenderCiliumConfig(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "values.yaml")
|
|
if err := os.WriteFile(path, []byte("host: ${CILIUM_K8S_SERVICE_HOST}\ninterface: ${CILIUM_TRAFFIC_INTERFACE}\nstart: ${CILIUM_LB_START}\nend: ${CILIUM_LB_END}\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := config.Config{Talos: config.TalosConfig{KubeconfigEndpoint: "192.168.45.3"}, Cilium: config.CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"}}
|
|
if err := renderCiliumConfig(dir, cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(content), "${") || !strings.Contains(string(content), "192.168.45.30") {
|
|
t.Fatalf("Cilium configuration was not rendered: %s", content)
|
|
}
|
|
}
|
|
|
|
func TestInitializeOpenBaoConfiguresDeclaredSecretGrants(t *testing.T) {
|
|
originalInitialize, originalGrants := initializeOpenBao, configureOpenBaoSecretGrants
|
|
t.Cleanup(func() { initializeOpenBao, configureOpenBaoSecretGrants = originalInitialize, originalGrants })
|
|
initializeOpenBao = func(_ string, _ string, _ string, _ string, _ string, _ string) (map[string]map[string]string, error) {
|
|
return map[string]map[string]string{"cicd/forgejo": {"token": "redacted"}}, nil
|
|
}
|
|
called := false
|
|
configureOpenBaoSecretGrants = func(kubeconfig, identity, bundle string, grants []config.SecretGrant) error {
|
|
called = kubeconfig == filepath.Join("generated", "kubeconfig") && identity == "identity" && bundle == "bundle" && len(grants) == 1 && grants[0].Application == "orders-api"
|
|
return nil
|
|
}
|
|
r := Runner{Config: config.Config{SOPS: config.SOPSConfig{RecoveryIdentityPath: "identity", RecoveryBundlePath: "bundle", RecoveryRecipient: "recipient", AgeKeyPath: "age", OperationalSecretsPath: "secrets"}, SecretGrants: []config.SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "staging", Secrets: []string{"database"}}}}}
|
|
if _, err := r.initializeOpenBaoForCluster("generated"); err != nil || !called {
|
|
t.Fatalf("secret grant configuration was not invoked: called=%t err=%v", called, err)
|
|
}
|
|
}
|
|
|
|
func TestCiliumChartVersion(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "release.yaml")
|
|
if err := os.WriteFile(path, []byte("spec:\n chart:\n spec:\n version: test-version\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
version, err := ciliumChartVersion(path)
|
|
if err != nil || version != "test-version" {
|
|
t.Fatalf("ciliumChartVersion() = %q, %v", version, err)
|
|
}
|
|
}
|
|
|
|
func TestRenderPlatformDeliveryConfig(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "webhook.yaml")
|
|
if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\nowner: ${GIT_OWNER}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{ClusterDomain: "example.test", TektonCatalogRepo: "my-tekton-catalog"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}}
|
|
if err := renderPlatformDeliveryConfig(dir, cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(content), "${") || !strings.Contains(string(content), "host: tekton.example.test") || !strings.Contains(string(content), "owner: user-org") || !strings.Contains(string(content), "https://git.example.test/user-org/my-tekton-catalog.git") || !strings.Contains(string(content), "ref: release") {
|
|
t.Fatalf("platform delivery configuration was not rendered: %s", content)
|
|
}
|
|
}
|
|
|
|
func TestRemoveDuplicateAppDeliverySource(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte("resources:\n - easycsr-frontend-source.yaml\n - delivery-source.yaml\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "easycsr-frontend-source.yaml"), []byte("stale: source\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := removeDuplicateAppDeliverySource(dir, "easycsr-frontend"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "easycsr-frontend-source.yaml")); !os.IsNotExist(err) {
|
|
t.Fatalf("duplicate app source remains: %v", err)
|
|
}
|
|
kustomization, err := os.ReadFile(filepath.Join(dir, "kustomization.yaml"))
|
|
if err != nil || strings.Contains(string(kustomization), "easycsr-frontend-source.yaml") || !strings.Contains(string(kustomization), "delivery-source.yaml") {
|
|
t.Fatalf("Tekton Kustomization = %q, %v", kustomization, err)
|
|
}
|
|
}
|
|
|
|
func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(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{"name: build-layer", "maidn-node-static-image", "name: url\n value: \"https://git.example.test/apps/web-ui.git\"", "name: revision\n value: $(params.git-revision)", "name: image\n value: $(params.image)", "name: artifact\n workspace: artifact", "runAfter: [build-layer]", "maidn-preview-orphan-reconciler", "valid_pr_number()", "valid_commit()", "values: [promotion]", "values: [\"production\"]", "cmp -s \"$expected_marker\" \"$marker\"", "values: [closed]"} {
|
|
if !strings.Contains(string(content), expected) {
|
|
t.Fatalf("generated delivery does not contain %q", expected)
|
|
}
|
|
}
|
|
for _, unexpected := range []string{"maidn-git-clone", "maidn-node-static-build", "maidn-node-static-push", "runAfter: [clone]", "name: source", "workspace: source", "easycsr", "test-org", "git rm -r"} {
|
|
if strings.Contains(string(content), unexpected) {
|
|
t.Fatalf("generated delivery contains unexpected %q", unexpected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.T) {
|
|
cfg := config.Config{
|
|
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
|
|
Flux: config.FluxConfig{Branch: "main", 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", BuildConfiguration: "production"},
|
|
}
|
|
content, err := renderAppDelivery(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rendered := string(content)
|
|
for _, expected := range []string{
|
|
`app_dir="apps/staging/$APP_NAME"`, `root=apps/staging/kustomization.yaml`, `name: $APP_NAME`, `namespace: flux-system`,
|
|
`PROMOTION_BRANCH="maidn/promotion-$APP_NAME-$TAG"`, `git push origin "HEAD:$PROMOTION_BRANCH"`, `--post-file "$pr_body"`,
|
|
`head=$FORGEJO_OWNER%3A$PROMOTION_BRANCH`, `value: $(params.forgejo-base-url)`,
|
|
} {
|
|
if !strings.Contains(rendered, expected) {
|
|
t.Fatalf("generated delivery does not contain %q", expected)
|
|
}
|
|
}
|
|
start := strings.Index(rendered, `if [ "$ENVIRONMENT" = production ]; then`)
|
|
if start < 0 {
|
|
t.Fatal("generated delivery does not contain a production manifest path")
|
|
}
|
|
production := rendered[start:]
|
|
if end := strings.Index(production, "\n else\n"); end >= 0 {
|
|
production = production[:end]
|
|
}
|
|
if strings.Contains(production, `git push origin "$MANIFESTS_BRANCH"`) {
|
|
t.Fatal("production path pushes directly to manifests main")
|
|
}
|
|
}
|
|
|
|
func TestGenerateAppDeliveryRequiresCompleteConfig(t *testing.T) {
|
|
dir := t.TempDir()
|
|
err := GenerateAppDelivery(dir, config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}})
|
|
if err == nil || !strings.Contains(err.Error(), "delivery appName") {
|
|
t.Fatalf("GenerateAppDelivery() error = %v, want incomplete delivery error", err)
|
|
}
|
|
if _, statErr := os.Stat(filepath.Join(dir, ".tekton")); !os.IsNotExist(statErr) {
|
|
t.Fatal("GenerateAppDelivery() wrote delivery files before rejecting incomplete config")
|
|
}
|
|
}
|
|
|
|
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, "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)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "maidn-preview-delivery-config.yaml")); !os.IsNotExist(err) {
|
|
t.Fatalf("obsolete preview config remains: %v", err)
|
|
}
|
|
kustomization, err := os.ReadFile(filepath.Join(dir, "kustomization.yaml"))
|
|
if err != nil || strings.Contains(string(kustomization), "maidn-preview-delivery-config.yaml") || !strings.Contains(string(kustomization), "preview-delivery-config.yaml") {
|
|
t.Fatalf("preview config resource was not replaced: %q, %v", kustomization, 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 TestCopyAndRenderPlatformDeliveryBasesOverwritesExistingMigrationOutput(t *testing.T) {
|
|
templateDir := t.TempDir()
|
|
repoDir := t.TempDir()
|
|
files := []struct {
|
|
base, name, template, want string
|
|
}{
|
|
{"gateway", "route.yaml", "host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\n", "host: tekton.example.test\npath: /\n"},
|
|
{"tekton", "catalog-source.yaml", "url: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n", "url: https://git.example.test/user-org/my-tekton-catalog.git\nref: release\n"},
|
|
{"tekton-triggers", "trigger.yaml", "owner: ${FORGEJO_OWNER}\nhost: ${WEBHOOK_HOSTNAME}\n", "owner: user-org\nhost: tekton.example.test\n"},
|
|
}
|
|
for _, file := range files {
|
|
templatePath := filepath.Join(templateDir, "base", file.base, file.name)
|
|
outputPath := filepath.Join(repoDir, "base", file.base, file.name)
|
|
if err := os.MkdirAll(filepath.Dir(templatePath), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(templatePath, []byte(file.template), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(outputPath, []byte("stale: output\n"), 0644); err != nil {
|
|
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{ClusterDomain: "example.test", TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"},
|
|
Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"},
|
|
}
|
|
if err := copyAndRenderPlatformDeliveryBases(templateDir, repoDir, cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, file := range files {
|
|
content, err := os.ReadFile(filepath.Join(repoDir, "base", file.base, file.name))
|
|
if err != nil || string(content) != file.want {
|
|
t.Fatalf("%s was not copied and rendered: %q, %v", file.base, content, err)
|
|
}
|
|
}
|
|
content, err := os.ReadFile(customGatewayFile)
|
|
if err != nil || string(content) != "custom: route\n" {
|
|
t.Fatalf("custom gateway file was not preserved: %q, %v", content, err)
|
|
}
|
|
}
|
|
|
|
func TestCopyTemplateBaseComponentsRefreshesCNPGAndPreservesGeneratedSecrets(t *testing.T) {
|
|
templateDir := t.TempDir()
|
|
repoDir := t.TempDir()
|
|
for _, component := range templateBaseComponents {
|
|
if err := os.MkdirAll(filepath.Join(templateDir, "base", component), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
cnpgSource := filepath.Join(templateDir, "base", "cnpg", "kustomization.yaml")
|
|
cnpgDestination := filepath.Join(repoDir, "base", "cnpg", "kustomization.yaml")
|
|
if err := os.WriteFile(cnpgSource, []byte("resources:\n - operator-kustomization.yaml\n - infrastructure-kustomization.yaml\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(cnpgDestination), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(cnpgDestination, []byte("resources:\n - release.yaml\n - infrastructure-postgres.yaml\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cloudflareSource := filepath.Join(templateDir, "base", "cloudflare-tunnel", "deployment.yaml")
|
|
cloudflareDestination := filepath.Join(repoDir, "base", "cloudflare-tunnel", "deployment.yaml")
|
|
if err := os.WriteFile(cloudflareSource, []byte("command: cloudflared\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(cloudflareDestination), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(cloudflareDestination, []byte("command: stale\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
operatorSource := filepath.Join(templateDir, "base", "cnpg", "operator", "kustomization.yaml")
|
|
if err := os.MkdirAll(filepath.Dir(operatorSource), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(operatorSource, []byte("resources:\n - release.yaml\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, file := range []string{"democratic-csi/secret.sops.yaml", "openbao/unseal.sops.yaml"} {
|
|
if err := os.WriteFile(filepath.Join(templateDir, "base", file), []byte("sops: template\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
destination := filepath.Join(repoDir, "base", file)
|
|
if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(destination, []byte("sops: generated\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if err := copyTemplateBaseComponents(templateDir, repoDir, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, err := os.ReadFile(cnpgDestination)
|
|
if err != nil || string(content) != "resources:\n - operator-kustomization.yaml\n - infrastructure-kustomization.yaml\n" {
|
|
t.Fatalf("CNPG Kustomization was not refreshed: %q, %v", content, err)
|
|
}
|
|
content, err = os.ReadFile(filepath.Join(repoDir, "base", "cnpg", "operator", "kustomization.yaml"))
|
|
if err != nil || string(content) != "resources:\n - release.yaml\n" {
|
|
t.Fatalf("CNPG operator child was not copied: %q, %v", content, err)
|
|
}
|
|
content, err = os.ReadFile(cloudflareDestination)
|
|
if err != nil || string(content) != "command: cloudflared\n" {
|
|
t.Fatalf("Cloudflare Tunnel deployment was not refreshed: %q, %v", content, err)
|
|
}
|
|
for _, file := range []string{"democratic-csi/secret.sops.yaml", "openbao/unseal.sops.yaml"} {
|
|
content, err := os.ReadFile(filepath.Join(repoDir, "base", file))
|
|
if err != nil || string(content) != "sops: generated\n" {
|
|
t.Fatalf("generated SOPS file %q was overwritten: %q, %v", file, content, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlatformCopyPreservesExistingDeliveryBases(t *testing.T) {
|
|
templateDir := t.TempDir()
|
|
repoDir := t.TempDir()
|
|
for _, component := range templateBaseComponents {
|
|
if err := os.MkdirAll(filepath.Join(templateDir, "base", component), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
legacy := filepath.Join(repoDir, "base", "tekton", "legacy-delivery.yaml")
|
|
if err := os.MkdirAll(filepath.Dir(legacy), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(legacy, []byte("generated: delivery\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := copyTemplateBaseComponents(templateDir, repoDir, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, err := os.ReadFile(legacy)
|
|
if err != nil || string(content) != "generated: delivery\n" {
|
|
t.Fatalf("platform reconciliation removed existing delivery output: %q, %v", content, err)
|
|
}
|
|
}
|
|
|
|
func TestCopyAndRenderCiliumBasesRefreshesTemplateWithoutLeavingPlaceholders(t *testing.T) {
|
|
templateDir := t.TempDir()
|
|
repoDir := t.TempDir()
|
|
files := map[string]string{
|
|
"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",
|
|
}
|
|
for name, content := range files {
|
|
path := filepath.Join(templateDir, "base", name)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, base := range []string{"cilium", "cilium-config"} {
|
|
if err := os.MkdirAll(filepath.Join(repoDir, "base", base), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
stale := filepath.Join(repoDir, "base", "cilium-config", "load-balancer-pool.yaml")
|
|
if err := os.WriteFile(stale, []byte("start: stale\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
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-config/load-balancer-pool.yaml": "start: 192.168.45.19\nstop: 192.168.45.30\n",
|
|
"cilium-config/l2-policy.yaml": "interface: eth1\n",
|
|
} {
|
|
content, err := os.ReadFile(filepath.Join(repoDir, "base", name))
|
|
if err != nil || string(content) != want {
|
|
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) {
|
|
secret, err := renderDemocraticCSISecret(config.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"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(secret), "name: democratic-csi-secrets") || !strings.Contains(string(secret), "labels:\n reconcile.fluxcd.io/watch: Enabled") || !strings.Contains(string(secret), "dataset-parent-nfs: pool/kubernetes/nfs/v") {
|
|
t.Fatalf("Democratic CSI secret was not rendered: %s", secret)
|
|
}
|
|
}
|
|
|
|
func TestWriteDemocraticCSISecretEncryptsValues(t *testing.T) {
|
|
if _, err := exec.LookPath("age-keygen"); err != nil {
|
|
t.Skip("age-keygen is required for bootstrap encryption")
|
|
}
|
|
if _, err := exec.LookPath("sops"); err != nil {
|
|
t.Skip("sops is required for bootstrap encryption")
|
|
}
|
|
dir := t.TempDir()
|
|
identity := filepath.Join(dir, "age-key.txt")
|
|
if err := exec.Command("age-keygen", "-o", identity).Run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secretPath := filepath.Join(dir, "secret.sops.yaml")
|
|
csi := config.DemocraticCSIConfig{TrueNASAPIKey: "test-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"}
|
|
if err := writeDemocraticCSISecret(secretPath, csi, identity); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
encrypted, err := os.ReadFile(secretPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(encrypted), csi.TrueNASAPIKey) || !strings.Contains(string(encrypted), "sops:") {
|
|
t.Fatal("Democratic CSI secret was not SOPS encrypted")
|
|
}
|
|
if err := writeDemocraticCSISecret(secretPath, csi, identity); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
unchanged, err := os.ReadFile(secretPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(encrypted) != string(unchanged) {
|
|
t.Fatal("Democratic CSI secret was re-encrypted without a configuration change")
|
|
}
|
|
}
|
|
|
|
func TestWriteOpenBaoUnsealSecretRendersOnlyUnsealShares(t *testing.T) {
|
|
originalRead := readOpenBaoRecovery
|
|
originalWrite := writeGeneratedSOPS
|
|
t.Cleanup(func() {
|
|
readOpenBaoRecovery = originalRead
|
|
writeGeneratedSOPS = originalWrite
|
|
})
|
|
readOpenBaoRecovery = func(identityPath, bundlePath string) (openbao.RecoveryMaterial, error) {
|
|
if identityPath != "recovery-identity" || bundlePath != "recovery-bundle" {
|
|
t.Fatal("OpenBao recovery paths were not passed to the decryption boundary")
|
|
}
|
|
return openbao.RecoveryMaterial{RootToken: "test-root", UnsealThreshold: 2, UnsealKeysB64: []string{"test-share-1", "test-share-2", "test-share-3"}}, nil
|
|
}
|
|
writeGeneratedSOPS = func(path, ageKeyPath string, plaintext []byte) error {
|
|
if ageKeyPath != "flux-age-identity" {
|
|
t.Fatal("OpenBao unseal secret used the wrong Flux age identity")
|
|
}
|
|
var secret struct {
|
|
Metadata map[string]string `yaml:"metadata"`
|
|
StringData map[string]string `yaml:"stringData"`
|
|
}
|
|
if err := yaml.Unmarshal(plaintext, &secret); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if secret.Metadata["name"] != "openbao-unseal" || secret.Metadata["namespace"] != "openbao" || len(secret.StringData) != 3 {
|
|
t.Fatal("OpenBao unseal Secret was not rendered")
|
|
}
|
|
if _, ok := secret.StringData["root-token"]; ok {
|
|
t.Fatal("OpenBao unseal Secret contains a root token")
|
|
}
|
|
for _, name := range []string{"unseal-1", "unseal-2", "unseal-3"} {
|
|
if _, ok := secret.StringData[name]; !ok {
|
|
t.Fatalf("OpenBao unseal Secret is missing %s", name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := writeOpenBaoUnsealSecret(filepath.Join(t.TempDir(), "unseal.sops.yaml"), "recovery-identity", "recovery-bundle", "flux-age-identity"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestWriteOpenBaoUnsealSecretEncryptsSharesWithoutRootToken(t *testing.T) {
|
|
if _, err := exec.LookPath("age-keygen"); err != nil {
|
|
t.Skip("age-keygen is required for bootstrap encryption")
|
|
}
|
|
if _, err := exec.LookPath("sops"); err != nil {
|
|
t.Skip("sops is required for bootstrap encryption")
|
|
}
|
|
originalRead := readOpenBaoRecovery
|
|
t.Cleanup(func() { readOpenBaoRecovery = originalRead })
|
|
readOpenBaoRecovery = func(string, string) (openbao.RecoveryMaterial, error) {
|
|
return openbao.RecoveryMaterial{RootToken: "test-root", UnsealThreshold: 2, UnsealKeysB64: []string{"test-share-1", "test-share-2", "test-share-3"}}, nil
|
|
}
|
|
dir := t.TempDir()
|
|
identity := filepath.Join(dir, "age-key.txt")
|
|
if err := exec.Command("age-keygen", "-o", identity).Run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(dir, "unseal.sops.yaml")
|
|
if err := writeOpenBaoUnsealSecret(path, "recovery-identity", "recovery-bundle", identity); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
encrypted, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(encrypted), "sops:") {
|
|
t.Fatal("OpenBao unseal Secret was not SOPS encrypted")
|
|
}
|
|
if strings.Contains(string(encrypted), "root-token") {
|
|
t.Fatal("SOPS-encrypted OpenBao unseal Secret contains a root token")
|
|
}
|
|
}
|
|
|
|
func TestWriteOpenBaoUnsealSecretIsIdempotent(t *testing.T) {
|
|
originalRead := readOpenBaoRecovery
|
|
originalDecrypt := decryptGeneratedSOPS
|
|
originalWrite := writeGeneratedSOPS
|
|
t.Cleanup(func() {
|
|
readOpenBaoRecovery = originalRead
|
|
decryptGeneratedSOPS = originalDecrypt
|
|
writeGeneratedSOPS = originalWrite
|
|
})
|
|
material := openbao.RecoveryMaterial{RootToken: "test-root", UnsealThreshold: 2, UnsealKeysB64: []string{"test-share-1", "test-share-2"}}
|
|
plaintext, err := renderOpenBaoUnsealSecret(material)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
readOpenBaoRecovery = func(string, string) (openbao.RecoveryMaterial, error) { return material, nil }
|
|
decryptGeneratedSOPS = func(string, string) ([]byte, error) { return plaintext, nil }
|
|
writes := 0
|
|
writeGeneratedSOPS = func(string, string, []byte) error {
|
|
writes++
|
|
return nil
|
|
}
|
|
path := filepath.Join(t.TempDir(), "unseal.sops.yaml")
|
|
if err := os.WriteFile(path, []byte("sops: {}\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeOpenBaoUnsealSecret(path, "recovery-identity", "recovery-bundle", "flux-age-identity"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if writes != 0 {
|
|
t.Fatal("matching OpenBao unseal Secret was re-encrypted")
|
|
}
|
|
}
|
|
|
|
func TestWriteOpenBaoUnsealSecretRejectsUnreadableExistingSecret(t *testing.T) {
|
|
originalRead := readOpenBaoRecovery
|
|
originalDecrypt := decryptGeneratedSOPS
|
|
originalWrite := writeGeneratedSOPS
|
|
t.Cleanup(func() {
|
|
readOpenBaoRecovery = originalRead
|
|
decryptGeneratedSOPS = originalDecrypt
|
|
writeGeneratedSOPS = originalWrite
|
|
})
|
|
readOpenBaoRecovery = func(string, string) (openbao.RecoveryMaterial, error) {
|
|
return openbao.RecoveryMaterial{RootToken: "test-root", UnsealThreshold: 1, UnsealKeysB64: []string{"test-share"}}, nil
|
|
}
|
|
decryptGeneratedSOPS = func(string, string) ([]byte, error) { return nil, errors.New("unavailable") }
|
|
writes := 0
|
|
writeGeneratedSOPS = func(string, string, []byte) error {
|
|
writes++
|
|
return nil
|
|
}
|
|
path := filepath.Join(t.TempDir(), "unseal.sops.yaml")
|
|
if err := os.WriteFile(path, []byte("sops: {}\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeOpenBaoUnsealSecret(path, "recovery-identity", "recovery-bundle", "flux-age-identity"); err == nil || writes != 0 {
|
|
t.Fatal("unreadable OpenBao unseal Secret was overwritten")
|
|
}
|
|
}
|
|
|
|
func TestEnsureOpenBaoUnsealKustomizationIsIdempotent(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "kustomization.yaml")
|
|
if err := os.WriteFile(path, []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - namespace.yaml\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureOpenBaoUnsealKustomization(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureOpenBaoUnsealKustomization(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := os.ReadFile(path)
|
|
if err != nil || !strings.Contains(string(first), " - unseal.sops.yaml\n") || string(first) != string(second) {
|
|
t.Fatal("OpenBao Kustomization was not updated idempotently")
|
|
}
|
|
}
|
|
|
|
func TestNewWebhookAuthorization(t *testing.T) {
|
|
authorization, err := NewWebhookAuthorization()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(authorization, "Bearer ") || len(authorization) <= len("Bearer ") {
|
|
t.Fatalf("invalid webhook authorization")
|
|
}
|
|
}
|
|
|
|
func TestForgejoRegistryDockerConfig(t *testing.T) {
|
|
dockerConfig, err := ForgejoRegistryDockerConfig("registry.example.test/team/app", "registry-user", "registry-token")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var document struct {
|
|
Auths map[string]struct {
|
|
Auth string `json:"auth"`
|
|
} `json:"auths"`
|
|
}
|
|
if err := json.Unmarshal([]byte(dockerConfig), &document); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if document.Auths["registry.example.test"].Auth != base64.StdEncoding.EncodeToString([]byte("registry-user:registry-token")) {
|
|
t.Fatal("Forgejo registry Docker config has the wrong credentials")
|
|
}
|
|
}
|
|
|
|
func TestUpsertOperationalSecretPreservesExistingSecrets(t *testing.T) {
|
|
if _, err := exec.LookPath("age-keygen"); err != nil {
|
|
t.Skip("age-keygen is required for bootstrap encryption")
|
|
}
|
|
if _, err := exec.LookPath("sops"); err != nil {
|
|
t.Skip("sops is required for bootstrap encryption")
|
|
}
|
|
dir := t.TempDir()
|
|
identity := filepath.Join(dir, "age-key.txt")
|
|
if err := exec.Command("age-keygen", "-o", identity).Run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(dir, "operational-secrets.sops.yaml")
|
|
if err := WriteOperationalSecrets(path, identity, map[string]map[string]string{
|
|
"cicd/forgejo": {"token": "existing-token"},
|
|
"platform/cloudflare": {"api-token": "existing-api-token"},
|
|
"cicd/forgejo-registry": {"dockerconfigjson": "old-config"},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := UpsertOperationalSecret(path, identity, "cicd/forgejo-registry", "dockerconfigjson", "new-config"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
encrypted, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(encrypted), "existing-token") || strings.Contains(string(encrypted), "new-config") || !strings.Contains(string(encrypted), "sops:") {
|
|
t.Fatal("operational secret upsert was not SOPS encrypted")
|
|
}
|
|
secrets, err := ReadOperationalSecrets(path, identity)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if secrets["cicd/forgejo"]["token"] != "existing-token" || secrets["platform/cloudflare"]["api-token"] != "existing-api-token" || secrets["cicd/forgejo-registry"]["dockerconfigjson"] != "new-config" {
|
|
t.Fatal("operational secret upsert did not preserve unrelated secrets")
|
|
}
|
|
}
|
|
|
|
func TestInitializeOpenBaoUsesGeneratedKubeconfig(t *testing.T) {
|
|
original := initializeOpenBao
|
|
defer func() { initializeOpenBao = original }()
|
|
var kubeconfig string
|
|
initializeOpenBao = func(path, recipient, identityPath, bundlePath, ageKeyPath, operationalSecretsPath string) (map[string]map[string]string, error) {
|
|
kubeconfig = path
|
|
if recipient != "recipient" || identityPath != "identity" || bundlePath != "bundle" || ageKeyPath != "age" || operationalSecretsPath != "secrets" {
|
|
t.Fatal("InitializeOpenBao passed incorrect configured paths")
|
|
}
|
|
return nil, nil
|
|
}
|
|
cfg := config.Config{
|
|
Git: config.GitConfig{CloneParent: "checkout"},
|
|
Talos: config.TalosConfig{RepoDirName: "talos", GeneratedDir: "generated"},
|
|
SOPS: config.SOPSConfig{RecoveryRecipient: "recipient", RecoveryIdentityPath: "identity", RecoveryBundlePath: "bundle", AgeKeyPath: "age", OperationalSecretsPath: "secrets"},
|
|
}
|
|
if err := InitializeOpenBao(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if kubeconfig != filepath.Join("checkout", "talos", "generated", "kubeconfig") {
|
|
t.Fatal("InitializeOpenBao did not use the configured generated kubeconfig")
|
|
}
|
|
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
|
|
return nil, errors.New("unavailable")
|
|
}
|
|
if err := InitializeOpenBao(cfg); err == nil {
|
|
t.Fatal("InitializeOpenBao accepted a seeding error")
|
|
}
|
|
}
|
|
|
|
func TestCopyDirSkipsGitDirectory(t *testing.T) {
|
|
source := t.TempDir()
|
|
destination := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(source, ".git"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(source, ".git", "config"), []byte("private"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(source, "README.md"), []byte("template"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := copyDir(source, destination, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(destination, ".git")); !os.IsNotExist(err) {
|
|
t.Fatal("copied template must not contain its Git metadata")
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(destination, "README.md"))
|
|
if err != nil || string(content) != "template" {
|
|
t.Fatalf("template content was not copied: %q, %v", content, err)
|
|
}
|
|
}
|
|
|
|
func TestCopyDirPreservesExistingDeploymentStateWithoutOverwrite(t *testing.T) {
|
|
source := t.TempDir()
|
|
destination := t.TempDir()
|
|
for path, content := range map[string]string{
|
|
"apps/production/app.yaml": "template: current\n",
|
|
"apps/staging/app.yaml": "template: new\n",
|
|
} {
|
|
file := filepath.Join(source, path)
|
|
if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(file, []byte(content), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
existing := filepath.Join(destination, "apps", "production", "app.yaml")
|
|
if err := os.MkdirAll(filepath.Dir(existing), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(existing, []byte("deployment: existing\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := copyDir(source, destination, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, err := os.ReadFile(existing)
|
|
if err != nil || string(content) != "deployment: existing\n" {
|
|
t.Fatalf("existing deployment state was overwritten: %q, %v", content, err)
|
|
}
|
|
content, err = os.ReadFile(filepath.Join(destination, "apps", "staging", "app.yaml"))
|
|
if err != nil || string(content) != "template: new\n" {
|
|
t.Fatalf("new template file was not copied: %q, %v", content, err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureManifestsKustomizations(t *testing.T) {
|
|
dir := t.TempDir()
|
|
for _, environment := range []string{"previews", "staging", "production"} {
|
|
if err := os.MkdirAll(filepath.Join(dir, "apps", environment), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "apps", "staging", "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureManifestsKustomizations(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(dir, "apps", "previews", "kustomization.yaml"))
|
|
if err != nil || !strings.Contains(string(content), "resources:") {
|
|
t.Fatalf("preview Kustomization was not created: %q, %v", content, err)
|
|
}
|
|
content, err = os.ReadFile(filepath.Join(dir, "apps", "staging", "kustomization.yaml"))
|
|
if err != nil || !strings.Contains(string(content), "resources:") {
|
|
t.Fatalf("staging Kustomization was not repaired: %q, %v", content, err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureClusterKustomizationsUsesTemplateExternalSecretsResource(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "kustomization.yaml")
|
|
if err := os.WriteFile(path, []byte("resources:\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureClusterKustomizations(dir, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, err := os.ReadFile(path)
|
|
if err != nil || !strings.Contains(string(content), "snapshot-crds-kustomization.yaml") || !strings.Contains(string(content), "democratic-csi-kustomization.yaml") || !strings.Contains(string(content), "openbao-kustomization.yaml") || !strings.Contains(string(content), "external-secrets-kustomization.yaml") || !strings.Contains(string(content), "cnpg-kustomization.yaml") || !strings.Contains(string(content), "cloudflare-tunnel-kustomization.yaml") || !strings.Contains(string(content), "tekton-kustomization.yaml") || strings.Contains(string(content), "external-secrets-config-kustomization.yaml") {
|
|
t.Fatalf("cluster Kustomization was not updated: %q, %v", content, err)
|
|
}
|
|
}
|
|
|
|
func TestCopyClusterTemplateCopiesCurrentClusterContract(t *testing.T) {
|
|
templateDir := t.TempDir()
|
|
clusterTemplate := filepath.Join(templateDir, "clusters", "template")
|
|
resources := []string{
|
|
"snapshot-crds-kustomization.yaml", "democratic-csi-kustomization.yaml", "cert-manager-kustomization.yaml", "cluster-issuers-kustomization.yaml", "gateway-api-kustomization.yaml", "cilium-kustomization.yaml", "cilium-config-kustomization.yaml", "openbao-kustomization.yaml", "external-secrets-kustomization.yaml", "external-secrets-config-kustomization.yaml", "cnpg-kustomization.yaml", "gateway-kustomization.yaml", "external-dns-kustomization.yaml", "cloudflare-tunnel-kustomization.yaml", "monitoring-kustomization.yaml", "tekton-kustomization.yaml", "tekton-triggers-kustomization.yaml",
|
|
}
|
|
if err := os.MkdirAll(clusterTemplate, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var content strings.Builder
|
|
content.WriteString("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n")
|
|
for _, resource := range resources {
|
|
content.WriteString(" - " + resource + "\n")
|
|
if err := os.WriteFile(filepath.Join(clusterTemplate, resource), []byte("apiVersion: v1\nkind: ConfigMap\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(clusterTemplate, "kustomization.yaml"), []byte(content.String()), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
clusterDir := filepath.Join(t.TempDir(), "clusters", "maidn-cd-0")
|
|
if err := copyClusterTemplate(clusterTemplate, clusterDir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(clusterDir, "cicd-manifests-repo.yaml"), []byte("apiVersion: v1\nkind: ConfigMap\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureClusterKustomizations(clusterDir, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first, err := os.ReadFile(filepath.Join(clusterDir, "kustomization.yaml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureClusterKustomizations(clusterDir, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := os.ReadFile(filepath.Join(clusterDir, "kustomization.yaml"))
|
|
if err != nil || string(first) != string(second) {
|
|
t.Fatalf("cluster Kustomization was not generated idempotently: %q, %v", second, err)
|
|
}
|
|
for _, resource := range append(resources, "cicd-manifests-repo.yaml") {
|
|
if !strings.Contains(string(second), " - "+resource+"\n") {
|
|
t.Fatalf("cluster Kustomization omitted template resource %q: %q", resource, second)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWebhookTargetTimeoutExceedsExternalSecretRefreshInterval(t *testing.T) {
|
|
if webhookTargetTimeout <= time.Hour {
|
|
t.Fatal("webhook target timeout must exceed the one-hour ExternalSecret refresh interval")
|
|
}
|
|
}
|
|
|
|
func runnerTestConfig(workspace, ageKeyPath string) config.Config {
|
|
return config.Config{
|
|
ClusterID: "test-cluster",
|
|
WorkspaceDir: workspace,
|
|
Git: config.GitConfig{Provider: "forgejo", BaseURL: "https://git.example.test", Username: "bot", Token: "token", Owner: "test-org", CloneParent: filepath.Join(workspace, "checkouts")},
|
|
Flux: config.FluxConfig{RepoName: "cluster", Branch: "main", ClusterPath: "./clusters/test", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
|
|
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", ProductionBranch: "production", ImageRepository: "registry.example.test/test/app", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"},
|
|
SOPS: config.SOPSConfig{AgeKeyPath: ageKeyPath},
|
|
Talos: config.TalosConfig{
|
|
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
|
|
Proxmox: config.TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
|
|
Cluster: config.TalosClusterConfig{Name: "test-cluster", Domain: "example.test"},
|
|
Image: config.TalosImageConfig{TalosVersion: "v1.13.6", KubernetesVersion: "v1.33.4", SchematicID: "abcdefghijkl"},
|
|
Nodes: []config.TalosNode{{Name: "cp-01", VMID: 100, Role: "controlplane", Networks: []config.TalosNetwork{{IP: "192.168.45.3", CIDR: "192.168.45.0/28", Gateway: "192.168.45.1", VLANID: 45}, {IP: "192.168.45.18", CIDR: "192.168.45.16/28", VLANID: 451}}}},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestRunnerAutoBootstrapFluxIgnoresPartialLegacyDelivery(t *testing.T) {
|
|
originalPreflight := preflight
|
|
t.Cleanup(func() { preflight = originalPreflight })
|
|
preflight = func(config.Config) error { return errors.New("reached preflight") }
|
|
|
|
cfg := runnerTestConfig(t.TempDir(), "")
|
|
cfg.Delivery = config.DeliveryConfig{AppName: "legacy-app", AppRepoURL: "https://git.example.test/test-org/legacy-app.git", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/legacy-app"}
|
|
cfg.Talos.AutoBootstrapFlux = true
|
|
err := (Runner{Config: cfg}).Run()
|
|
if err == nil || !strings.Contains(err.Error(), "reached preflight") {
|
|
t.Fatalf("platform reconcile validated partial delivery before preflight: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunnerEnableDeliveryAppliesDefaults(t *testing.T) {
|
|
originalPreflight := preflight
|
|
t.Cleanup(func() { preflight = originalPreflight })
|
|
preflight = func(cfg config.Config) error {
|
|
if !cfg.Delivery.Configured() {
|
|
t.Fatal("delivery defaults were not applied")
|
|
}
|
|
return errors.New("reached preflight")
|
|
}
|
|
|
|
cfg := runnerTestConfig(t.TempDir(), "")
|
|
cfg.Delivery.AppRepoRef = ""
|
|
cfg.Delivery.BuildOutputDirectory = ""
|
|
cfg.Delivery.BuildConfiguration = ""
|
|
cfg.Delivery.WebhookHostname = ""
|
|
cfg.Delivery.WebhookPath = ""
|
|
err := (Runner{Config: cfg, EnableDelivery: true}).Run()
|
|
if err == nil || !strings.Contains(err.Error(), "reached preflight") {
|
|
t.Fatalf("enable delivery did not resolve defaults before preflight: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunnerAutoMergesBootstrapMigration(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodGet && (request.URL.Path == "/api/v1/repos/test-org/manifests/pulls" || request.URL.Path == "/api/v1/repos/test-org/cluster/pulls") {
|
|
if request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/bootstrap-test-cluster" {
|
|
t.Fatalf("unexpected migration lookup query: %q", request.URL.RawQuery)
|
|
}
|
|
_ = json.NewEncoder(writer).Encode([]struct {
|
|
Number int `json:"number"`
|
|
}{{Number: 4}})
|
|
return
|
|
}
|
|
if request.Method == http.MethodPost && (request.URL.Path == "/api/v1/repos/test-org/manifests/pulls/4/merge" || request.URL.Path == "/api/v1/repos/test-org/cluster/pulls/4/merge") {
|
|
var body struct {
|
|
Do string `json:"Do"`
|
|
}
|
|
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Do != "merge" {
|
|
t.Fatalf("unexpected migration merge request: %#v, %v", body, err)
|
|
}
|
|
writer.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
t.Fatalf("unexpected Forgejo request %s %s", request.Method, request.URL.Path)
|
|
}))
|
|
defer server.Close()
|
|
|
|
manager := forgejo.NewRepoManager(server.URL, "test-token", "test-org", "bot", "manifests", "cluster", "main", "maidn/bootstrap-test-cluster")
|
|
manager.HTTPClient = server.Client()
|
|
manager.MigrationRepositories = []string{"manifests", "cluster"}
|
|
if err := (Runner{Config: config.Config{Flux: config.FluxConfig{RepoName: "cluster"}}, AutoMergeBootstrapMigration: true}).reconcileBootstrapMigration(manager); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestRunnerRetainsMigrationApprovalGate(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
t.Fatal("normal bootstrap must not call Forgejo to merge a migration")
|
|
}))
|
|
defer server.Close()
|
|
|
|
manager := forgejo.NewRepoManager(server.URL, "test-token", "test-org", "bot", "manifests", "cluster", "main", "maidn/bootstrap-test-cluster")
|
|
manager.HTTPClient = server.Client()
|
|
manager.MigrationRepositories = []string{"manifests"}
|
|
if err := (Runner{Config: config.Config{Flux: config.FluxConfig{RepoName: "cluster"}}}).reconcileBootstrapMigration(manager); err == nil || !strings.Contains(err.Error(), "merge and rerun bootstrap") {
|
|
t.Fatalf("normal bootstrap migration gate = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
|
|
originalPreflight := preflight
|
|
originalGit := runGit
|
|
originalInitialize := initializeOpenBao
|
|
originalCommand := runWebhookCommand
|
|
originalWebhook := ensureForgejoWebhook
|
|
t.Cleanup(func() {
|
|
preflight = originalPreflight
|
|
runGit = originalGit
|
|
initializeOpenBao = originalInitialize
|
|
runWebhookCommand = originalCommand
|
|
ensureForgejoWebhook = originalWebhook
|
|
})
|
|
|
|
workspace := t.TempDir()
|
|
ageKeyPath := filepath.Join(workspace, "age-key.txt")
|
|
if err := os.WriteFile(ageKeyPath, nil, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
preflight = func(config.Config) error { return nil }
|
|
runGit = func(string, ...string) ([]byte, error) {
|
|
t.Fatal("webhook-only reconciliation must not access template repositories")
|
|
return nil, nil
|
|
}
|
|
authorization := "Bearer test-webhook-authorization"
|
|
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
|
|
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
|
|
}
|
|
runWebhookCommand = func(_ string, _ string, args ...string) ([]byte, error) {
|
|
if strings.Contains(strings.Join(args, " "), "secret/forgejo-webhook") {
|
|
return []byte(base64.StdEncoding.EncodeToString([]byte(authorization))), nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
ensureForgejoWebhook = func(_ config.Config, _, _, observedAuthorization string) error {
|
|
if observedAuthorization != authorization {
|
|
t.Fatal("webhook reconciliation used the wrong authorization")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
cfg := runnerTestConfig(workspace, ageKeyPath)
|
|
partial := cfg
|
|
partial.Delivery.ImageRepository = ""
|
|
preflight = func(config.Config) error {
|
|
t.Fatal("webhook registration accepted incomplete delivery config")
|
|
return nil
|
|
}
|
|
if err := (Runner{Config: partial, RegisterWebhook: true}).Run(); err == nil || !strings.Contains(err.Error(), "imageRepository") {
|
|
t.Fatalf("webhook registration error = %v, want incomplete delivery error", err)
|
|
}
|
|
preflight = func(config.Config) error { return nil }
|
|
if err := (Runner{Config: cfg, RegisterWebhook: true}).Run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(workspace, "maidn-template-revisions.yaml")); !os.IsNotExist(err) {
|
|
t.Fatal("webhook-only reconciliation created a template revision lock")
|
|
}
|
|
}
|
|
|
|
func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) {
|
|
originalInitialize := initializeOpenBao
|
|
originalCommand := runWebhookCommand
|
|
originalWebhook := ensureForgejoWebhook
|
|
originalTimeout := webhookTargetTimeout
|
|
originalInterval := webhookTargetPollInterval
|
|
t.Cleanup(func() {
|
|
initializeOpenBao = originalInitialize
|
|
runWebhookCommand = originalCommand
|
|
ensureForgejoWebhook = originalWebhook
|
|
webhookTargetTimeout = originalTimeout
|
|
webhookTargetPollInterval = originalInterval
|
|
})
|
|
|
|
authorization := "Bearer test-webhook-authorization"
|
|
staleTarget := base64.StdEncoding.EncodeToString([]byte("Bearer stale-webhook-authorization"))
|
|
refreshedTarget := base64.StdEncoding.EncodeToString([]byte(authorization))
|
|
targetChecks := 0
|
|
refreshedObserved := false
|
|
runWebhookCommand = func(_ string, name string, args ...string) ([]byte, error) {
|
|
if name != "kubectl" || strings.Contains(strings.Join(args, " "), authorization) {
|
|
t.Fatal("webhook target probe used an unexpected command")
|
|
}
|
|
if strings.Contains(strings.Join(args, " "), "secret/forgejo-webhook") {
|
|
targetChecks++
|
|
if targetChecks == 1 {
|
|
return []byte(staleTarget), nil
|
|
}
|
|
refreshedObserved = true
|
|
return []byte(refreshedTarget), nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
webhookTargetPollInterval = 0
|
|
|
|
patches := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
if !refreshedObserved {
|
|
t.Error("Forgejo was called before the refreshed webhook Secret was observed")
|
|
writer.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
switch request.Method {
|
|
case http.MethodGet:
|
|
_ = json.NewEncoder(writer).Encode([]map[string]any{{"id": 7, "url": "https://tekton.example.test/"}})
|
|
case http.MethodPatch:
|
|
var body struct {
|
|
AuthorizationHeader string `json:"authorization_header"`
|
|
}
|
|
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.AuthorizationHeader != authorization {
|
|
t.Error("Forgejo webhook update did not use the observed authorization")
|
|
writer.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
patches++
|
|
writer.WriteHeader(http.StatusOK)
|
|
default:
|
|
t.Error("unexpected Forgejo request")
|
|
writer.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
ensureForgejoWebhook = func(_ config.Config, repo, webhookURL, authorization string) error {
|
|
manager := forgejo.NewRepoManager(server.URL, "test-token", "owner", "user", "manifests", "flux", "main", "migration")
|
|
manager.HTTPClient = server.Client()
|
|
return manager.EnsureWebhook(repo, webhookURL, authorization)
|
|
}
|
|
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
|
|
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
|
|
}
|
|
|
|
cfg := config.Config{Delivery: config.DeliveryConfig{AppName: "app", WebhookHostname: "tekton.example.test", WebhookPath: "/"}}
|
|
if err := (Runner{Config: cfg}).reconcileWebhook(t.TempDir()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if targetChecks != 2 || patches != 1 {
|
|
t.Fatal("Forgejo webhook was not updated after the target Secret refreshed")
|
|
}
|
|
}
|
|
|
|
func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) {
|
|
originalInitialize := initializeOpenBao
|
|
originalCommand := runWebhookCommand
|
|
originalWebhook := ensureForgejoWebhook
|
|
originalTimeout := webhookTargetTimeout
|
|
originalInterval := webhookTargetPollInterval
|
|
t.Cleanup(func() {
|
|
initializeOpenBao = originalInitialize
|
|
runWebhookCommand = originalCommand
|
|
ensureForgejoWebhook = originalWebhook
|
|
webhookTargetTimeout = originalTimeout
|
|
webhookTargetPollInterval = originalInterval
|
|
})
|
|
|
|
authorization := "Bearer test-webhook-authorization"
|
|
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
|
|
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
|
|
}
|
|
runWebhookCommand = func(_ string, _ string, _ ...string) ([]byte, error) {
|
|
return []byte(base64.StdEncoding.EncodeToString([]byte("Bearer stale-webhook-authorization"))), nil
|
|
}
|
|
webhookTargetTimeout = -time.Nanosecond
|
|
webhookTargetPollInterval = 0
|
|
webhookUpdated := false
|
|
ensureForgejoWebhook = func(config.Config, string, string, string) error {
|
|
webhookUpdated = true
|
|
return nil
|
|
}
|
|
|
|
err := (Runner{Config: config.Config{Delivery: config.DeliveryConfig{AppName: "app"}}}).reconcileWebhook(t.TempDir())
|
|
if err == nil || !strings.Contains(err.Error(), "Forgejo webhook was not updated") || !strings.Contains(err.Error(), "--register-webhook") {
|
|
t.Fatal("webhook refresh timeout did not return a safe rerun error")
|
|
}
|
|
if webhookUpdated {
|
|
t.Fatal("Forgejo webhook update was attempted before the target Secret refreshed")
|
|
}
|
|
}
|
|
|
|
func TestRenderTerraformTFVarsIsStableAndRedactsToken(t *testing.T) {
|
|
cfg := config.Config{
|
|
ClusterID: "test",
|
|
Talos: config.TalosConfig{
|
|
Proxmox: config.TalosProxmoxConfig{APIURL: "https://proxmox.test", APITokenID: "id", APITokenSecret: "secret", NodeInterfaces: map[string]string{"b": "eno2", "a": "eno1"}},
|
|
Cluster: config.TalosClusterConfig{Name: "test", Domain: "test", DiskStorage: "local", AdditionalStorage: "local"},
|
|
Image: config.TalosImageConfig{TalosVersion: "v1.13.6", KubernetesVersion: "v1.33.4", SchematicID: "abcdefghijkl", Architecture: "amd64"},
|
|
},
|
|
}
|
|
first := renderTerraformTFVars(cfg)
|
|
second := renderTerraformTFVars(cfg)
|
|
if first != second || strings.Contains(first, "secret") || strings.Index(first, `"a"`) > strings.Index(first, `"b"`) {
|
|
t.Fatalf("Terraform rendering is not deterministic or redacted: %s", first)
|
|
}
|
|
}
|
|
|
|
func TestTerraformPlanPathIsAbsolute(t *testing.T) {
|
|
path, err := terraformPlanPath(filepath.Join("maidn-workspace", "terraform"), "cluster")
|
|
if err != nil || !filepath.IsAbs(path) {
|
|
t.Fatalf("Terraform plan path is not absolute: %q, %v", path, err)
|
|
}
|
|
}
|
|
|
|
func TestTerraformReconcileTargetsStageImageTalconfigAndManagedBridgesOnly(t *testing.T) {
|
|
if targets := strings.Join(terraformReconcileTargets(config.Config{}), " "); targets != "-target=proxmox_virtual_environment_download_file.talos_iso -target=local_file.talconfig" {
|
|
t.Fatalf("default reconcile targets = %q", targets)
|
|
}
|
|
cfg := config.Config{Talos: config.TalosConfig{Cluster: config.TalosClusterConfig{ManageNetworkBridges: true}}}
|
|
targets := strings.Join(terraformReconcileTargets(cfg), " ")
|
|
if !strings.Contains(targets, "-target=proxmox_virtual_environment_download_file.talos_iso") || !strings.Contains(targets, "-target=local_file.talconfig") || !strings.Contains(targets, "-target=proxmox_virtual_environment_network_linux_bridge.cluster_bridge") || strings.Contains(targets, "proxmox_virtual_environment_vm.vm") {
|
|
t.Fatalf("managed bridge reconcile targets = %q", targets)
|
|
}
|
|
}
|
|
|
|
func TestTerraformStateResourcesAllowsANSINoStateFile(t *testing.T) {
|
|
original := runTerraformStateList
|
|
t.Cleanup(func() { runTerraformStateList = original })
|
|
runTerraformStateList = func(string, []string) ([]byte, []byte, error) {
|
|
return nil, []byte("\x1b[31mError:\x1b[0m \x1b[31mNO STATE\x1b[0m FILE was found!\n"), errors.New("exit status 1")
|
|
}
|
|
|
|
resources, err := listTerraformStateResources("terraform", nil)
|
|
if err != nil || len(resources) != 0 {
|
|
t.Fatalf("no-state Terraform result = %q, %v; want empty resources and nil error", resources, err)
|
|
}
|
|
}
|
|
|
|
func TestTerraformStateResourcesRejectsUnexpectedStateError(t *testing.T) {
|
|
original := runTerraformStateList
|
|
t.Cleanup(func() { runTerraformStateList = original })
|
|
runTerraformStateList = func(string, []string) ([]byte, []byte, error) {
|
|
return nil, []byte("Error: failed to load backend\n"), errors.New("exit status 1")
|
|
}
|
|
|
|
if _, err := listTerraformStateResources("terraform", nil); err == nil || !strings.Contains(err.Error(), "list Terraform state") {
|
|
t.Fatalf("unexpected Terraform state error was accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
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 TestReconcileTerraformImportsConfiguredTalosVMsBeforePlan(t *testing.T) {
|
|
originalVerify := verifyTalosVMs
|
|
originalStateList := runTerraformStateList
|
|
originalRun := runTerraform
|
|
t.Cleanup(func() {
|
|
verifyTalosVMs = originalVerify
|
|
runTerraformStateList = originalStateList
|
|
runTerraform = originalRun
|
|
})
|
|
|
|
cfg := config.Config{ClusterID: "test-cluster", Talos: config.TalosConfig{
|
|
Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100, Role: "controlplane"}},
|
|
}}
|
|
importedAddress := talosVMImports(cfg)[0].Address
|
|
var events, state []string
|
|
verifyTalosVMs = func(got config.Config) error {
|
|
if len(got.Talos.Nodes) != 1 || got.Talos.Nodes[0].Name != "cp-01" || got.Talos.Nodes[0].ProxmoxNode != "pve" || got.Talos.Nodes[0].VMID != 100 {
|
|
t.Fatal("Talos VM identity verification used the wrong config")
|
|
}
|
|
events = append(events, "verify")
|
|
return nil
|
|
}
|
|
runTerraformStateList = func(string, []string) ([]byte, []byte, error) {
|
|
return []byte(strings.Join(state, "\n")), nil, nil
|
|
}
|
|
runTerraform = func(_ string, _ []string, name string, args ...string) error {
|
|
if name != "terraform" {
|
|
t.Fatalf("unexpected command %q", name)
|
|
}
|
|
switch args[0] {
|
|
case "init":
|
|
events = append(events, "init")
|
|
case "import":
|
|
if len(args) != 4 || args[2] != importedAddress || args[3] != "pve/100" {
|
|
t.Fatalf("unexpected Talos VM import: %q", args)
|
|
}
|
|
events = append(events, "import")
|
|
state = append(state, importedAddress)
|
|
case "plan":
|
|
if len(state) != 1 || state[0] != importedAddress {
|
|
return errors.New("VM create collision")
|
|
}
|
|
if strings.Contains(strings.Join(args, " "), "proxmox_virtual_environment_vm.vm") || !strings.Contains(strings.Join(args, " "), "-target=proxmox_virtual_environment_download_file.talos_iso") || !strings.Contains(strings.Join(args, " "), "-target=local_file.talconfig") {
|
|
t.Fatalf("reconcile plan did not exclude the Talos VM: %q", args)
|
|
}
|
|
events = append(events, "plan")
|
|
case "apply":
|
|
if strings.Contains(strings.Join(args, " "), "proxmox_virtual_environment_vm.vm") {
|
|
t.Fatalf("reconcile apply did not exclude the Talos VM: %q", args)
|
|
}
|
|
events = append(events, "apply")
|
|
default:
|
|
t.Fatalf("unexpected Terraform operation %q", args[0])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := (Runner{Config: cfg, Mode: Reconcile}).reconcileTerraform(t.TempDir()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(events, ",") != "init,verify,import,plan,apply" {
|
|
t.Fatalf("Terraform phase order = %q", events)
|
|
}
|
|
}
|
|
|
|
func TestRebuildTerraformRetainsFullTalosVMLifecycle(t *testing.T) {
|
|
originalVerify := verifyTalosVMs
|
|
originalStateList := runTerraformStateList
|
|
originalRun := runTerraform
|
|
originalDestroy := destroyTalosVMs
|
|
t.Cleanup(func() {
|
|
verifyTalosVMs = originalVerify
|
|
runTerraformStateList = originalStateList
|
|
runTerraform = originalRun
|
|
destroyTalosVMs = originalDestroy
|
|
})
|
|
|
|
cfg := config.Config{ClusterID: "test-cluster", Talos: config.TalosConfig{
|
|
Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100, Role: "controlplane"}},
|
|
}}
|
|
importedAddress := talosVMImports(cfg)[0].Address
|
|
var events, state []string
|
|
verifyTalosVMs = func(config.Config) error {
|
|
events = append(events, "verify")
|
|
return nil
|
|
}
|
|
runTerraformStateList = func(string, []string) ([]byte, []byte, error) {
|
|
return []byte(strings.Join(state, "\n")), nil, nil
|
|
}
|
|
destroyTalosVMs = func(_ string, _ []string) error {
|
|
events = append(events, "destroy")
|
|
return nil
|
|
}
|
|
runTerraform = func(_ string, _ []string, name string, args ...string) error {
|
|
if name != "terraform" {
|
|
t.Fatalf("unexpected command %q", name)
|
|
}
|
|
switch args[0] {
|
|
case "init":
|
|
events = append(events, "init")
|
|
case "import":
|
|
state = append(state, importedAddress)
|
|
events = append(events, "import")
|
|
case "plan":
|
|
if strings.Contains(strings.Join(args, " "), "-target=") {
|
|
t.Fatalf("rebuild plan must retain the full VM lifecycle: %q", args)
|
|
}
|
|
events = append(events, "plan")
|
|
case "apply":
|
|
events = append(events, "apply")
|
|
default:
|
|
t.Fatalf("unexpected Terraform operation %q", args[0])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := (Runner{Config: cfg, Mode: Rebuild}).reconcileTerraform(t.TempDir()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(events, ",") != "init,verify,import,destroy,plan,apply" {
|
|
t.Fatalf("Terraform phase order = %q", events)
|
|
}
|
|
}
|
|
|
|
func TestRebuildTalosVMsRetriesOnlyDeadlineErrors(t *testing.T) {
|
|
originalDestroy := destroyTalosVMs
|
|
originalDelay := destroyTalosVMRetryDelay
|
|
t.Cleanup(func() {
|
|
destroyTalosVMs = originalDestroy
|
|
destroyTalosVMRetryDelay = originalDelay
|
|
})
|
|
destroyTalosVMRetryDelay = 0
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
err error
|
|
calls int
|
|
}{
|
|
{name: "deadline", err: fmt.Errorf("context deadline exceeded"), calls: 3},
|
|
{name: "other error", err: fmt.Errorf("permission denied"), calls: 1},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
calls := 0
|
|
destroyTalosVMs = func(string, []string) error {
|
|
calls++
|
|
return test.err
|
|
}
|
|
if err := (Runner{}).rebuildTalosVMs(t.TempDir(), nil); !errors.Is(err, test.err) {
|
|
t.Fatalf("rebuild Talos VMs error = %v, want %v", err, test.err)
|
|
}
|
|
if calls != test.calls {
|
|
t.Fatalf("destroy calls = %d, want %d", calls, test.calls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRebuildTerraformSkipsAlreadyAbsentTalosVM(t *testing.T) {
|
|
originalVerify := verifyTalosVMs
|
|
originalRun := runTerraform
|
|
originalDestroy := destroyTalosVMs
|
|
t.Cleanup(func() {
|
|
verifyTalosVMs = originalVerify
|
|
runTerraform = originalRun
|
|
destroyTalosVMs = originalDestroy
|
|
})
|
|
|
|
cfg := config.Config{ClusterID: "test-cluster", Talos: config.TalosConfig{
|
|
Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100, Role: "controlplane"}},
|
|
}}
|
|
var events []string
|
|
for _, apiErr := range []proxmox.APIError{{StatusCode: 404}, {StatusCode: 500, Message: `{"data":null}`, Path: "/nodes/pve/qemu/100/config"}} {
|
|
verifyTalosVMs = func(config.Config) error {
|
|
return fmt.Errorf("inspect configured Talos VM: %w", apiErr)
|
|
}
|
|
destroyTalosVMs = func(string, []string) error {
|
|
t.Fatal("destroy ran for an already-absent VM")
|
|
return nil
|
|
}
|
|
runTerraform = func(_ string, _ []string, name string, args ...string) error {
|
|
if name != "terraform" {
|
|
t.Fatalf("unexpected command %q", name)
|
|
}
|
|
switch args[0] {
|
|
case "init", "plan", "apply":
|
|
events = append(events, args[0])
|
|
default:
|
|
t.Fatalf("unexpected Terraform operation %q", args[0])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := (Runner{Config: cfg, Mode: Rebuild}).reconcileTerraform(t.TempDir()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(events, ",") != "init,plan,apply" {
|
|
t.Fatalf("Terraform phase order = %q", events)
|
|
}
|
|
events = nil
|
|
}
|
|
}
|
|
|
|
func TestRebuildTerraformRejectsUnexpectedProxmoxError(t *testing.T) {
|
|
originalVerify := verifyTalosVMs
|
|
t.Cleanup(func() { verifyTalosVMs = originalVerify })
|
|
verifyTalosVMs = func(config.Config) error {
|
|
return fmt.Errorf("inspect configured Talos VM: %w", proxmox.APIError{StatusCode: 500, Message: "server unavailable", Path: "/nodes/pve/qemu/100/config"})
|
|
}
|
|
|
|
err := (Runner{Config: config.Config{Talos: config.TalosConfig{Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100}}}}, Mode: Rebuild}).rebuildTalosVMs(t.TempDir(), nil)
|
|
if err == nil || !strings.Contains(err.Error(), "server unavailable") {
|
|
t.Fatalf("unexpected Proxmox error was accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureLifecycleIdentityStoresMetadataUnderGeneratedDirectory(t *testing.T) {
|
|
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 })
|
|
values, err := (cloudflare.StoredTunnel{
|
|
Credentials: cloudflare.Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"},
|
|
Config: cloudflare.NewConfig("tunnel"),
|
|
}).Values()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
|
|
return map[string]map[string]string{"platform/cloudflare-tunnel": values}, nil
|
|
}
|
|
if err := (Runner{Config: config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}}).reconcileCloudflareTunnel(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestReconcileCloudflareTunnelMigratesDottedStateAtomically(t *testing.T) {
|
|
originalRead := readCloudflareOperationalSecrets
|
|
originalWrite := writeCloudflareOperationalSecrets
|
|
t.Cleanup(func() {
|
|
readCloudflareOperationalSecrets = originalRead
|
|
writeCloudflareOperationalSecrets = originalWrite
|
|
})
|
|
values, err := (cloudflare.StoredTunnel{
|
|
Credentials: cloudflare.Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"},
|
|
Config: cloudflare.NewConfig("tunnel"),
|
|
}).Values()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secrets := map[string]map[string]string{
|
|
"platform/cloudflare-tunnel": {"credentials.json": values["credentials"], "config.yml": values["config"]},
|
|
"platform/pihole": {"password": "preserved"},
|
|
}
|
|
readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) { return secrets, nil }
|
|
writes := 0
|
|
writeCloudflareOperationalSecrets = func(path, ageKeyPath string, updated map[string]map[string]string) error {
|
|
if path != "secrets" || ageKeyPath != "age" || updated["platform/pihole"]["password"] != "preserved" {
|
|
t.Fatal("dotted-state migration did not use the encrypted operational state boundary")
|
|
}
|
|
secrets = updated
|
|
writes++
|
|
return nil
|
|
}
|
|
if err := (Runner{Config: config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}}).reconcileCloudflareTunnel(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, present, legacy, err := cloudflare.ParseStoredTunnelState(secrets["platform/cloudflare-tunnel"])
|
|
if err != nil || !present || legacy || writes != 1 || len(secrets["platform/cloudflare-tunnel"]) != 2 {
|
|
t.Fatal("dotted Cloudflare tunnel state was not rewritten to simple keys")
|
|
}
|
|
}
|
|
|
|
func TestReconcileCloudflareTunnelRequiresImportForAbsentOrLegacyState(t *testing.T) {
|
|
originalRead := readCloudflareOperationalSecrets
|
|
t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead })
|
|
for _, state := range []map[string]map[string]string{
|
|
nil,
|
|
{"platform/cloudflare-tunnel": {"token": "legacy-run-token"}},
|
|
} {
|
|
readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
|
|
err := (Runner{}).reconcileCloudflareTunnel()
|
|
if err == nil || !strings.Contains(err.Error(), "cloudflare-tunnel import") || strings.Contains(err.Error(), "legacy-run-token") {
|
|
t.Fatal("missing Cloudflare tunnel state did not return a safe import instruction")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReconcileCloudflareTunnelRejectsMalformedState(t *testing.T) {
|
|
originalRead := readCloudflareOperationalSecrets
|
|
t.Cleanup(func() { readCloudflareOperationalSecrets = originalRead })
|
|
readCloudflareOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
|
|
return map[string]map[string]string{"platform/cloudflare-tunnel": {"token": "legacy-run-token", "unexpected": "value"}}, nil
|
|
}
|
|
err := (Runner{}).reconcileCloudflareTunnel()
|
|
if err == nil || strings.Contains(err.Error(), "cloudflare-tunnel import") || strings.Contains(err.Error(), "legacy-run-token") {
|
|
t.Fatal("malformed Cloudflare tunnel state was not rejected safely")
|
|
}
|
|
}
|