feat: make bootstrap self-contained

This commit is contained in:
eding 2026-07-26 20:39:07 +02:00
parent d20a728f65
commit 72fbbca0ac
16 changed files with 1728 additions and 195 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.age/
.recovery/
maidn-bootstrap*.yaml
maidn-workspace/
cicd-tool.exe

View file

@ -45,3 +45,15 @@ talos:
ip: <node-traffic-address> ip: <node-traffic-address>
vlanId: <opnsense-traffic-vlan> vlanId: <opnsense-traffic-vlan>
``` ```
https://192.168.0.15:8006
root@pam!maidn-test-key
2ce7bff1-ac98-4a45-8db4-186d49ae4159
test-org-test-key:147878348db3b8ab660b1af2799c3842705a31bc
maidn-dev-work:fca448f2677362cc165eebb7859ce8f9f5e3735c
https://192.168.0.13:8006
root@pam!maidn-test-key
3773033f-552b-4572-83e9-cd16e8ac2e13

View file

@ -2,37 +2,82 @@ package cmd
import ( import (
"fmt" "fmt"
"os"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap" "github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/openbao"
"github.com/Pingu-Studio/MaidnCLI/internal/ui" "github.com/Pingu-Studio/MaidnCLI/internal/ui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var bootstrapConfigPath string var bootstrapConfigPath string
var bootstrapOutputPath string var bootstrapOutputPath string
var bootstrapMode string
var bootstrapYes bool
var bootstrapPromptDemocraticCSI bool
var bootstrapPromptOperationalSecrets bool
var bootstrapInitializeOpenBaoRecovery bool
var bootstrapCmd = &cobra.Command{ var bootstrapCmd = &cobra.Command{
Use: "bootstrap", Use: "bootstrap",
Short: "Bootstrap Talos and Flux from config or an interactive wizard.", Short: "Bootstrap Talos and Flux from config or an interactive wizard.",
Run: runBootstrap, RunE: runBootstrap,
} }
func init() { func init() {
rootCmd.AddCommand(bootstrapCmd) rootCmd.AddCommand(bootstrapCmd)
bootstrapCmd.Flags().StringVar(&bootstrapConfigPath, "config", "", "Path to bootstrap config YAML") bootstrapCmd.Flags().StringVar(&bootstrapConfigPath, "config", "", "Path to bootstrap config YAML")
bootstrapCmd.Flags().StringVar(&bootstrapOutputPath, "out", "maidn-bootstrap.yaml", "Path to save generated config") bootstrapCmd.Flags().StringVar(&bootstrapOutputPath, "out", "maidn-bootstrap.yaml", "Path to save generated config")
bootstrapCmd.Flags().StringVar(&bootstrapMode, "mode", string(bootstrap.Reconcile), "Lifecycle mode: reconcile or rebuild")
bootstrapCmd.Flags().BoolVar(&bootstrapYes, "yes", false, "Confirm destructive rebuild")
bootstrapCmd.Flags().BoolVar(&bootstrapPromptDemocraticCSI, "prompt-democratic-csi", false, "Prompt for and save Democratic CSI settings in --config")
bootstrapCmd.Flags().BoolVar(&bootstrapPromptOperationalSecrets, "prompt-operational-secrets", false, "Prompt for and encrypt operational secrets for --config")
bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBaoRecovery, "initialize-openbao-recovery", false, "Create and save a separate OpenBao recovery age identity for --config")
} }
func runBootstrap(cmd *cobra.Command, args []string) { func runBootstrap(cmd *cobra.Command, args []string) error {
var cfg config.Config var cfg config.Config
var err error var err error
if bootstrapConfigPath != "" { if bootstrapConfigPath != "" {
cfg, err = config.Load(bootstrapConfigPath) if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery {
cfg, err = config.LoadRaw(bootstrapConfigPath)
if err == nil {
if bootstrapPromptDemocraticCSI {
cfg = ui.PromptDemocraticCSI(cfg)
}
cfg, err = config.Resolve(cfg)
}
if err == nil && bootstrapPromptOperationalSecrets {
var secrets map[string]map[string]string
secrets, err = ui.PromptOperationalSecrets(cfg)
if err == nil {
var authorization string
authorization, err = bootstrap.NewWebhookAuthorization()
if err == nil {
secrets["cicd/forgejo-webhook"] = map[string]string{"authorization": authorization}
err = bootstrap.WriteOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, secrets)
}
}
}
if err == nil && bootstrapInitializeOpenBaoRecovery {
var recipient string
recipient, err = openbao.EnsureRecoveryIdentity(cfg.SOPS.RecoveryIdentityPath)
if err == nil {
cfg.SOPS.RecoveryRecipient = recipient
}
}
if err == nil {
err = config.Save(bootstrapConfigPath, cfg)
}
} else {
cfg, err = config.Load(bootstrapConfigPath)
}
} else { } else {
cfg, err = ui.RunBootstrapWizard(config.Config{}) cfg, err = ui.RunBootstrapWizard(config.Config{})
if err == nil {
cfg, err = config.Resolve(cfg)
}
if err == nil { if err == nil {
err = config.Save(bootstrapOutputPath, cfg) err = config.Save(bootstrapOutputPath, cfg)
if err == nil { if err == nil {
@ -41,13 +86,9 @@ func runBootstrap(cmd *cobra.Command, args []string) {
} }
} }
if err != nil { if err != nil {
fmt.Printf("[ERROR] %v\n", err) return err
os.Exit(1)
} }
runner := bootstrap.Runner{Config: cfg} runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes}
if err = runner.Run(); err != nil { return runner.Run()
fmt.Printf("[ERROR] %v\n", err)
os.Exit(1)
}
} }

47
docs/secrets.md Normal file
View file

@ -0,0 +1,47 @@
# Bootstrap Secret Inputs
`bootstrap` uses the Flux age identity in ignored `.age/` storage to encrypt
the configured Democratic CSI Secret directly into the generated cluster
repository. Its TrueNAS API key is never printed or committed in plaintext.
`operational-secrets.sops.yaml` is decrypted only in MaidnCLI memory after
OpenBao is initialized. It is not copied to the cluster repository. Its schema
is:
```yaml
secrets:
cicd/forgejo:
username: encrypted-value
token: encrypted-value
cicd/forgejo-registry:
dockerconfigjson: encrypted-value
cicd/forgejo-webhook:
authorization: encrypted-value
platform/cloudflare:
api-token: encrypted-value
platform/cloudflare-tunnel:
token: encrypted-value
```
Keys are written to OpenBao KV v2 under `secret/<path>`. Additional paths are
allowed when they use lowercase path characters and scalar property names.
Set all `democraticCsi` settings in the bootstrap configuration or provide
them through the interactive wizard. The CLI writes those values only to
`base/democratic-csi/secret.sops.yaml` in the generated cluster repository.
For an existing configuration, run `bootstrap --config <path>
--prompt-democratic-csi` to enter the settings with the API key masked.
For a new operational-secret input, run `bootstrap --config <path>
--prompt-operational-secrets`. It derives Forgejo Git and registry credentials
from the configured Forgejo account, prompts for Cloudflare credentials with
input masked, and generates the webhook authorization value.
`cicd/forgejo-webhook.authorization` is required for delivery bootstrap. The
CLI supplies it as the Forgejo webhook Authorization header and Tekton compares
that header against the ExternalSecret-derived `forgejo-webhook` Secret.
Run `bootstrap --config <path> --initialize-openbao-recovery` to create and
save a separate recovery age identity for `openbao-recovery.age`. The Flux SOPS
age identity is installed in `flux-system`; it must not encrypt OpenBao
recovery material.

View file

@ -1,29 +1,75 @@
package bootstrap package bootstrap
import ( import (
"bytes"
"crypto/rand"
"encoding/base64"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
"github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo" "github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
ghrepo "github.com/Pingu-Studio/MaidnCLI/internal/github" ghrepo "github.com/Pingu-Studio/MaidnCLI/internal/github"
"github.com/Pingu-Studio/MaidnCLI/internal/openbao"
"github.com/Pingu-Studio/MaidnCLI/internal/utils" "github.com/Pingu-Studio/MaidnCLI/internal/utils"
"gopkg.in/yaml.v3"
)
type Mode string
const (
Reconcile Mode = "reconcile"
Rebuild Mode = "rebuild"
) )
type Runner struct { type Runner struct {
Config config.Config Config config.Config
Mode Mode
ConfirmRebuild bool
} }
func (r Runner) Run() error { func (r Runner) Run() error {
resolved, err := config.Resolve(r.Config)
if err != nil {
return err
}
r.Config = resolved
if err := config.Preflight(r.Config); err != nil {
return fmt.Errorf("preflight: %w", err)
}
if _, err := os.Stat(r.Config.SOPS.AgeKeyPath); err != nil {
return fmt.Errorf("preflight SOPS age identity: %w", err)
}
if r.Config.Talos.AutoBootstrapFlux {
if _, err := os.Stat(r.Config.SOPS.OperationalSecretsPath); err != nil {
return fmt.Errorf("preflight operational SOPS secrets: %w", err)
}
if r.Config.SOPS.RecoveryRecipient == "" || r.Config.SOPS.RecoveryIdentityPath == "" || r.Config.SOPS.RecoveryBundlePath == "" {
return errors.New("SOPS recoveryRecipient, recoveryIdentityPath, and recoveryBundlePath are required")
}
}
if r.Mode == "" {
r.Mode = Reconcile
}
if r.Mode != Reconcile && r.Mode != Rebuild {
return fmt.Errorf("unsupported bootstrap mode %q", r.Mode)
}
if r.Mode == Rebuild && !r.ConfirmRebuild {
return fmt.Errorf("rebuild is destructive; rerun with --mode=rebuild --yes")
}
workspace := r.Config.WorkspaceDir workspace := r.Config.WorkspaceDir
if err := os.MkdirAll(workspace, 0755); err != nil { if err := os.MkdirAll(workspace, 0755); err != nil {
return err return err
} }
if err := config.Save(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil { if err := config.WriteRedacted(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil {
return err return err
} }
if err := os.MkdirAll(r.Config.Git.CloneParent, 0755); err != nil { if err := os.MkdirAll(r.Config.Git.CloneParent, 0755); err != nil {
@ -31,20 +77,29 @@ func (r Runner) Run() error {
} }
manifestsURL := forgejo.CloneURL(r.Config.Git.BaseURL, r.Config.Git.Owner, r.Config.Flux.ManifestsRepo) manifestsURL := forgejo.CloneURL(r.Config.Git.BaseURL, r.Config.Git.Owner, r.Config.Flux.ManifestsRepo)
fluxConfig := ghrepo.BuildFluxConfig(strings.ToLower(r.Config.Git.Owner), manifestsURL, r.Config.Flux.ManifestsRepo) fluxConfig := ghrepo.BuildFluxConfig(manifestsURL, r.Config.Flux.ManifestsRepo, r.Config.Flux.Branch)
cicdTemplateDir := filepath.Join(workspace, "maidn-cicd-cluster-template") cicdTemplateDir := filepath.Join(workspace, "maidn-cicd-cluster-template")
if err := ensureRepo(cicdTemplateDir, r.Config.Templates.CICDRepoURL, r.Config.Templates.CICDRepoRef); err != nil { if err := ensureRepo(cicdTemplateDir, r.Config.Templates.CICDRepoURL, r.Config.Templates.CICDRepoRef); err != nil {
return err return err
} }
manager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, r.Config.Flux.ManifestsRepo, r.Config.Flux.RepoName) manifestsTemplateDir := filepath.Join(workspace, "cicd-deployment-manifests-template")
if err := ensureRepo(manifestsTemplateDir, r.Config.Templates.ManifestsRepoURL, r.Config.Templates.ManifestsRepoRef); err != nil {
return err
}
manager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, r.Config.Flux.ManifestsRepo, r.Config.Flux.RepoName, r.Config.Flux.Branch, "maidn/bootstrap-"+r.Config.ClusterID)
if err := manager.InitializeAll( if err := manager.InitializeAll(
func(dir string) error { return ghrepo.WriteManifestsStructure(dir, r.Config.Flux.ManifestsRepo) }, func(dir string) error {
if err := copyDir(manifestsTemplateDir, dir, false); err != nil {
return err
}
return ensureManifestsKustomizations(dir)
},
func(dir string) error { func(dir string) error {
clusterDir := filepath.Join(dir, strings.TrimPrefix(r.Config.Flux.ClusterPath, "./")) clusterDir := filepath.Join(dir, strings.TrimPrefix(r.Config.Flux.ClusterPath, "./"))
if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil { if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil {
return err return err
} }
if err := copyDir(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir, false); err != nil { if err := copyClusterTemplate(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir); err != nil {
return err return err
} }
if err := copyDir(filepath.Join(cicdTemplateDir, "base", "cilium"), filepath.Join(dir, "base", "cilium"), true); err != nil { if err := copyDir(filepath.Join(cicdTemplateDir, "base", "cilium"), filepath.Join(dir, "base", "cilium"), true); err != nil {
@ -53,13 +108,25 @@ func (r Runner) Run() error {
if err := copyDir(filepath.Join(cicdTemplateDir, "base", "cilium-config"), filepath.Join(dir, "base", "cilium-config"), true); err != nil { if err := copyDir(filepath.Join(cicdTemplateDir, "base", "cilium-config"), filepath.Join(dir, "base", "cilium-config"), true); err != nil {
return err return err
} }
if err := copyDir(filepath.Join(cicdTemplateDir, "base", "democratic-csi"), filepath.Join(dir, "base", "democratic-csi"), true); err != nil {
return err
}
if err := renderCiliumConfig(filepath.Join(dir, "base", "cilium"), r.Config); err != nil { if err := renderCiliumConfig(filepath.Join(dir, "base", "cilium"), r.Config); err != nil {
return err return err
} }
if err := renderCiliumConfig(filepath.Join(dir, "base", "cilium-config"), r.Config); err != nil { if err := renderCiliumConfig(filepath.Join(dir, "base", "cilium-config"), r.Config); err != nil {
return err return err
} }
if err := ensureCiliumKustomizations(clusterDir); err != nil { if err := renderDeliveryConfig(filepath.Join(dir, "base", "tekton"), r.Config); err != nil {
return err
}
if err := renderDeliveryConfig(filepath.Join(dir, "base", "tekton-triggers"), r.Config); err != nil {
return err
}
if err := writeDemocraticCSISecret(filepath.Join(dir, "base", "democratic-csi", "secret.sops.yaml"), r.Config.DemocraticCSI, r.Config.SOPS.AgeKeyPath); err != nil {
return err
}
if err := ensureClusterKustomizations(clusterDir); err != nil {
return err return err
} }
return ghrepo.WriteFluxStructure(dir, r.Config.Flux.RepoName, r.Config.Flux.ClusterPath, fluxConfig) return ghrepo.WriteFluxStructure(dir, r.Config.Flux.RepoName, r.Config.Flux.ClusterPath, fluxConfig)
@ -67,6 +134,9 @@ func (r Runner) Run() error {
); err != nil { ); err != nil {
return err return err
} }
if manager.MigrationPending {
return errors.New("existing repository migration PR created; merge and rerun bootstrap before infrastructure changes")
}
repoDir := filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName) repoDir := filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName)
if err := ensureRepo(repoDir, r.Config.Templates.TalosRepoURL, r.Config.Templates.TalosRepoRef); err != nil { if err := ensureRepo(repoDir, r.Config.Templates.TalosRepoURL, r.Config.Templates.TalosRepoRef); err != nil {
@ -78,15 +148,15 @@ func (r Runner) Run() error {
if err := os.MkdirAll(generatedDir, 0755); err != nil { if err := os.MkdirAll(generatedDir, 0755); err != nil {
return err return err
} }
if err := os.WriteFile(filepath.Join(terraformDir, r.Config.Talos.ConfigFileName), []byte(renderTerraformTFVars(r.Config)), 0644); err != nil { if err := ensureLifecycleIdentity(terraformDir, r.Config); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(terraformDir, r.Config.Talos.ConfigFileName), []byte(renderTerraformTFVars(r.Config)), 0600); err != nil {
return err return err
} }
if r.Config.Talos.AutoRunTerraform { if r.Config.Talos.AutoRunTerraform {
if err := utils.RunCommandInDir(terraformDir, "terraform", "init"); err != nil { if err := r.reconcileTerraform(terraformDir); err != nil {
return err
}
if err := utils.RunCommandInDir(terraformDir, "terraform", "apply", "-auto-approve"); err != nil {
return err return err
} }
} }
@ -94,22 +164,11 @@ func (r Runner) Run() error {
return err return err
} }
if r.Config.Talos.AutoBootstrap { if r.Config.Talos.AutoBootstrap {
configFile := filepath.Join("clusterconfig", fmt.Sprintf("%s-%s.yaml", r.Config.Talos.Cluster.Name, r.Config.Talos.Nodes[0].Name)) if err := applyTalosConfigs(generatedDir, r.Config); err != nil {
if _, err := utils.RunCommandQuietOutputInDir(generatedDir, "talosctl", "apply-config", "--talosconfig=./clusterconfig/talosconfig", "--nodes="+r.Config.Talos.BootstrapNode, "--endpoints="+r.Config.Talos.BootstrapEndpoint, "--file="+configFile); err != nil {
if err := utils.RunCommandInDir(generatedDir, "talosctl", "apply-config", "--insecure", "--nodes="+r.Config.Talos.BootstrapNode, "--endpoints="+r.Config.Talos.BootstrapEndpoint, "--file="+configFile); err != nil {
return err
}
}
if err := waitForTalosReboot(generatedDir, r.Config); err != nil {
return err return err
} }
if _, err := utils.RunCommandQuietOutputInDir(generatedDir, "talosctl", "health", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+r.Config.Talos.BootstrapEndpoint, "--nodes="+r.Config.Talos.BootstrapNode, "--wait-timeout=20s"); err != nil { if err := bootstrapEtcdIfNeeded(generatedDir, r.Config); err != nil {
if err := utils.RunCommandInDir(generatedDir, "talosctl", "bootstrap", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+r.Config.Talos.BootstrapEndpoint, "--nodes="+r.Config.Talos.BootstrapNode); err != nil { return err
return err
}
if err := utils.RunCommandInDir(generatedDir, "talosctl", "health", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+r.Config.Talos.BootstrapEndpoint, "--nodes="+r.Config.Talos.BootstrapNode, "--wait-timeout=2m"); err != nil {
return err
}
} }
if err := utils.RunCommandInDir(generatedDir, "talosctl", "kubeconfig", "--talosconfig=./clusterconfig/talosconfig", "--nodes="+r.Config.Talos.KubeconfigNode, "."); err != nil { if err := utils.RunCommandInDir(generatedDir, "talosctl", "kubeconfig", "--talosconfig=./clusterconfig/talosconfig", "--nodes="+r.Config.Talos.KubeconfigNode, "."); err != nil {
return err return err
@ -119,9 +178,29 @@ func (r Runner) Run() error {
if err := installCilium(generatedDir, r.Config); err != nil { if err := installCilium(generatedDir, r.Config); err != nil {
return err return err
} }
if err := utils.RunCommandInDir(generatedDir, "flux", "bootstrap", "git", "--url="+forgejo.CloneURL(r.Config.Git.BaseURL, r.Config.Git.Owner, r.Config.Flux.RepoName), "--branch="+r.Config.Flux.Branch, "--path="+r.Config.Flux.ClusterPath, "--cluster-domain="+r.Config.Flux.ClusterDomain, "--username="+r.Config.Git.Username, "--password="+r.Config.Git.Token, "--token-auth", "--kubeconfig=kubeconfig"); err != nil { if err := utils.RunCommandInDirEnv(generatedDir, []string{"GIT_PASSWORD=" + r.Config.Git.Token}, "flux", "bootstrap", "git", "--url="+forgejo.CloneURL(r.Config.Git.BaseURL, r.Config.Git.Owner, r.Config.Flux.RepoName), "--branch="+r.Config.Flux.Branch, "--path="+r.Config.Flux.ClusterPath, "--cluster-domain="+r.Config.Flux.ClusterDomain, "--username="+r.Config.Git.Username, "--token-auth", "--kubeconfig=kubeconfig"); err != nil {
return err return err
} }
if err := installSOPSKey(generatedDir, r.Config); err != nil {
return err
}
if err := configureFluxSOPS(generatedDir); err != nil {
return err
}
operationalSecrets, err := openbao.Initialize(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath)
if err != nil {
return fmt.Errorf("initialize OpenBao: %w", err)
}
authorization := operationalSecrets["cicd/forgejo-webhook"]["authorization"]
if authorization == "" {
return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization")
}
if err := waitForWebhookTargets(generatedDir, r.Config); err != nil {
return err
}
if err := manager.EnsureWebhook(r.Config.Delivery.AppName, r.Config.Delivery.WebhookURL(), authorization); err != nil {
return fmt.Errorf("reconcile Forgejo webhook: %w", err)
}
} }
return nil return nil
} }
@ -145,14 +224,133 @@ func renderCiliumConfig(dir string, cfg config.Config) error {
}) })
} }
func ensureCiliumKustomizations(clusterDir string) error { func renderDeliveryConfig(dir string, cfg config.Config) error {
replacements := strings.NewReplacer(
"${APP_NAME}", cfg.Delivery.AppName,
"${APP_REPO_URL}", cfg.Delivery.AppRepoURL,
"${APP_REPO_REF}", cfg.Delivery.AppRepoRef,
"${IMAGE_REPOSITORY}", cfg.Delivery.ImageRepository,
"${FORGEJO_BASE_URL}", cfg.Git.BaseURL,
"${WEBHOOK_HOSTNAME}", cfg.Delivery.WebhookHostname,
"${WEBHOOK_PATH}", cfg.Delivery.WebhookPath,
)
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(path, []byte(replacements.Replace(string(content))), info.Mode())
})
}
func writeDemocraticCSISecret(path string, csi config.DemocraticCSIConfig, ageKeyPath string) error {
plaintext, err := renderDemocraticCSISecret(csi)
if err != nil {
return err
}
return writeSOPSEncryptedFile(path, ageKeyPath, plaintext)
}
func WriteOperationalSecrets(path, ageKeyPath string, secrets map[string]map[string]string) error {
plaintext, err := yaml.Marshal(struct {
Secrets map[string]map[string]string `yaml:"secrets"`
}{Secrets: secrets})
if err != nil {
return err
}
return writeSOPSEncryptedFile(path, ageKeyPath, plaintext)
}
func NewWebhookAuthorization() (string, error) {
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
return "", err
}
return "Bearer " + base64.RawURLEncoding.EncodeToString(value), nil
}
func writeSOPSEncryptedFile(path, ageKeyPath string, plaintext []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
recipient, err := exec.Command("age-keygen", "-y", ageKeyPath).Output()
if err != nil {
return fmt.Errorf("derive SOPS age recipient: %w", err)
}
command := exec.Command("sops", "encrypt", "--age", strings.TrimSpace(string(recipient)), "--encrypted-regex", "^(data|stringData)$", "--input-type", "yaml", "--output-type", "yaml", "--filename-override", filepath.Base(path))
command.Stdin = bytes.NewReader(plaintext)
encrypted, err := command.Output()
if err != nil {
return fmt.Errorf("encrypt Democratic CSI secret: %w", err)
}
return os.WriteFile(path, encrypted, 0600)
}
func renderDemocraticCSISecret(csi config.DemocraticCSIConfig) ([]byte, error) {
return yaml.Marshal(struct {
APIVersion string `yaml:"apiVersion"`
Kind string `yaml:"kind"`
Metadata map[string]string `yaml:"metadata"`
Type string `yaml:"type"`
StringData map[string]string `yaml:"stringData"`
}{
APIVersion: "v1",
Kind: "Secret",
Metadata: map[string]string{"name": "democratic-csi-secrets", "namespace": "democratic-storage"},
Type: "Opaque",
StringData: map[string]string{
"truenas-api-key": csi.TrueNASAPIKey,
"truenas-host": csi.TrueNASHost,
"target-portal": csi.TargetPortal,
"share-host": csi.ShareHost,
"dataset-parent-nfs": csi.DatasetParentNFS,
"dataset-snapshots-nfs": csi.DatasetSnapshotsNFS,
"allowed-networks": csi.AllowedNetworks,
"name-suffix": csi.NameSuffix,
"portal-group": csi.PortalGroup,
"initiator-group": csi.InitiatorGroup,
},
})
}
func waitForWebhookTargets(dir string, cfg config.Config) error {
resources := []string{
"secret/forgejo-webhook",
"deployment/el-" + cfg.Delivery.AppName,
"pipeline/" + cfg.Delivery.AppName,
}
for _, resource := range resources {
deadline := time.Now().Add(10 * time.Minute)
for time.Now().Before(deadline) {
if _, err := utils.RunCommandQuietOutputInDir(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", resource); err == nil {
break
}
time.Sleep(2 * time.Second)
}
if _, err := utils.RunCommandQuietOutputInDir(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", resource); err != nil {
return fmt.Errorf("wait for %s before registering Forgejo webhook", resource)
}
}
return nil
}
func ensureClusterKustomizations(clusterDir string) error {
path := filepath.Join(clusterDir, "kustomization.yaml") path := filepath.Join(clusterDir, "kustomization.yaml")
content, err := os.ReadFile(path) content, err := os.ReadFile(path)
if os.IsNotExist(err) {
// Existing Flux roots may intentionally use recursive discovery. Do not
// introduce a partial root Kustomization that could prune its resources.
return nil
}
if err != nil { if err != nil {
return err return err
} }
updated := string(content) updated := string(content)
for _, resource := range []string{"cilium-kustomization.yaml", "cilium-config-kustomization.yaml"} { updated = strings.ReplaceAll(updated, " - bootstrap-secrets.sops.yaml\n", "")
for _, resource := range []string{"cilium-kustomization.yaml", "cilium-config-kustomization.yaml", "cicd-manifests-repo.yaml", "tekton-triggers-kustomization.yaml"} {
if !strings.Contains(updated, resource) { if !strings.Contains(updated, resource) {
updated += " - " + resource + "\n" updated += " - " + resource + "\n"
} }
@ -163,23 +361,67 @@ func ensureCiliumKustomizations(clusterDir string) error {
return os.WriteFile(path, []byte(updated), 0644) return os.WriteFile(path, []byte(updated), 0644)
} }
func copyClusterTemplate(source, destination string) error {
entries, err := os.ReadDir(destination)
if err != nil && !os.IsNotExist(err) {
return err
}
if len(entries) == 0 {
return copyDir(source, destination, false)
}
if _, err := os.Stat(filepath.Join(destination, "kustomization.yaml")); err == nil {
return copyDir(source, destination, false)
} else if !os.IsNotExist(err) {
return err
}
return copyDirExcept(source, destination, false, map[string]bool{"kustomization.yaml": true})
}
func ensureManifestsKustomizations(dir string) error {
for _, environment := range []string{"previews", "staging", "production"} {
path := filepath.Join(dir, "apps", environment, "kustomization.yaml")
if _, err := os.Stat(path); err == nil {
continue
} else if !os.IsNotExist(err) {
return err
}
if err := os.WriteFile(path, []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n"), 0644); err != nil {
return err
}
}
return nil
}
func installCilium(dir string, cfg config.Config) error { func installCilium(dir string, cfg config.Config) error {
helmDir := filepath.Join(dir, ".helm") helmDir := filepath.Join(dir, ".helm")
if err := os.MkdirAll(helmDir, 0755); err != nil { if err := os.MkdirAll(helmDir, 0755); err != nil {
return err return err
} }
return utils.RunCommandInDir(dir, "helm", "upgrade", "--install", "cilium", "cilium", "--repo=https://helm.cilium.io", "--version=1.19.6", "--repository-config="+filepath.Join(helmDir, "repositories.yaml"), "--repository-cache="+helmDir, "--namespace=kube-system", "--create-namespace", "--kubeconfig=kubeconfig", "--wait", "--timeout=5m", "--set=kubeProxyReplacement=true", "--set=ipam.mode=kubernetes", "--set=k8sServiceHost=localhost", "--set=k8sServicePort=7445", "--set=cgroup.autoMount.enabled=false", "--set=cgroup.hostRoot=/sys/fs/cgroup", "--set=bpf.hostLegacyRouting=true", "--set=securityContext.capabilities.ciliumAgent={CHOWN,KILL,NET_ADMIN,NET_RAW,IPC_LOCK,SYS_ADMIN,SYS_RESOURCE,DAC_OVERRIDE,FOWNER,SETGID,SETUID}", "--set=securityContext.capabilities.cleanCiliumState={NET_ADMIN,SYS_ADMIN,SYS_RESOURCE}", "--set=gatewayAPI.enabled=true", "--set=l2announcements.enabled=true", "--set=operator.replicas=1") return utils.RunCommandInDir(dir, "helm", "upgrade", "--install", "cilium", "cilium", "--repo=https://helm.cilium.io", "--version=1.19.6", "--repository-config="+filepath.Join(helmDir, "repositories.yaml"), "--repository-cache="+helmDir, "--namespace=kube-system", "--create-namespace", "--kubeconfig=kubeconfig", "--wait", "--timeout=5m", "--set=kubeProxyReplacement=true", "--set=ipam.mode=kubernetes", "--set=k8sServiceHost=localhost", "--set=k8sServicePort=7445", "--set=cgroup.autoMount.enabled=false", "--set=cgroup.hostRoot=/sys/fs/cgroup", "--set=bpf.hostLegacyRouting=true", "--set=securityContext.capabilities.ciliumAgent={CHOWN,KILL,NET_ADMIN,NET_RAW,IPC_LOCK,SYS_ADMIN,SYS_RESOURCE,DAC_OVERRIDE,FOWNER,SETGID,SETUID}", "--set=securityContext.capabilities.cleanCiliumState={NET_ADMIN,SYS_ADMIN,SYS_RESOURCE}", "--set=gatewayAPI.enabled=false", "--set=l2announcements.enabled=true", "--set=operator.replicas=1")
} }
func copyDir(source, destination string, overwrite bool) error { func copyDir(source, destination string, overwrite bool) error {
return copyDirExcept(source, destination, overwrite, nil)
}
func copyDirExcept(source, destination string, overwrite bool, excluded map[string]bool) error {
return filepath.Walk(source, func(path string, info os.FileInfo, err error) error { return filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
if info.IsDir() && info.Name() == ".git" {
return filepath.SkipDir
}
relative, err := filepath.Rel(source, path) relative, err := filepath.Rel(source, path)
if err != nil || relative == "." { if err != nil || relative == "." {
return err return err
} }
if excluded[filepath.ToSlash(relative)] {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
target := filepath.Join(destination, relative) target := filepath.Join(destination, relative)
if info.IsDir() { if info.IsDir() {
return os.MkdirAll(target, 0755) return os.MkdirAll(target, 0755)
@ -191,25 +433,66 @@ func copyDir(source, destination string, overwrite bool) error {
if err != nil { if err != nil {
return err return err
} }
defer input.Close()
flags := os.O_WRONLY | os.O_CREATE | os.O_EXCL flags := os.O_WRONLY | os.O_CREATE | os.O_EXCL
if overwrite { if overwrite {
flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC
} }
output, err := os.OpenFile(target, flags, info.Mode()) output, err := os.OpenFile(target, flags, info.Mode())
if err != nil { if err != nil {
_ = input.Close()
return err return err
} }
defer output.Close() _, copyErr := io.Copy(output, input)
_, err = io.Copy(output, input) closeInputErr := input.Close()
return err closeOutputErr := output.Close()
if copyErr != nil {
return copyErr
}
if closeInputErr != nil {
return closeInputErr
}
return closeOutputErr
}) })
} }
func waitForTalosReboot(dir string, cfg config.Config) error { func applyTalosConfigs(dir string, cfg config.Config) error {
for _, node := range cfg.Talos.Nodes {
configFile := filepath.Join("clusterconfig", fmt.Sprintf("%s-%s.yaml", cfg.Talos.Cluster.Name, node.Name))
nodeAddress := node.Networks[0].IP
secureArgs := []string{"apply-config", "--talosconfig=./clusterconfig/talosconfig", "--nodes=" + nodeAddress, "--endpoints=" + cfg.Talos.BootstrapEndpoint, "--file=" + configFile}
if _, err := utils.RunCommandQuietOutputInDir(dir, "talosctl", "get", "machinestatus", "--talosconfig=./clusterconfig/talosconfig", "--nodes="+nodeAddress, "--endpoints="+cfg.Talos.BootstrapEndpoint, "--output=json"); err == nil {
if err := utils.RunCommandInDir(dir, "talosctl", secureArgs...); err != nil {
return fmt.Errorf("apply Talos config to %s: %w", node.Name, err)
}
} else {
if _, maintenanceErr := utils.RunCommandQuietOutputInDir(dir, "talosctl", "get", "machinestatus", "--insecure", "--nodes="+nodeAddress, "--endpoints="+nodeAddress, "--output=json"); maintenanceErr != nil {
return fmt.Errorf("determine Talos state for %s; refusing insecure takeover", node.Name)
}
if err := utils.RunCommandInDir(dir, "talosctl", "apply-config", "--insecure", "--nodes="+nodeAddress, "--endpoints="+nodeAddress, "--file="+configFile); err != nil {
return fmt.Errorf("apply initial Talos config to %s: %w", node.Name, err)
}
}
if err := waitForTalosReboot(dir, cfg, nodeAddress); err != nil {
return fmt.Errorf("wait for %s: %w", node.Name, err)
}
}
return nil
}
func bootstrapEtcdIfNeeded(dir string, cfg config.Config) error {
if _, err := utils.RunCommandQuietOutputInDir(dir, "talosctl", "etcd", "status", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+cfg.Talos.BootstrapEndpoint, "--nodes="+cfg.Talos.BootstrapNode); err == nil {
return nil
}
if members, err := utils.RunCommandQuietOutputInDir(dir, "talosctl", "get", "members", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+cfg.Talos.BootstrapEndpoint, "--nodes="+cfg.Talos.BootstrapNode, "--output=json"); err == nil && len(strings.TrimSpace(string(members))) > 0 {
return errors.New("etcd members exist but etcd status is unavailable; refusing to bootstrap an existing cluster")
}
return utils.RunCommandInDir(dir, "talosctl", "bootstrap", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+cfg.Talos.BootstrapEndpoint, "--nodes="+cfg.Talos.BootstrapNode)
}
func waitForTalosReboot(dir string, cfg config.Config, node string) error {
deadline := time.Now().Add(5 * time.Minute) deadline := time.Now().Add(5 * time.Minute)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
output, err := utils.RunCommandQuietOutputInDir(dir, "talosctl", "get", "machinestatus", "--output=json", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+cfg.Talos.BootstrapEndpoint, "--nodes="+cfg.Talos.BootstrapNode) output, err := utils.RunCommandQuietOutputInDir(dir, "talosctl", "get", "machinestatus", "--output=json", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+node, "--nodes="+node)
if err == nil && strings.Contains(string(output), `"stage": "running"`) { if err == nil && strings.Contains(string(output), `"stage": "running"`) {
return nil return nil
} }
@ -218,6 +501,77 @@ func waitForTalosReboot(dir string, cfg config.Config) error {
return fmt.Errorf("Talos API did not return after applying its machine configuration") return fmt.Errorf("Talos API did not return after applying its machine configuration")
} }
type lifecycle struct {
ClusterID string `yaml:"clusterId"`
ClusterName string `yaml:"clusterName"`
}
func ensureLifecycleIdentity(terraformDir string, cfg config.Config) error {
statePath := filepath.Join(terraformDir, "terraform.tfstate")
lifecyclePath := filepath.Join(terraformDir, ".maidn", "lifecycle.yaml")
data, err := os.ReadFile(lifecyclePath)
if err == nil {
var current lifecycle
if err := yaml.Unmarshal(data, &current); err != nil {
return fmt.Errorf("read lifecycle metadata: %w", err)
}
if current.ClusterID != cfg.ClusterID || current.ClusterName != cfg.Talos.Cluster.Name {
return fmt.Errorf("terraform checkout belongs to cluster %q; use its matching configuration", current.ClusterID)
}
return nil
}
if !os.IsNotExist(err) {
return err
}
if state, err := os.ReadFile(statePath); err == nil && !strings.Contains(string(state), cfg.Talos.Cluster.Name) {
return errors.New("terraform state exists but does not match clusterName; use an explicit recovery checkout")
} else if err != nil && !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(filepath.Dir(lifecyclePath), 0700); err != nil {
return err
}
data, err = yaml.Marshal(lifecycle{ClusterID: cfg.ClusterID, ClusterName: cfg.Talos.Cluster.Name})
if err != nil {
return err
}
return os.WriteFile(lifecyclePath, data, 0600)
}
func (r Runner) reconcileTerraform(terraformDir string) error {
environment := []string{"TF_VAR_proxmox_api_token=" + r.Config.Talos.Proxmox.APITokenID + "=" + r.Config.Talos.Proxmox.APITokenSecret}
if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "init", "-input=false"); 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 {
return err
}
}
planPath := filepath.Join(terraformDir, r.Config.ClusterID+".tfplan")
defer os.Remove(planPath)
if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "plan", "-input=false", "-out="+planPath); err != nil {
return err
}
return utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "apply", "-input=false", "-auto-approve", planPath)
}
func installSOPSKey(dir string, cfg config.Config) error {
if _, err := os.Stat(cfg.SOPS.AgeKeyPath); err != nil {
return fmt.Errorf("read SOPS age identity: %w", err)
}
manifest, err := utils.RunCommandOutputInDir(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "flux-system", "create", "secret", "generic", "sops-age", "--from-file=age.agekey="+cfg.SOPS.AgeKeyPath, "--dry-run=client", "-o", "yaml")
if err != nil {
return err
}
return utils.RunCommandInputInDir(dir, manifest, "kubectl", "--kubeconfig=kubeconfig", "apply", "-f", "-")
}
func configureFluxSOPS(dir string) error {
patch := `{"spec":{"decryption":{"provider":"sops","secretRef":{"name":"sops-age"}}}}`
return utils.RunCommandInDir(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "flux-system", "patch", "kustomization", "flux-system", "--type=merge", "-p", patch)
}
func ensureRepo(dir, repoURL, ref string) error { func ensureRepo(dir, repoURL, ref string) error {
if _, err := os.Stat(dir); os.IsNotExist(err) { if _, err := os.Stat(dir); os.IsNotExist(err) {
return utils.RunCommand("git", "clone", "--branch", ref, repoURL, dir) return utils.RunCommand("git", "clone", "--branch", ref, repoURL, dir)
@ -233,54 +587,34 @@ func ensureRepo(dir, repoURL, ref string) error {
func ensureTalosConfig(generatedDir string, cfg config.Config) error { func ensureTalosConfig(generatedDir string, cfg config.Config) error {
talhelperPath := "talhelper" talhelperPath := "talhelper"
secretPath := filepath.Join(generatedDir, "talsecret.sops.yaml") secretPath := filepath.Join(generatedDir, "talsecret.yaml")
if _, err := os.Stat(secretPath); os.IsNotExist(err) { if _, err := os.Stat(secretPath); os.IsNotExist(err) {
secrets, err := utils.RunCommandOutputInDir(generatedDir, talhelperPath, "gensecret") legacyPath := filepath.Join(generatedDir, "talsecret.sops.yaml")
if err != nil { if _, legacyErr := os.Stat(legacyPath); legacyErr == nil {
return err secretPath = legacyPath
}
if err := os.WriteFile(secretPath, secrets, 0600); err != nil {
return err
}
}
kubernetesVersion := cfg.Talos.Image.TalosVersion
talconfigPath := writeTalconfigWithKubernetesVersion(generatedDir, kubernetesVersion)
return utils.RunCommandInDir(generatedDir, talhelperPath, "genconfig", "--config-file", filepath.Base(talconfigPath), "--secret-file", filepath.Base(secretPath))
}
func writeTalconfigWithKubernetesVersion(generatedDir, kubernetesVersion string) string {
path := filepath.Join(generatedDir, "talconfig.yaml")
content, _ := os.ReadFile(path)
if strings.HasPrefix(kubernetesVersion, "v1.13.") {
kubernetesVersion = "v1.33.4"
}
text := string(content)
if strings.Contains(text, "\nkubernetesVersion:") {
start := strings.Index(text, "\nkubernetesVersion:") + 1
end := strings.Index(text[start:], "\n")
if end == -1 {
text = text[:start] + "kubernetesVersion: " + kubernetesVersion
} else { } else {
text = text[:start] + "kubernetesVersion: " + kubernetesVersion + text[start+end:] secrets, err := utils.RunCommandOutputInDir(generatedDir, talhelperPath, "gensecret")
if err != nil {
return err
}
if err := os.WriteFile(secretPath, secrets, 0600); err != nil {
return err
}
} }
_ = os.WriteFile(path, []byte(text), 0644)
return path
} }
updated := strings.Replace(text, "\nendpoint:", "\nkubernetesVersion: "+kubernetesVersion+"\nendpoint:", 1) return utils.RunCommandInDir(generatedDir, talhelperPath, "genconfig", "--config-file", "talconfig.yaml", "--secret-file", filepath.Base(secretPath))
_ = os.WriteFile(path, []byte(updated), 0644)
return path
} }
func renderTerraformTFVars(cfg config.Config) string { func renderTerraformTFVars(cfg config.Config) string {
var builder strings.Builder var builder strings.Builder
builder.WriteString(fmt.Sprintf("proxmox_api_url = %q\n", cfg.Talos.Proxmox.APIURL)) builder.WriteString(fmt.Sprintf("proxmox_api_url = %q\n", cfg.Talos.Proxmox.APIURL))
builder.WriteString(fmt.Sprintf("proxmox_node = %q\n", cfg.Talos.Proxmox.DefaultNode)) builder.WriteString(fmt.Sprintf("proxmox_node = %q\n", cfg.Talos.Proxmox.DefaultNode))
builder.WriteString(fmt.Sprintf("proxmox_api_token = %q\n", cfg.Talos.Proxmox.APITokenID+"="+cfg.Talos.Proxmox.APITokenSecret))
builder.WriteString(fmt.Sprintf("proxmox_pool = %q\n", cfg.Talos.Proxmox.Pool)) builder.WriteString(fmt.Sprintf("proxmox_pool = %q\n", cfg.Talos.Proxmox.Pool))
builder.WriteString(fmt.Sprintf("cluster_name = %q\n", cfg.Talos.Cluster.Name)) builder.WriteString(fmt.Sprintf("cluster_name = %q\n", cfg.Talos.Cluster.Name))
builder.WriteString(fmt.Sprintf("cluster_domain = %q\n", cfg.Talos.Cluster.Domain)) builder.WriteString(fmt.Sprintf("cluster_domain = %q\n", cfg.Talos.Cluster.Domain))
builder.WriteString(fmt.Sprintf("talos_factory_schematic_id = %q\n", cfg.Talos.Image.SchematicID)) builder.WriteString(fmt.Sprintf("talos_factory_schematic_id = %q\n", cfg.Talos.Image.SchematicID))
builder.WriteString(fmt.Sprintf("talos_version = %q\n", cfg.Talos.Image.TalosVersion)) builder.WriteString(fmt.Sprintf("talos_version = %q\n", cfg.Talos.Image.TalosVersion))
builder.WriteString(fmt.Sprintf("kubernetes_version = %q\n", cfg.Talos.Image.KubernetesVersion))
builder.WriteString("cni_name = \"none\"\n") builder.WriteString("cni_name = \"none\"\n")
isoStorage := cfg.Talos.Image.Storage isoStorage := cfg.Talos.Image.Storage
if isoStorage == cfg.Talos.Cluster.DiskStorage { if isoStorage == cfg.Talos.Cluster.DiskStorage {
@ -296,16 +630,19 @@ func renderTerraformTFVars(cfg config.Config) string {
builder.WriteString(fmt.Sprintf("dns_servers = [%s]\n", quoteList(cfg.Talos.Cluster.DNSServers))) builder.WriteString(fmt.Sprintf("dns_servers = [%s]\n", quoteList(cfg.Talos.Cluster.DNSServers)))
builder.WriteString(fmt.Sprintf("control_plane_vip = %q\n", cfg.Talos.Cluster.ControlPlaneVIP)) builder.WriteString(fmt.Sprintf("control_plane_vip = %q\n", cfg.Talos.Cluster.ControlPlaneVIP))
builder.WriteString("node_interfaces = {\n") builder.WriteString("node_interfaces = {\n")
for node, iface := range cfg.Talos.Proxmox.NodeInterfaces { for _, node := range sortedKeys(cfg.Talos.Proxmox.NodeInterfaces) {
iface := cfg.Talos.Proxmox.NodeInterfaces[node]
builder.WriteString(fmt.Sprintf(" %q = %q\n", node, iface)) builder.WriteString(fmt.Sprintf(" %q = %q\n", node, iface))
} }
builder.WriteString("}\n") builder.WriteString("}\n")
builder.WriteString("node_addresses = {\n") builder.WriteString("node_addresses = {\n")
for node, addr := range cfg.Talos.Proxmox.NodeAddresses { for _, node := range sortedKeys(cfg.Talos.Proxmox.NodeAddresses) {
addr := cfg.Talos.Proxmox.NodeAddresses[node]
builder.WriteString(fmt.Sprintf(" %q = %q\n", node, addr)) builder.WriteString(fmt.Sprintf(" %q = %q\n", node, addr))
} }
builder.WriteString("}\n") builder.WriteString("}\n")
builder.WriteString(fmt.Sprintf("create_vlan_interface = %t\n", cfg.Talos.Cluster.CreateVLANInterface)) builder.WriteString(fmt.Sprintf("create_vlan_interface = %t\n", cfg.Talos.Cluster.CreateVLANInterface))
builder.WriteString(fmt.Sprintf("manage_network_bridges = %t\n", cfg.Talos.Cluster.ManageNetworkBridges))
builder.WriteString("image_cache_proxy = { enabled = false, ip = \"\", port = 3128 }\n") builder.WriteString("image_cache_proxy = { enabled = false, ip = \"\", port = 3128 }\n")
builder.WriteString("nodes = [\n") builder.WriteString("nodes = [\n")
for _, node := range cfg.Talos.Nodes { for _, node := range cfg.Talos.Nodes {
@ -349,3 +686,12 @@ func quoteList(values []string) string {
} }
return strings.Join(quoted, ", ") return strings.Join(quoted, ", ")
} }
func sortedKeys(values map[string]string) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}

View file

@ -2,6 +2,7 @@ package bootstrap
import ( import (
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@ -27,3 +28,124 @@ func TestRenderCiliumConfig(t *testing.T) {
t.Fatalf("Cilium configuration was not rendered: %s", content) t.Fatalf("Cilium configuration was not rendered: %s", content)
} }
} }
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}\n"), 0644); err != nil {
t.Fatal(err)
}
cfg := config.Config{Delivery: config.DeliveryConfig{WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"}}
if err := renderDeliveryConfig(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), "/hooks/forgejo") {
t.Fatalf("delivery configuration was not rendered: %s", content)
}
}
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), "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")
}
}
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 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 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 := 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)
}
}
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)
}
}

View file

@ -1,40 +1,78 @@
package config package config
import ( import (
"bytes"
"errors" "errors"
"fmt"
"io"
"net/netip" "net/netip"
"os" "os"
"path/filepath"
"regexp"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/talos" "github.com/Pingu-Studio/MaidnCLI/internal/talos"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
func Load(path string) (Config, error) { func Load(path string) (Config, error) {
cfg, err := LoadRaw(path)
if err != nil {
return cfg, err
}
return Resolve(cfg)
}
func LoadRaw(path string) (Config, error) {
var cfg Config var cfg Config
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return cfg, err return cfg, err
} }
if err := yaml.Unmarshal(data, &cfg); err != nil { decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
if err := decoder.Decode(&cfg); err != nil {
return cfg, err return cfg, err
} }
if err := decoder.Decode(&Config{}); !errors.Is(err, io.EOF) {
return cfg, errors.New("bootstrap config must contain one YAML document")
}
return cfg, nil
}
func Save(path string, cfg Config) error {
resolved, err := Resolve(cfg)
if err != nil {
return err
}
data, err := yaml.Marshal(resolved)
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func WriteRedacted(path string, cfg Config) error {
redacted := cfg
redacted.Git.Token = ""
redacted.Talos.Proxmox.APITokenSecret = ""
redacted.DemocraticCSI.TrueNASAPIKey = ""
data, err := yaml.Marshal(redacted)
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func Resolve(cfg Config) (Config, error) {
applyDefaults(&cfg) applyDefaults(&cfg)
return cfg, Validate(cfg) return cfg, Validate(cfg)
} }
func Save(path string, cfg Config) error {
applyDefaults(&cfg)
if err := Validate(cfg); err != nil {
return err
}
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
func applyDefaults(cfg *Config) { func applyDefaults(cfg *Config) {
if cfg.ClusterID == "" {
cfg.ClusterID = cfg.Talos.Cluster.Name
}
if cfg.Git.Provider == "" { if cfg.Git.Provider == "" {
cfg.Git.Provider = "forgejo" cfg.Git.Provider = "forgejo"
} }
@ -65,9 +103,39 @@ func applyDefaults(cfg *Config) {
if cfg.Templates.CICDRepoRef == "" { if cfg.Templates.CICDRepoRef == "" {
cfg.Templates.CICDRepoRef = "main" cfg.Templates.CICDRepoRef = "main"
} }
if cfg.Templates.ManifestsRepoURL == "" {
cfg.Templates.ManifestsRepoURL = "https://git.pingu.pw/Maidn/cicd-deployment-manifests-template.git"
}
if cfg.Templates.ManifestsRepoRef == "" {
cfg.Templates.ManifestsRepoRef = "main"
}
if cfg.Cilium.TrafficInterface == "" { if cfg.Cilium.TrafficInterface == "" {
cfg.Cilium.TrafficInterface = "eth1" cfg.Cilium.TrafficInterface = "eth1"
} }
if cfg.DemocraticCSI.PortalGroup == "" {
cfg.DemocraticCSI.PortalGroup = "1"
}
if cfg.DemocraticCSI.InitiatorGroup == "" {
cfg.DemocraticCSI.InitiatorGroup = "1"
}
if cfg.Delivery.AppName == "" {
cfg.Delivery.AppName = "easycsr-frontend"
}
if cfg.Delivery.AppRepoURL == "" {
cfg.Delivery.AppRepoURL = strings.TrimRight(cfg.Git.BaseURL, "/") + "/" + cfg.Git.Owner + "/" + cfg.Delivery.AppName + ".git"
}
if cfg.Delivery.AppRepoRef == "" {
cfg.Delivery.AppRepoRef = cfg.Flux.Branch
}
if cfg.Delivery.ImageRepository == "" {
cfg.Delivery.ImageRepository = strings.TrimPrefix(strings.TrimPrefix(cfg.Git.BaseURL, "https://"), "http://") + "/" + strings.ToLower(cfg.Git.Owner) + "/" + cfg.Delivery.AppName
}
if cfg.Delivery.WebhookHostname == "" && cfg.Flux.ClusterDomain != "" {
cfg.Delivery.WebhookHostname = "tekton." + cfg.Flux.ClusterDomain
}
if cfg.Delivery.WebhookPath == "" {
cfg.Delivery.WebhookPath = "/"
}
if cfg.Talos.RepoDirName == "" { if cfg.Talos.RepoDirName == "" {
cfg.Talos.RepoDirName = "maidn-talos-proxmox" cfg.Talos.RepoDirName = "maidn-talos-proxmox"
} }
@ -89,6 +157,21 @@ func applyDefaults(cfg *Config) {
if cfg.Talos.Image.Architecture == "" { if cfg.Talos.Image.Architecture == "" {
cfg.Talos.Image.Architecture = "amd64" cfg.Talos.Image.Architecture = "amd64"
} }
if cfg.Talos.Image.KubernetesVersion == "" && strings.HasPrefix(cfg.Talos.Image.TalosVersion, "v1.13.") {
cfg.Talos.Image.KubernetesVersion = "v1.33.4"
}
if cfg.SOPS.AgeKeyPath == "" && cfg.WorkspaceDir != "" {
cfg.SOPS.AgeKeyPath = filepath.Join(cfg.WorkspaceDir, ".age", "key.txt")
}
if cfg.SOPS.OperationalSecretsPath == "" && cfg.WorkspaceDir != "" {
cfg.SOPS.OperationalSecretsPath = filepath.Join(cfg.WorkspaceDir, "operational-secrets.sops.yaml")
}
if cfg.SOPS.RecoveryIdentityPath == "" && cfg.WorkspaceDir != "" {
cfg.SOPS.RecoveryIdentityPath = filepath.Join(cfg.WorkspaceDir, ".age", "recovery-key.txt")
}
if cfg.SOPS.RecoveryBundlePath == "" && cfg.WorkspaceDir != "" {
cfg.SOPS.RecoveryBundlePath = filepath.Join(cfg.WorkspaceDir, ".recovery", "openbao-recovery.age")
}
if cfg.Talos.BootstrapEndpoint == "" { if cfg.Talos.BootstrapEndpoint == "" {
cfg.Talos.BootstrapEndpoint = cfg.Talos.BootstrapNode cfg.Talos.BootstrapEndpoint = cfg.Talos.BootstrapNode
} }
@ -98,17 +181,35 @@ func applyDefaults(cfg *Config) {
} }
func Validate(cfg Config) error { func Validate(cfg Config) error {
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`).MatchString(cfg.ClusterID) {
return errors.New("clusterId must be a lowercase DNS label")
}
if cfg.WorkspaceDir == "" { if cfg.WorkspaceDir == "" {
return errors.New("workspaceDir is required") return errors.New("workspaceDir is required")
} }
if cfg.Git.Provider != "forgejo" {
return errors.New("git provider must be forgejo")
}
if cfg.Git.BaseURL == "" || cfg.Git.Username == "" || cfg.Git.Owner == "" { if cfg.Git.BaseURL == "" || cfg.Git.Username == "" || cfg.Git.Owner == "" {
return errors.New("git baseUrl, username, and owner are required") return errors.New("git baseUrl, username, and owner are required")
} }
if cfg.Flux.RepoName == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ManifestsRepo == "" { if cfg.Git.Token == "" {
return errors.New("flux repoName, manifestsRepo, and clusterPath are required") return errors.New("git token is required; SSH bootstrap is not implemented")
} }
if cfg.Templates.TalosRepoURL == "" || cfg.Talos.RepoDirName == "" { if cfg.Flux.RepoName == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.ClusterDomain == "" {
return errors.New("talos template repo and checkout dir are required") return errors.New("flux repoName, clusterDomain, manifestsRepo, and clusterPath are required")
}
if cfg.Delivery.AppName == "" || cfg.Delivery.AppRepoURL == "" || cfg.Delivery.AppRepoRef == "" || cfg.Delivery.ImageRepository == "" || cfg.Delivery.WebhookHostname == "" || cfg.Delivery.WebhookPath == "" {
return errors.New("delivery appName, appRepoUrl, appRepoRef, imageRepository, webhookHostname, and webhookPath are required")
}
if strings.ContainsAny(cfg.Delivery.WebhookHostname, "/:@?#") || !strings.HasPrefix(cfg.Delivery.WebhookPath, "/") || strings.ContainsAny(cfg.Delivery.WebhookPath, "?#") {
return errors.New("delivery webhookHostname must be a hostname and webhookPath must be an absolute path")
}
if cfg.Templates.TalosRepoURL == "" || cfg.Templates.TalosRepoRef == "" || cfg.Templates.CICDRepoURL == "" || cfg.Templates.CICDRepoRef == "" || cfg.Templates.ManifestsRepoURL == "" || cfg.Templates.ManifestsRepoRef == "" {
return errors.New("all template repository URLs and refs are required")
}
if cfg.Talos.RepoDirName == "" {
return errors.New("talos checkout dir is required")
} }
if cfg.Talos.TerraformDir == "" || cfg.Talos.GeneratedDir == "" || cfg.Talos.ConfigFileName == "" { if cfg.Talos.TerraformDir == "" || cfg.Talos.GeneratedDir == "" || cfg.Talos.ConfigFileName == "" {
return errors.New("talos terraformDir, generatedDir, and configFileName are required") return errors.New("talos terraformDir, generatedDir, and configFileName are required")
@ -119,11 +220,11 @@ func Validate(cfg Config) error {
if cfg.Talos.Cluster.Name == "" || cfg.Talos.Cluster.Domain == "" { if cfg.Talos.Cluster.Name == "" || cfg.Talos.Cluster.Domain == "" {
return errors.New("talos cluster name and domain are required") return errors.New("talos cluster name and domain are required")
} }
if cfg.Talos.Image.TalosVersion == "" || cfg.Talos.Image.SchematicID == "" { if cfg.Talos.Image.TalosVersion == "" || cfg.Talos.Image.KubernetesVersion == "" || cfg.Talos.Image.SchematicID == "" {
return errors.New("talos version and schematicId are required") return errors.New("talos version, kubernetesVersion, and schematicId are required")
} }
if err := talos.RequireCLICompatibility(cfg.Talos.Image.TalosVersion); err != nil { if len(cfg.Talos.Image.SchematicID) < 12 {
return err return errors.New("talos schematicId must contain at least 12 characters")
} }
if cfg.Talos.Image.UpdateMode != "manual" && cfg.Talos.Image.UpdateMode != "download" { if cfg.Talos.Image.UpdateMode != "manual" && cfg.Talos.Image.UpdateMode != "download" {
return errors.New("talos image updateMode must be manual or download") return errors.New("talos image updateMode must be manual or download")
@ -142,22 +243,85 @@ func Validate(cfg Config) error {
if err != nil || start.BitLen() != end.BitLen() || start.Compare(end) > 0 { if err != nil || start.BitLen() != end.BitLen() || start.Compare(end) > 0 {
return errors.New("cilium loadBalancerEnd must be an IP address after loadBalancerStart") return errors.New("cilium loadBalancerEnd must be an IP address after loadBalancerStart")
} }
for _, node := range cfg.Talos.Nodes { if cfg.DemocraticCSI.TrueNASAPIKey == "" || cfg.DemocraticCSI.TrueNASHost == "" || cfg.DemocraticCSI.TargetPortal == "" || cfg.DemocraticCSI.ShareHost == "" || cfg.DemocraticCSI.DatasetParentNFS == "" || cfg.DemocraticCSI.DatasetSnapshotsNFS == "" || cfg.DemocraticCSI.AllowedNetworks == "" || cfg.DemocraticCSI.NameSuffix == "" || cfg.DemocraticCSI.PortalGroup == "" || cfg.DemocraticCSI.InitiatorGroup == "" {
return errors.New("all democraticCsi settings are required")
}
if _, err := netip.ParsePrefix(cfg.DemocraticCSI.AllowedNetworks); err != nil {
return errors.New("democraticCsi allowedNetworks must be a CIDR")
}
seenNames := map[string]bool{}
seenVMIDs := map[int]bool{}
seenIPs := map[netip.Addr]bool{}
seenMACs := map[string]bool{}
controlPlanes := 0
trafficVLAN := 0
for index, node := range cfg.Talos.Nodes {
if node.Name == "" || seenNames[node.Name] || node.VMID <= 0 || seenVMIDs[node.VMID] {
return errors.New("talos node names and VMIDs must be unique and non-zero")
}
seenNames[node.Name] = true
seenVMIDs[node.VMID] = true
if node.Role != "controlplane" && node.Role != "worker" {
return fmt.Errorf("talos node %q role must be controlplane or worker", node.Name)
}
if node.Role == "controlplane" {
controlPlanes++
}
if index == 0 && node.Role != "controlplane" {
return errors.New("the first talos node must be a controlplane")
}
if len(node.Networks) < 2 { if len(node.Networks) < 2 {
return errors.New("cilium requires a second static traffic network on every talos node") return errors.New("cilium requires a second static traffic network on every talos node")
} }
primary := node.Networks[0]
primaryIP, err := netip.ParseAddr(primary.IP)
if err != nil || primary.Gateway == "" {
return errors.New("talos primary networks require a static IP and gateway")
}
if seenIPs[primaryIP] {
return errors.New("talos node IPs must be unique")
}
seenIPs[primaryIP] = true
traffic := node.Networks[1] traffic := node.Networks[1]
prefix, err := netip.ParsePrefix(traffic.CIDR) prefix, err := netip.ParsePrefix(traffic.CIDR)
if err != nil || traffic.IP == "" || traffic.Gateway != "" { if err != nil || traffic.IP == "" || traffic.Gateway != "" {
return errors.New("cilium traffic networks require a static IP, valid CIDR, and no gateway") return errors.New("cilium traffic networks require a static IP, valid CIDR, and no gateway")
} }
nodeIP, err := netip.ParseAddr(traffic.IP) nodeIP, err := netip.ParseAddr(traffic.IP)
if err != nil || !prefix.Contains(nodeIP) || nodeIP == start || nodeIP == end { if err != nil || !prefix.Contains(nodeIP) || (nodeIP.Compare(start) >= 0 && nodeIP.Compare(end) <= 0) {
return errors.New("cilium traffic node IP must belong to its CIDR and not use the LoadBalancer range") return errors.New("cilium traffic node IP must belong to its CIDR and not use the LoadBalancer range")
} }
if seenIPs[nodeIP] {
return errors.New("talos node IPs must be unique")
}
seenIPs[nodeIP] = true
if trafficVLAN == 0 {
trafficVLAN = traffic.VLANID
} else if trafficVLAN != traffic.VLANID {
return errors.New("cilium traffic networks must use one VLAN")
}
if !prefix.Contains(start) || !prefix.Contains(end) { if !prefix.Contains(start) || !prefix.Contains(end) {
return errors.New("cilium LoadBalancer range must belong to every node traffic network") return errors.New("cilium LoadBalancer range must belong to every node traffic network")
} }
for _, network := range node.Networks {
if network.VLANID < 1 || network.VLANID > 4094 {
return errors.New("talos VLAN IDs must be between 1 and 4094")
}
if network.MACAddress != "" {
mac := strings.ToLower(network.MACAddress)
if seenMACs[mac] {
return errors.New("talos network MAC addresses must be unique")
}
seenMACs[mac] = true
}
}
}
if controlPlanes == 0 {
return errors.New("at least one controlplane node is required")
} }
return nil return nil
} }
func Preflight(cfg Config) error {
return talos.RequireCLICompatibility(cfg.Talos.Image.TalosVersion)
}

View file

@ -0,0 +1,81 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func validConfig(t *testing.T) Config {
t.Helper()
return Config{
ClusterID: "test-cluster",
WorkspaceDir: t.TempDir(),
Git: GitConfig{Provider: "forgejo", BaseURL: "https://git.example.test", Username: "bot", Token: "token", Owner: "test-org"},
Flux: FluxConfig{RepoName: "cluster", Branch: "main", ClusterPath: "./clusters/test", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
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"},
Talos: TalosConfig{
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
Proxmox: TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
Cluster: TalosClusterConfig{Name: "test-cluster", Domain: "example.test"},
Image: TalosImageConfig{TalosVersion: "v1.13.6", KubernetesVersion: "v1.33.4", SchematicID: "abcdefghijkl"},
Nodes: []TalosNode{{Name: "cp-01", VMID: 100, Role: "controlplane", Networks: []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 TestResolveDefaultsWebhookEndpoint(t *testing.T) {
cfg, err := Resolve(validConfig(t))
if err != nil {
t.Fatal(err)
}
if cfg.Delivery.WebhookURL() != "https://tekton.example.test/" {
t.Fatalf("WebhookURL() = %q", cfg.Delivery.WebhookURL())
}
}
func TestLoadRejectsUnknownFields(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("workspaceDir: test\nunknown: value\n"), 0600); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "field unknown") {
t.Fatalf("Load() error = %v, want unknown field error", err)
}
}
func TestLoadRawAllowsCompletionBeforeValidation(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("workspaceDir: test\n"), 0600); err != nil {
t.Fatal(err)
}
if _, err := LoadRaw(path); err != nil {
t.Fatalf("LoadRaw() error = %v", err)
}
}
func TestWriteRedactedOmitsCredentials(t *testing.T) {
cfg := validConfig(t)
path := filepath.Join(t.TempDir(), "resolved.yaml")
if err := WriteRedacted(path, cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "token: token") || strings.Contains(string(data), "apiTokenSecret: secret") || strings.Contains(string(data), "truenasApiKey: api-key") {
t.Fatalf("redacted config contains credentials: %s", data)
}
}
func TestValidateRejectsLoadBalancerAddressOnNode(t *testing.T) {
cfg := validConfig(t)
cfg.Talos.Nodes[0].Networks[1].IP = cfg.Cilium.LoadBalancerStart
if _, err := Resolve(cfg); err == nil {
t.Fatal("Resolve() succeeded with traffic node address in LoadBalancer range")
}
}

View file

@ -3,12 +3,51 @@ package config
import "net/url" import "net/url"
type Config struct { type Config struct {
WorkspaceDir string `yaml:"workspaceDir"` ClusterID string `yaml:"clusterId"`
Git GitConfig `yaml:"git"` WorkspaceDir string `yaml:"workspaceDir"`
Flux FluxConfig `yaml:"flux"` Git GitConfig `yaml:"git"`
Talos TalosConfig `yaml:"talos"` Flux FluxConfig `yaml:"flux"`
Templates TemplateConfig `yaml:"templates"` Talos TalosConfig `yaml:"talos"`
Cilium CiliumConfig `yaml:"cilium"` Templates TemplateConfig `yaml:"templates"`
Cilium CiliumConfig `yaml:"cilium"`
DemocraticCSI DemocraticCSIConfig `yaml:"democraticCsi"`
Delivery DeliveryConfig `yaml:"delivery"`
SOPS SOPSConfig `yaml:"sops"`
}
type DemocraticCSIConfig struct {
TrueNASAPIKey string `yaml:"truenasApiKey"`
TrueNASHost string `yaml:"truenasHost"`
TargetPortal string `yaml:"targetPortal"`
ShareHost string `yaml:"shareHost"`
DatasetParentNFS string `yaml:"datasetParentNfs"`
DatasetSnapshotsNFS string `yaml:"datasetSnapshotsNfs"`
AllowedNetworks string `yaml:"allowedNetworks"`
NameSuffix string `yaml:"nameSuffix"`
PortalGroup string `yaml:"portalGroup"`
InitiatorGroup string `yaml:"initiatorGroup"`
}
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"`
}
func (c DeliveryConfig) WebhookURL() string {
return (&url.URL{Scheme: "https", Host: c.WebhookHostname, Path: c.WebhookPath}).String()
}
type SOPSConfig struct {
AgeKeyPath string `yaml:"ageKeyPath"`
BootstrapSecretsPath string `yaml:"bootstrapSecretsPath,omitempty"`
OperationalSecretsPath string `yaml:"operationalSecretsPath"`
RecoveryRecipient string `yaml:"recoveryRecipient"`
RecoveryIdentityPath string `yaml:"recoveryIdentityPath"`
RecoveryBundlePath string `yaml:"recoveryBundlePath"`
} }
type GitConfig struct { type GitConfig struct {
@ -31,10 +70,12 @@ type FluxConfig struct {
} }
type TemplateConfig struct { type TemplateConfig struct {
TalosRepoURL string `yaml:"talosRepoUrl"` TalosRepoURL string `yaml:"talosRepoUrl"`
TalosRepoRef string `yaml:"talosRepoRef"` TalosRepoRef string `yaml:"talosRepoRef"`
CICDRepoURL string `yaml:"cicdRepoUrl"` CICDRepoURL string `yaml:"cicdRepoUrl"`
CICDRepoRef string `yaml:"cicdRepoRef"` CICDRepoRef string `yaml:"cicdRepoRef"`
ManifestsRepoURL string `yaml:"manifestsRepoUrl"`
ManifestsRepoRef string `yaml:"manifestsRepoRef"`
} }
type CiliumConfig struct { type CiliumConfig struct {
@ -73,21 +114,23 @@ type TalosProxmoxConfig struct {
} }
type TalosClusterConfig struct { type TalosClusterConfig struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Domain string `yaml:"domain"` Domain string `yaml:"domain"`
ControlPlaneVIP string `yaml:"controlPlaneVip"` ControlPlaneVIP string `yaml:"controlPlaneVip"`
DNSServers []string `yaml:"dnsServers"` DNSServers []string `yaml:"dnsServers"`
DiskStorage string `yaml:"diskStorage"` DiskStorage string `yaml:"diskStorage"`
AdditionalStorage string `yaml:"additionalStorage"` AdditionalStorage string `yaml:"additionalStorage"`
CreateVLANInterface bool `yaml:"createVlanInterface"` CreateVLANInterface bool `yaml:"createVlanInterface"`
ManageNetworkBridges bool `yaml:"manageNetworkBridges"`
} }
type TalosImageConfig struct { type TalosImageConfig struct {
TalosVersion string `yaml:"talosVersion"` TalosVersion string `yaml:"talosVersion"`
SchematicID string `yaml:"schematicId"` KubernetesVersion string `yaml:"kubernetesVersion"`
UpdateMode string `yaml:"updateMode"` SchematicID string `yaml:"schematicId"`
Storage string `yaml:"storage"` UpdateMode string `yaml:"updateMode"`
Architecture string `yaml:"architecture"` Storage string `yaml:"storage"`
Architecture string `yaml:"architecture"`
} }
type TalosNode struct { type TalosNode struct {

View file

@ -8,10 +8,9 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"time" "time"
"github.com/Pingu-Studio/MaidnCLI/internal/utils"
) )
type RepoManager struct { type RepoManager struct {
@ -21,17 +20,49 @@ type RepoManager struct {
Username string Username string
ManifestsRepoName string ManifestsRepoName string
FluxRepoName string FluxRepoName string
Branch string
MigrationBranch string
MigrationPending bool
HTTPClient *http.Client
} }
type createRepoRequest struct { type createRepoRequest struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Private bool `json:"private"` Private bool `json:"private"`
AutoInit bool `json:"auto_init"` AutoInit bool `json:"auto_init"`
DefaultBranch string `json:"default_branch"` DefaultBranch string `json:"default_branch"`
} }
func NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo string) *RepoManager { type pullRequestRequest struct {
Title string `json:"title"`
Head string `json:"head"`
Base string `json:"base"`
}
type hook struct {
ID int64 `json:"id"`
URL string `json:"url"`
}
type hookRequest struct {
Type string `json:"type,omitempty"`
Active bool `json:"active"`
AuthorizationHeader string `json:"authorization_header"`
Config map[string]string `json:"config"`
Events []string `json:"events"`
}
type APIError struct {
StatusCode int
Status string
}
func (e *APIError) Error() string {
return fmt.Sprintf("forgejo returned %s", e.Status)
}
func NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch string) *RepoManager {
return &RepoManager{ return &RepoManager{
BaseURL: strings.TrimRight(baseURL, "/"), BaseURL: strings.TrimRight(baseURL, "/"),
Token: token, Token: token,
@ -39,6 +70,9 @@ func NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo str
Username: username, Username: username,
ManifestsRepoName: manifestsRepo, ManifestsRepoName: manifestsRepo,
FluxRepoName: fluxRepo, FluxRepoName: fluxRepo,
Branch: branch,
MigrationBranch: migrationBranch,
HTTPClient: &http.Client{Timeout: 15 * time.Second},
} }
} }
@ -46,83 +80,240 @@ func (rm *RepoManager) InitializeAll(createRepo func(string) error, createFlux f
if err := rm.ensureRepo(rm.ManifestsRepoName, "Centralized deployment manifests for Flux CD", createRepo); err != nil { if err := rm.ensureRepo(rm.ManifestsRepoName, "Centralized deployment manifests for Flux CD", createRepo); err != nil {
return err return err
} }
if err := rm.ensureRepo(rm.FluxRepoName, "Flux CD cluster configurations", createFlux); err != nil { return rm.ensureRepo(rm.FluxRepoName, "Flux CD cluster configurations", createFlux)
}
func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error {
exists, err := rm.repoExists(name)
if err != nil {
return err return err
} }
if !exists {
if err := rm.createRepo(name, description); err != nil {
return err
}
}
return rm.setupRepository(CloneURL(rm.BaseURL, rm.Owner, name), name, exists, createStructure)
}
func (rm *RepoManager) repoExists(name string) (bool, error) {
status, err := rm.apiRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/repos/%s/%s", rm.BaseURL, rm.Owner, name), nil)
if err != nil {
return false, err
}
switch status {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("unexpected Forgejo repository lookup status %d", status)
}
}
func (rm *RepoManager) createRepo(name, description string) 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: true, DefaultBranch: rm.Branch})
if err != nil {
return err
}
status, err := rm.apiRequest(http.MethodPost, createURL, body)
if err != nil {
return err
}
if status != http.StatusCreated && status != http.StatusConflict {
return fmt.Errorf("unexpected Forgejo create repository status %d", status)
}
return nil return nil
} }
func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error { func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool, createStructure func(string) error) error {
repoURL := fmt.Sprintf("%s/%s/%s.git", rm.BaseURL, rm.Owner, name)
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s", rm.BaseURL, rm.Owner, name)
if err := rm.apiRequest(http.MethodGet, apiURL, nil); err != nil {
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, _ := json.Marshal(createRepoRequest{Name: name, Description: description, Private: true, AutoInit: false, DefaultBranch: "main"})
if apiErr := rm.apiRequest(http.MethodPost, createURL, body); apiErr != nil {
return apiErr
}
}
return rm.setupRepository(repoURL, name, createStructure)
}
func (rm *RepoManager) setupRepository(repoURL, repoName string, createStructure func(string) error) error {
tempDir, err := os.MkdirTemp("", "repo-setup-*") tempDir, err := os.MkdirTemp("", "repo-setup-*")
if err != nil { if err != nil {
return err return err
} }
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
if err := utils.RunCommand("git", "clone", repoURL, tempDir); err != nil { cleanupAskPass, environment, err := rm.gitEnvironment(tempDir)
if err != nil {
return err return err
} }
if err := utils.RunCommandInDir(tempDir, "git", "checkout", "-B", "main"); err != nil { defer cleanupAskPass()
if err := runGit("", environment, "clone", "--branch", rm.Branch, repoURL, tempDir); err != nil {
return err return err
} }
targetBranch := rm.Branch
if existing {
targetBranch = rm.MigrationBranch
if err := runGit(tempDir, environment, "checkout", "-B", targetBranch, "origin/"+rm.Branch); err != nil {
return err
}
}
if err := createStructure(tempDir); err != nil { if err := createStructure(tempDir); err != nil {
return err return err
} }
return commitAndPush(tempDir, repoName) changed, err := commitAndPush(tempDir, repoName, targetBranch, environment)
if err != nil || !changed || !existing {
return err
}
rm.MigrationPending = true
return rm.createMigrationPullRequest(repoName, targetBranch)
} }
func commitAndPush(tempDir, repoName string) error { func (rm *RepoManager) createMigrationPullRequest(repo, branch string) error {
utils.RunCommandInDir(tempDir, "git", "config", "user.name", "Maidn") body, err := json.Marshal(pullRequestRequest{Title: "feat: bootstrap Maidn CI/CD structure", Head: branch, Base: rm.Branch})
utils.RunCommandInDir(tempDir, "git", "config", "user.email", "maidn@free-maidn.com") if err != nil {
utils.RunCommandInDir(tempDir, "git", "add", ".")
statusCmd := exec.Command("git", "status", "--porcelain")
statusCmd.Dir = tempDir
output, _ := statusCmd.Output()
if len(output) == 0 {
return nil
}
if err := utils.RunCommandInDir(tempDir, "git", "commit", "-m", "feat: initialize repository structure for CI/CD"); err != nil {
return err return err
} }
if err := utils.RunCommandInDir(tempDir, "git", "push", "origin", "main"); err != nil { status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", rm.BaseURL, rm.Owner, repo), body)
if err != nil {
return err return err
} }
fmt.Printf("[SUCCESS] Changes pushed to %s successfully.\n", repoName) if status != http.StatusCreated && status != http.StatusUnprocessableEntity {
return fmt.Errorf("unexpected Forgejo pull request status %d", status)
}
return nil return nil
} }
func (rm *RepoManager) apiRequest(method, endpoint string, body []byte) error { func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) error {
client := http.Client{Timeout: 15 * time.Second} if webhookURL == "" || authorization == "" {
request, err := http.NewRequest(method, endpoint, bytes.NewReader(body)) return fmt.Errorf("Forgejo webhook URL and authorization are required")
}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", rm.BaseURL, rm.Owner, repo)
var hooks []hook
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &hooks)
if err != nil { if err != nil {
return err return err
} }
if status != http.StatusOK {
return fmt.Errorf("unexpected Forgejo webhook lookup status %d", status)
}
createRequest := hookRequest{
Type: "forgejo",
Active: true,
AuthorizationHeader: authorization,
Config: map[string]string{"url": webhookURL, "content_type": "json"},
Events: []string{"push", "pull_request"},
}
for _, existing := range hooks {
if existing.URL != webhookURL {
continue
}
request, err := json.Marshal(hookRequest{Active: createRequest.Active, AuthorizationHeader: createRequest.AuthorizationHeader, Config: createRequest.Config, Events: createRequest.Events})
if err != nil {
return err
}
status, err = rm.apiRequest(http.MethodPatch, fmt.Sprintf("%s/%d", endpoint, existing.ID), request)
if err != nil {
return err
}
if status != http.StatusOK {
return fmt.Errorf("unexpected Forgejo webhook update status %d", status)
}
return nil
}
request, err := json.Marshal(createRequest)
if err != nil {
return err
}
status, err = rm.apiRequest(http.MethodPost, endpoint, request)
if err != nil {
return err
}
if status != http.StatusCreated {
return fmt.Errorf("unexpected Forgejo webhook 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 {
return false, err
}
}
cmd := exec.Command("git", "status", "--porcelain")
cmd.Dir = tempDir
cmd.Env = environment
output, err := cmd.Output()
if err != nil {
return false, err
}
if len(output) == 0 {
return false, nil
}
if err := runGit(tempDir, environment, "commit", "-m", "feat: initialize repository structure for CI/CD"); err != nil {
return false, err
}
if err := runGit(tempDir, environment, "push", "origin", "HEAD:"+branch); err != nil {
return false, err
}
fmt.Printf("[SUCCESS] Changes pushed to %s successfully.\n", repoName)
return true, nil
}
func (rm *RepoManager) gitEnvironment(tempDir string) (func(), []string, error) {
if rm.Token == "" {
return func() {}, append(os.Environ(), "GIT_TERMINAL_PROMPT=0"), nil
}
askPassDir, err := os.MkdirTemp("", "maidn-askpass-*")
if err != nil {
return nil, nil, err
}
path := filepath.Join(askPassDir, "maidn-askpass")
content := "#!/bin/sh\ncase \"$1\" in *Username*) printf '%s\\n' \"$MAIDN_GIT_USERNAME\" ;; *) printf '%s\\n' \"$MAIDN_GIT_TOKEN\" ;; esac\n"
if runtime.GOOS == "windows" {
path += ".cmd"
content = "@echo off\r\necho %~1 | findstr /I Username >nul\r\nif not errorlevel 1 (echo %MAIDN_GIT_USERNAME%) else (echo %MAIDN_GIT_TOKEN%)\r\n"
}
if err := os.WriteFile(path, []byte(content), 0700); err != nil {
_ = 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)
return func() { _ = os.RemoveAll(askPassDir) }, environment, nil
}
func runGit(dir string, environment []string, args ...string) error {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = environment
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func (rm *RepoManager) apiRequest(method, endpoint string, body []byte) (int, error) {
return rm.apiJSONRequest(method, endpoint, body, nil)
}
func (rm *RepoManager) apiJSONRequest(method, endpoint string, body []byte, result any) (int, error) {
client := rm.HTTPClient
if client == nil {
client = &http.Client{Timeout: 15 * time.Second}
}
request, err := http.NewRequest(method, endpoint, bytes.NewReader(body))
if err != nil {
return 0, err
}
request.Header.Set("Authorization", "token "+rm.Token) request.Header.Set("Authorization", "token "+rm.Token)
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
response, err := client.Do(request) response, err := client.Do(request)
if err != nil { if err != nil {
return err return 0, err
} }
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode >= 300 { if response.StatusCode >= 500 || response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
return fmt.Errorf("forgejo returned %s for %s", response.Status, endpoint) return 0, &APIError{StatusCode: response.StatusCode, Status: response.Status}
} }
return nil if result != nil && response.StatusCode != http.StatusNoContent {
if err := json.NewDecoder(response.Body).Decode(result); err != nil {
return 0, err
}
}
return response.StatusCode, nil
} }
func FluxSourceURL(baseURL, owner, repo string) string { func FluxSourceURL(baseURL, owner, repo string) string {
@ -132,7 +323,3 @@ func FluxSourceURL(baseURL, owner, repo string) string {
func CloneURL(baseURL, owner, repo string) string { func CloneURL(baseURL, owner, repo string) string {
return fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(baseURL, "/"), owner, repo) return fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(baseURL, "/"), owner, repo)
} }
func RepoPath(parent, dirName string) string {
return filepath.Join(parent, dirName)
}

View file

@ -0,0 +1,96 @@
package forgejo
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestRepoExistsOnlyCreatesOnNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
if _, err := manager.repoExists("cluster"); err == nil {
t.Fatal("repoExists() accepted an unauthorized response")
}
}
func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
switch request.Method {
case http.MethodGet:
if request.URL.Path != "/api/v1/repos/owner/app/hooks" {
t.Fatalf("unexpected lookup path %q", request.URL.Path)
}
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, URL: "https://tekton.example.test/"}})
case http.MethodPatch:
if request.URL.Path != "/api/v1/repos/owner/app/hooks/7" {
t.Fatalf("unexpected update path %q", request.URL.Path)
}
var body hookRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Type != "" || body.AuthorizationHeader != "Bearer token" || len(body.Events) != 2 {
t.Fatalf("unexpected hook request: %#v", body)
}
writer.WriteHeader(http.StatusOK)
default:
t.Fatalf("unexpected method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
if err := manager.EnsureWebhook("app", "https://tekton.example.test/", "Bearer token"); err != nil {
t.Fatal(err)
}
if requests != 2 {
t.Fatalf("requests = %d, want 2", requests)
}
}
func TestEnsureWebhookCreatesMissingWebhook(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodGet:
_ = json.NewEncoder(writer).Encode([]hook{})
case http.MethodPost:
var body hookRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Type != "forgejo" || body.Config["content_type"] != "json" {
t.Fatalf("unexpected hook 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", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
if err := manager.EnsureWebhook("app", "https://tekton.example.test/", "Bearer token"); err != nil {
t.Fatal(err)
}
}
func TestRepoExistsReturnsFalseOnNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
exists, err := manager.repoExists("cluster")
if err != nil || exists {
t.Fatalf("repoExists() = (%t, %v), want (false, nil)", exists, err)
}
}

View file

@ -11,18 +11,18 @@ import (
"github.com/Pingu-Studio/MaidnCLI/internal/utils" "github.com/Pingu-Studio/MaidnCLI/internal/utils"
) )
func BuildFluxConfig(chartOwner, gitURL, manifestsRepo string) string { func BuildFluxConfig(gitURL, manifestsRepo, branch string) string {
return fmt.Sprintf(`--- return fmt.Sprintf(`---
apiVersion: source.toolkit.fluxcd.io/v1 apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository kind: GitRepository
metadata: metadata:
name: cicd-deployment-manifests name: %s
namespace: flux-system namespace: flux-system
spec: spec:
interval: 1m0s interval: 1m0s
url: %s url: %s
ref: ref:
branch: main branch: %s
secretRef: secretRef:
name: flux-system name: flux-system
--- ---
@ -37,7 +37,11 @@ spec:
prune: true prune: true
sourceRef: sourceRef:
kind: GitRepository kind: GitRepository
name: cicd-deployment-manifests name: %s
decryption:
provider: sops
secretRef:
name: sops-age
--- ---
apiVersion: kustomize.toolkit.fluxcd.io/v1 apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization kind: Kustomization
@ -52,7 +56,11 @@ spec:
prune: true prune: true
sourceRef: sourceRef:
kind: GitRepository kind: GitRepository
name: cicd-deployment-manifests name: %s
decryption:
provider: sops
secretRef:
name: sops-age
--- ---
apiVersion: kustomize.toolkit.fluxcd.io/v1 apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization kind: Kustomization
@ -67,7 +75,11 @@ spec:
prune: true prune: true
sourceRef: sourceRef:
kind: GitRepository kind: GitRepository
name: cicd-deployment-manifests name: %s
decryption:
provider: sops
secretRef:
name: sops-age
--- ---
apiVersion: kustomize.toolkit.fluxcd.io/v1 apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization kind: Kustomization
@ -82,8 +94,12 @@ spec:
prune: true prune: true
sourceRef: sourceRef:
kind: GitRepository kind: GitRepository
name: cicd-deployment-manifests name: %s
`, gitURL) decryption:
provider: sops
secretRef:
name: sops-age
`, manifestsRepo, gitURL, branch, manifestsRepo, manifestsRepo, manifestsRepo, manifestsRepo)
} }
type RepoManager struct { type RepoManager struct {

View file

@ -0,0 +1,274 @@
package openbao
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type status struct {
Initialized bool `json:"initialized"`
Sealed bool `json:"sealed"`
}
type recovery struct {
UnsealKeysB64 []string `json:"unseal_keys_b64"`
UnsealThreshold int `json:"unseal_threshold"`
RootToken string `json:"root_token"`
}
type operationalSecrets struct {
Secrets map[string]map[string]string `yaml:"secrets"`
}
func EnsureRecoveryIdentity(identityPath string) (string, error) {
if _, err := os.Stat(identityPath); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(identityPath), 0700); err != nil {
return "", err
}
if err := exec.Command("age-keygen", "-o", identityPath).Run(); err != nil {
return "", fmt.Errorf("create OpenBao recovery identity: %w", err)
}
} else if err != nil {
return "", err
}
recipient, err := exec.Command("age-keygen", "-y", identityPath).Output()
if err != nil {
return "", fmt.Errorf("derive OpenBao recovery recipient: %w", err)
}
if value := strings.TrimSpace(string(recipient)); value != "" {
return value, nil
}
return "", fmt.Errorf("OpenBao recovery recipient is empty")
}
func Initialize(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, operationalSecretsPath string) (map[string]map[string]string, error) {
if err := validateRecoveryRecipient(recipient, bundlePath); err != nil {
return nil, err
}
if err := waitForPod(kubeconfig); err != nil {
return nil, err
}
current, err := getStatus(kubeconfig)
if err != nil {
return nil, err
}
var material recovery
if !current.Initialized {
output, err := execInPod(kubeconfig, nil, "bao", "operator", "init", "-format=json")
if err != nil {
return nil, fmt.Errorf("initialize OpenBao: %w", err)
}
if err := json.Unmarshal(output, &material); err != nil {
return nil, fmt.Errorf("parse OpenBao recovery material: %w", err)
}
if err := encryptRecovery(recipient, bundlePath, output); err != nil {
return nil, err
}
} else {
output, err := decryptRecovery(identityPath, bundlePath)
if err != nil {
return nil, err
}
if err := json.Unmarshal(output, &material); err != nil {
return nil, fmt.Errorf("parse encrypted OpenBao recovery material: %w", err)
}
}
if current.Sealed {
if err := unseal(kubeconfig, material); err != nil {
return nil, err
}
}
reviewerToken, err := kubectlOutput(kubeconfig, "-n", "openbao", "create", "token", "openbao-auth")
if err != nil {
return nil, fmt.Errorf("create OpenBao Kubernetes token reviewer token: %w", err)
}
if err := configureKubernetesAuth(kubeconfig, material.RootToken, string(bytes.TrimSpace(reviewerToken))); err != nil {
return nil, err
}
return seedOperationalSecrets(kubeconfig, material.RootToken, ageKeyPath, operationalSecretsPath)
}
func seedOperationalSecrets(kubeconfig, rootToken, ageKeyPath, path string) (map[string]map[string]string, error) {
cmd := exec.Command("sops", "--decrypt", "--output-type", "yaml", path)
cmd.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+ageKeyPath)
plaintext, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("decrypt operational SOPS secrets: %w", err)
}
var document operationalSecrets
if err := yaml.Unmarshal(plaintext, &document); err != nil {
return nil, fmt.Errorf("parse operational SOPS secrets: %w", err)
}
if len(document.Secrets) == 0 {
return nil, fmt.Errorf("operational SOPS secrets contains no secrets")
}
paths := make([]string, 0, len(document.Secrets))
for secretPath := range document.Secrets {
paths = append(paths, secretPath)
}
sort.Strings(paths)
for _, secretPath := range paths {
values := document.Secrets[secretPath]
if err := writeSecret(kubeconfig, rootToken, secretPath, values); err != nil {
return nil, err
}
}
return document.Secrets, nil
}
func validateRecoveryRecipient(recipient, bundlePath string) error {
if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil {
return err
}
probe, err := os.CreateTemp(filepath.Dir(bundlePath), "age-recipient-*")
if err != nil {
return err
}
probePath := probe.Name()
if err := probe.Close(); err != nil {
return err
}
defer os.Remove(probePath)
cmd := exec.Command("age", "-r", recipient, "-o", probePath)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("validate OpenBao recovery recipient: %w: %s", err, bytes.TrimSpace(output))
}
return nil
}
func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]string) error {
if !regexp.MustCompile(`^[a-z0-9][a-z0-9/_-]*$`).MatchString(secretPath) || len(values) == 0 {
return fmt.Errorf("invalid OpenBao secret path %q", secretPath)
}
keys := make([]string, 0, len(values))
for key := range values {
if !regexp.MustCompile(`^[A-Za-z0-9_.-]+$`).MatchString(key) {
return fmt.Errorf("invalid OpenBao secret key %q", key)
}
keys = append(keys, key)
}
sort.Strings(keys)
arguments := make([]string, 0, len(keys))
input := strings.Builder{}
input.WriteString(rootToken)
input.WriteByte('\n')
for _, key := range keys {
arguments = append(arguments, fmt.Sprintf("%s=\"$value%d\"", key, len(arguments)))
input.WriteString(values[key])
input.WriteByte('\n')
}
reads := make([]string, 0, len(keys))
for index := range keys {
reads = append(reads, fmt.Sprintf("read -r value%d", 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"
_, err := execInPod(kubeconfig, []byte(input.String()), "sh", "-ec", script)
return err
}
func waitForPod(kubeconfig string) error {
deadline := time.Now().Add(10 * time.Minute)
for time.Now().Before(deadline) {
if _, err := kubectlOutput(kubeconfig, "-n", "openbao", "get", "pod", "openbao-0"); err == nil {
if output, _ := execInPod(kubeconfig, nil, "bao", "status", "-format=json"); len(output) > 0 {
return nil
}
}
time.Sleep(2 * time.Second)
}
return fmt.Errorf("OpenBao pod did not become ready")
}
func getStatus(kubeconfig string) (status, error) {
output, err := execInPod(kubeconfig, nil, "bao", "status", "-format=json")
if err != nil && len(output) == 0 {
return status{}, fmt.Errorf("get OpenBao status: %w", err)
}
var current status
if err := json.Unmarshal(output, &current); err != nil {
return status{}, fmt.Errorf("parse OpenBao status: %w", err)
}
return current, nil
}
func unseal(kubeconfig string, material recovery) error {
threshold := material.UnsealThreshold
if threshold == 0 {
threshold = 3
}
if len(material.UnsealKeysB64) < threshold {
return fmt.Errorf("OpenBao recovery bundle contains fewer than %d unseal keys", threshold)
}
for _, key := range material.UnsealKeysB64[:threshold] {
if _, err := execInPod(kubeconfig, []byte(key+"\n"), "sh", "-ec", "read -r key; bao operator unseal \"$key\" >/dev/null"); err != nil {
return fmt.Errorf("unseal OpenBao: %w", err)
}
}
return nil
}
func configureKubernetesAuth(kubeconfig, rootToken, reviewerToken string) error {
const script = `read -r root_token
read -r reviewer_token
export BAO_TOKEN="$root_token"
bao secrets enable -path=secret kv-v2 >/dev/null 2>&1 || true
bao auth enable kubernetes >/dev/null 2>&1 || true
bao write auth/kubernetes/config token_reviewer_jwt="$reviewer_token" kubernetes_host="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}" kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt >/dev/null
cat >/tmp/external-secrets.hcl <<'EOF'
path "secret/data/*" {
capabilities = ["read"]
}
path "secret/metadata/*" {
capabilities = ["list", "read"]
}
EOF
bao policy write external-secrets /tmp/external-secrets.hcl >/dev/null
rm -f /tmp/external-secrets.hcl
bao write auth/kubernetes/role/external-secrets bound_service_account_names=external-secrets bound_service_account_namespaces=external-secrets policies=external-secrets ttl=1h >/dev/null`
input := []byte(rootToken + "\n" + reviewerToken + "\n")
_, err := execInPod(kubeconfig, input, "sh", "-ec", script)
return err
}
func encryptRecovery(recipient, bundlePath string, plaintext []byte) error {
if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil {
return err
}
cmd := exec.Command("age", "-r", recipient, "-o", bundlePath)
cmd.Stdin = bytes.NewReader(plaintext)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("encrypt OpenBao recovery material: %w: %s", err, bytes.TrimSpace(output))
}
return os.Chmod(bundlePath, 0600)
}
func decryptRecovery(identityPath, bundlePath string) ([]byte, error) {
cmd := exec.Command("age", "-d", "-i", identityPath, bundlePath)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("decrypt OpenBao recovery material: %w", err)
}
return output, nil
}
func execInPod(kubeconfig string, input []byte, args ...string) ([]byte, error) {
command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...)
cmd := exec.Command("kubectl", command...)
cmd.Stdin = bytes.NewReader(input)
return cmd.CombinedOutput()
}
func kubectlOutput(kubeconfig string, args ...string) ([]byte, error) {
command := append([]string{"--kubeconfig", kubeconfig}, args...)
return exec.Command("kubectl", command...).Output()
}

View file

@ -0,0 +1,21 @@
package openbao
import (
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestEnsureRecoveryIdentity(t *testing.T) {
if _, err := exec.LookPath("age-keygen"); err != nil {
t.Skip("age-keygen is required for OpenBao recovery setup")
}
recipient, err := EnsureRecoveryIdentity(filepath.Join(t.TempDir(), "recovery-key.txt"))
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(recipient, "age1") {
t.Fatalf("invalid recovery recipient")
}
}

View file

@ -2,6 +2,9 @@ package ui
import ( import (
"bufio" "bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
@ -32,9 +35,11 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) {
cfg.Flux.ClusterPath = "./clusters/maidn-cd-0" cfg.Flux.ClusterPath = "./clusters/maidn-cd-0"
cfg.Templates.TalosRepoURL = prompt(reader, "Talos template repo URL", fallback(cfg.Templates.TalosRepoURL, "https://git.pingu.pw/Maidn/maidn-talos-proxmox.git")) 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.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"))
fmt.Printf("Template repos are cloned under: %s\n", cfg.Git.CloneParent) fmt.Printf("Template repos are cloned under: %s\n", cfg.Git.CloneParent)
cfg.Templates.TalosRepoRef = "main" cfg.Templates.TalosRepoRef = "main"
cfg.Templates.CICDRepoRef = "main" cfg.Templates.CICDRepoRef = "main"
cfg.Templates.ManifestsRepoRef = "main"
cfg.Talos.RepoDirName = "maidn-talos-proxmox" cfg.Talos.RepoDirName = "maidn-talos-proxmox"
cfg.Talos.TerraformDir = "terraform" cfg.Talos.TerraformDir = "terraform"
cfg.Talos.GeneratedDir = "generated" cfg.Talos.GeneratedDir = "generated"
@ -65,6 +70,7 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) {
} }
cfg.Talos.Cluster.Name = prompt(reader, "Talos cluster name", fallback(cfg.Talos.Cluster.Name, "proxmox-talos-dev02-cluster")) cfg.Talos.Cluster.Name = prompt(reader, "Talos cluster name", fallback(cfg.Talos.Cluster.Name, "proxmox-talos-dev02-cluster"))
cfg.ClusterID = prompt(reader, "Cluster ID", fallback(cfg.ClusterID, cfg.Talos.Cluster.Name))
cfg.Talos.Cluster.Domain = prompt(reader, "Talos cluster domain", fallback(cfg.Talos.Cluster.Domain, "dev02.nid3.com")) cfg.Talos.Cluster.Domain = prompt(reader, "Talos cluster domain", fallback(cfg.Talos.Cluster.Domain, "dev02.nid3.com"))
cfg.Flux.ClusterDomain = cfg.Talos.Cluster.Domain cfg.Flux.ClusterDomain = cfg.Talos.Cluster.Domain
cfg.Talos.Cluster.ControlPlaneVIP = prompt(reader, "Control plane VIP", fallback(cfg.Talos.Cluster.ControlPlaneVIP, "192.168.45.2")) cfg.Talos.Cluster.ControlPlaneVIP = prompt(reader, "Control plane VIP", fallback(cfg.Talos.Cluster.ControlPlaneVIP, "192.168.45.2"))
@ -72,6 +78,7 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) {
cfg.Talos.Cluster.DiskStorage = prompt(reader, "Primary disk storage", fallback(cfg.Talos.Cluster.DiskStorage, firstStorageByCapability(discovered, storageSupportsDisk, "local-lvm"))) cfg.Talos.Cluster.DiskStorage = prompt(reader, "Primary disk storage", fallback(cfg.Talos.Cluster.DiskStorage, firstStorageByCapability(discovered, storageSupportsDisk, "local-lvm")))
cfg.Talos.Cluster.AdditionalStorage = prompt(reader, "Additional disk storage", fallback(cfg.Talos.Cluster.AdditionalStorage, cfg.Talos.Cluster.DiskStorage)) cfg.Talos.Cluster.AdditionalStorage = prompt(reader, "Additional disk storage", fallback(cfg.Talos.Cluster.AdditionalStorage, cfg.Talos.Cluster.DiskStorage))
cfg.Talos.Cluster.CreateVLANInterface = promptBool(reader, "Create VLAN interfaces", true) cfg.Talos.Cluster.CreateVLANInterface = promptBool(reader, "Create VLAN interfaces", true)
cfg.Talos.Cluster.ManageNetworkBridges = promptBool(reader, "Terraform manages Proxmox bridges", cfg.Talos.Cluster.ManageNetworkBridges)
latestTalos := talos.LatestVersion() latestTalos := talos.LatestVersion()
cfg.Talos.Image.TalosVersion = talos.NormalizeVersion(prompt(reader, "Talos version", fallback(cfg.Talos.Image.TalosVersion, latestTalos))) cfg.Talos.Image.TalosVersion = talos.NormalizeVersion(prompt(reader, "Talos version", fallback(cfg.Talos.Image.TalosVersion, latestTalos)))
@ -79,6 +86,7 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) {
return cfg, err return cfg, err
} }
cfg.Talos.Image.Architecture = chooseArchitecture(reader, cfg.Talos.Image.Architecture) cfg.Talos.Image.Architecture = chooseArchitecture(reader, cfg.Talos.Image.Architecture)
cfg.Talos.Image.KubernetesVersion = prompt(reader, "Kubernetes version", fallback(cfg.Talos.Image.KubernetesVersion, "v1.33.4"))
fmt.Printf("Talos factory: %s\n", talos.FactoryURL(cfg.Talos.Image.TalosVersion, fallback(cfg.Talos.Image.Architecture, "amd64"))) fmt.Printf("Talos factory: %s\n", talos.FactoryURL(cfg.Talos.Image.TalosVersion, fallback(cfg.Talos.Image.Architecture, "amd64")))
schematicID, err := talos.ResolveSchematicID() schematicID, err := talos.ResolveSchematicID()
if err != nil { if err != nil {
@ -94,6 +102,7 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) {
cfg.Cilium.TrafficInterface = prompt(reader, "Cilium traffic interface", fallback(cfg.Cilium.TrafficInterface, "eth1")) cfg.Cilium.TrafficInterface = prompt(reader, "Cilium traffic interface", fallback(cfg.Cilium.TrafficInterface, "eth1"))
cfg.Cilium.LoadBalancerStart = prompt(reader, "Cilium LoadBalancer range start", cfg.Cilium.LoadBalancerStart) cfg.Cilium.LoadBalancerStart = prompt(reader, "Cilium LoadBalancer range start", cfg.Cilium.LoadBalancerStart)
cfg.Cilium.LoadBalancerEnd = prompt(reader, "Cilium LoadBalancer range end", cfg.Cilium.LoadBalancerEnd) cfg.Cilium.LoadBalancerEnd = prompt(reader, "Cilium LoadBalancer range end", cfg.Cilium.LoadBalancerEnd)
cfg = PromptDemocraticCSI(cfg)
cfg.Talos.BootstrapNode = cfg.Talos.Nodes[0].Networks[0].IP cfg.Talos.BootstrapNode = cfg.Talos.Nodes[0].Networks[0].IP
cfg.Talos.BootstrapEndpoint = cfg.Talos.BootstrapNode cfg.Talos.BootstrapEndpoint = cfg.Talos.BootstrapNode
cfg.Talos.KubeconfigNode = cfg.Talos.Nodes[0].Networks[0].IP cfg.Talos.KubeconfigNode = cfg.Talos.Nodes[0].Networks[0].IP
@ -101,6 +110,48 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) {
return cfg, nil return cfg, nil
} }
func PromptDemocraticCSI(cfg config.Config) config.Config {
reader := bufio.NewReader(os.Stdin)
cfg.DemocraticCSI.TrueNASAPIKey = promptSecret(reader, "TrueNAS API key", cfg.DemocraticCSI.TrueNASAPIKey)
cfg.DemocraticCSI.TrueNASHost = prompt(reader, "TrueNAS host", cfg.DemocraticCSI.TrueNASHost)
cfg.DemocraticCSI.TargetPortal = prompt(reader, "TrueNAS target portal", fallback(cfg.DemocraticCSI.TargetPortal, cfg.DemocraticCSI.TrueNASHost+":3260"))
cfg.DemocraticCSI.ShareHost = prompt(reader, "TrueNAS NFS share host", fallback(cfg.DemocraticCSI.ShareHost, cfg.DemocraticCSI.TrueNASHost))
cfg.DemocraticCSI.DatasetParentNFS = prompt(reader, "TrueNAS NFS dataset parent", cfg.DemocraticCSI.DatasetParentNFS)
cfg.DemocraticCSI.DatasetSnapshotsNFS = prompt(reader, "TrueNAS NFS snapshots dataset", cfg.DemocraticCSI.DatasetSnapshotsNFS)
cfg.DemocraticCSI.AllowedNetworks = prompt(reader, "TrueNAS allowed NFS network", cfg.DemocraticCSI.AllowedNetworks)
cfg.DemocraticCSI.NameSuffix = prompt(reader, "TrueNAS name suffix", fallback(cfg.DemocraticCSI.NameSuffix, "-"+cfg.ClusterID))
cfg.DemocraticCSI.PortalGroup = prompt(reader, "TrueNAS portal group", fallback(cfg.DemocraticCSI.PortalGroup, "1"))
cfg.DemocraticCSI.InitiatorGroup = prompt(reader, "TrueNAS initiator group", fallback(cfg.DemocraticCSI.InitiatorGroup, "1"))
return cfg
}
func PromptOperationalSecrets(cfg config.Config) (map[string]map[string]string, error) {
if cfg.Git.Username == "" || cfg.Git.Token == "" || cfg.Delivery.ImageRepository == "" {
return nil, errors.New("git username, token, and delivery imageRepository are required")
}
reader := bufio.NewReader(os.Stdin)
cloudflareAPIToken := promptSecret(reader, "Cloudflare API token", "")
cloudflareTunnelToken := promptSecret(reader, "Cloudflare Tunnel token", "")
if cloudflareAPIToken == "" || cloudflareTunnelToken == "" {
return nil, errors.New("Cloudflare API and Tunnel tokens are required")
}
registryHost := strings.Split(cfg.Delivery.ImageRepository, "/")[0]
dockerConfig, err := json.Marshal(map[string]map[string]map[string]string{
"auths": {
registryHost: {"auth": base64.StdEncoding.EncodeToString([]byte(cfg.Git.Username + ":" + cfg.Git.Token))},
},
})
if err != nil {
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/cloudflare": {"api-token": cloudflareAPIToken},
"platform/cloudflare-tunnel": {"token": cloudflareTunnelToken},
}, nil
}
func printNodes(nodes []proxmox.NodeInfo) { func printNodes(nodes []proxmox.NodeInfo) {
fmt.Println("Available Proxmox nodes:") fmt.Println("Available Proxmox nodes:")
for _, node := range nodes { for _, node := range nodes {
@ -124,7 +175,7 @@ func chooseArchitecture(reader *bufio.Reader, current string) string {
func buildNodes(reader *bufio.Reader, cfg config.Config, discovered []proxmox.NodeInfo, preset string) []config.TalosNode { func buildNodes(reader *bufio.Reader, cfg config.Config, discovered []proxmox.NodeInfo, preset string) []config.TalosNode {
switch preset { switch preset {
case "ha": case "ha":
return buildPresetNodes(reader, cfg, discovered, 3, 2) return buildPresetNodes(reader, cfg, discovered, 3, 0)
case "custom": case "custom":
count, _ := strconv.Atoi(prompt(reader, "Number of nodes", "3")) count, _ := strconv.Atoi(prompt(reader, "Number of nodes", "3"))
if count < 1 { if count < 1 {
@ -165,6 +216,9 @@ func buildPresetNodes(reader *bufio.Reader, cfg config.Config, discovered []prox
vmid, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d VMID", i+1), fmt.Sprintf("12%02d", i))) vmid, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d VMID", i+1), fmt.Sprintf("12%02d", i)))
memory, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d memory MB", i+1), memoryDefault)) memory, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d memory MB", i+1), memoryDefault))
cores, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d cores", i+1), coresDefault)) cores, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d cores", i+1), coresDefault))
trafficIP := prompt(reader, fmt.Sprintf("Node %d Cilium traffic IP", i+1), "")
trafficCIDR := prompt(reader, fmt.Sprintf("Node %d Cilium traffic CIDR", i+1), "192.168.45.16/28")
trafficVLAN, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d Cilium traffic VLAN ID", i+1), "451"))
result = append(result, config.TalosNode{ result = append(result, config.TalosNode{
Name: name, Name: name,
VMID: vmid, VMID: vmid,
@ -174,13 +228,21 @@ func buildPresetNodes(reader *bufio.Reader, cfg config.Config, discovered []prox
DiskSize: prompt(reader, fmt.Sprintf("Node %d disk size", i+1), diskDefault), DiskSize: prompt(reader, fmt.Sprintf("Node %d disk size", i+1), diskDefault),
Tags: []string{"talos", role}, Tags: []string{"talos", role},
ProxmoxNode: proxmoxNode, ProxmoxNode: proxmoxNode,
Networks: []config.TalosNetwork{{ Networks: []config.TalosNetwork{
MACAddress: prompt(reader, fmt.Sprintf("Node %d MAC", i+1), ""), {
CIDR: cidr, MACAddress: prompt(reader, fmt.Sprintf("Node %d primary MAC", i+1), ""),
IP: ip, CIDR: cidr,
Gateway: gateway, IP: ip,
VLANID: vlanID, Gateway: gateway,
}}, VLANID: vlanID,
},
{
MACAddress: prompt(reader, fmt.Sprintf("Node %d Cilium traffic MAC", i+1), ""),
CIDR: trafficCIDR,
IP: trafficIP,
VLANID: trafficVLAN,
},
},
}) })
} }
return result return result

View file

@ -37,8 +37,15 @@ func RunCommandQuiet(name string, args ...string) error {
} }
func RunCommandInDir(dir string, name string, args ...string) error { func RunCommandInDir(dir string, name string, args ...string) error {
return RunCommandInDirEnv(dir, nil, name, args...)
}
func RunCommandInDirEnv(dir string, environment []string, name string, args ...string) error {
cmd := exec.Command(name, args...) cmd := exec.Command(name, args...)
cmd.Dir = dir cmd.Dir = dir
if environment != nil {
cmd.Env = append(os.Environ(), environment...)
}
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
return cmd.Run() return cmd.Run()
@ -73,6 +80,15 @@ func RunCommandOutputInDir(dir string, name string, args ...string) ([]byte, err
return stdout.Bytes(), nil return stdout.Bytes(), nil
} }
func RunCommandInputInDir(dir string, input []byte, name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Stdin = bytes.NewReader(input)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func RunCommandJSON(target any, name string, args ...string) error { func RunCommandJSON(target any, name string, args ...string) error {
output, err := RunCommandOutput(name, args...) output, err := RunCommandOutput(name, args...)
if err != nil { if err != nil {