feat: make bootstrap self-contained
This commit is contained in:
parent
d20a728f65
commit
72fbbca0ac
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.age/
|
||||||
|
.recovery/
|
||||||
|
maidn-bootstrap*.yaml
|
||||||
|
maidn-workspace/
|
||||||
|
cicd-tool.exe
|
||||||
12
README.md
12
README.md
|
|
@ -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
|
||||||
|
|
@ -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 != "" {
|
||||||
|
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)
|
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
47
docs/secrets.md
Normal 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.
|
||||||
|
|
@ -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,23 +164,12 @@ 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
|
return err
|
||||||
}
|
}
|
||||||
}
|
if err := bootstrapEtcdIfNeeded(generatedDir, r.Config); err != nil {
|
||||||
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 := utils.RunCommandInDir(generatedDir, "talosctl", "bootstrap", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+r.Config.Talos.BootstrapEndpoint, "--nodes="+r.Config.Talos.BootstrapNode); err != nil {
|
|
||||||
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 {
|
||||||
path := filepath.Join(clusterDir, "kustomization.yaml")
|
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)
|
content, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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")
|
||||||
|
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 {
|
||||||
|
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, ¤t); 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,8 +587,12 @@ 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) {
|
||||||
|
legacyPath := filepath.Join(generatedDir, "talsecret.sops.yaml")
|
||||||
|
if _, legacyErr := os.Stat(legacyPath); legacyErr == nil {
|
||||||
|
secretPath = legacyPath
|
||||||
|
} else {
|
||||||
secrets, err := utils.RunCommandOutputInDir(generatedDir, talhelperPath, "gensecret")
|
secrets, err := utils.RunCommandOutputInDir(generatedDir, talhelperPath, "gensecret")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -243,44 +601,20 @@ func ensureTalosConfig(generatedDir string, cfg config.Config) error {
|
||||||
return err
|
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)
|
return utils.RunCommandInDir(generatedDir, talhelperPath, "genconfig", "--config-file", "talconfig.yaml", "--secret-file", filepath.Base(secretPath))
|
||||||
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 {
|
|
||||||
text = text[:start] + "kubernetesVersion: " + kubernetesVersion + text[start+end:]
|
|
||||||
}
|
|
||||||
_ = os.WriteFile(path, []byte(text), 0644)
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
updated := strings.Replace(text, "\nendpoint:", "\nkubernetesVersion: "+kubernetesVersion+"\nendpoint:", 1)
|
|
||||||
_ = 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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
81
internal/config/config_test.go
Normal file
81
internal/config/config_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,51 @@ package config
|
||||||
import "net/url"
|
import "net/url"
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
ClusterID string `yaml:"clusterId"`
|
||||||
WorkspaceDir string `yaml:"workspaceDir"`
|
WorkspaceDir string `yaml:"workspaceDir"`
|
||||||
Git GitConfig `yaml:"git"`
|
Git GitConfig `yaml:"git"`
|
||||||
Flux FluxConfig `yaml:"flux"`
|
Flux FluxConfig `yaml:"flux"`
|
||||||
Talos TalosConfig `yaml:"talos"`
|
Talos TalosConfig `yaml:"talos"`
|
||||||
Templates TemplateConfig `yaml:"templates"`
|
Templates TemplateConfig `yaml:"templates"`
|
||||||
Cilium CiliumConfig `yaml:"cilium"`
|
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 {
|
||||||
|
|
@ -35,6 +74,8 @@ type TemplateConfig struct {
|
||||||
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 {
|
||||||
|
|
@ -80,10 +121,12 @@ type TalosClusterConfig struct {
|
||||||
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"`
|
||||||
|
KubernetesVersion string `yaml:"kubernetesVersion"`
|
||||||
SchematicID string `yaml:"schematicId"`
|
SchematicID string `yaml:"schematicId"`
|
||||||
UpdateMode string `yaml:"updateMode"`
|
UpdateMode string `yaml:"updateMode"`
|
||||||
Storage string `yaml:"storage"`
|
Storage string `yaml:"storage"`
|
||||||
|
|
|
||||||
|
|
@ -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,6 +20,10 @@ 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 {
|
||||||
|
|
@ -31,7 +34,35 @@ type createRepoRequest struct {
|
||||||
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)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error {
|
func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error {
|
||||||
repoURL := fmt.Sprintf("%s/%s/%s.git", rm.BaseURL, rm.Owner, name)
|
exists, err := rm.repoExists(name)
|
||||||
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s", rm.BaseURL, rm.Owner, name)
|
if err != nil {
|
||||||
if err := rm.apiRequest(http.MethodGet, apiURL, nil); err != nil {
|
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)
|
createURL := fmt.Sprintf("%s/api/v1/orgs/%s/repos", rm.BaseURL, rm.Owner)
|
||||||
if rm.Owner == rm.Username {
|
if rm.Owner == rm.Username {
|
||||||
createURL = fmt.Sprintf("%s/api/v1/user/repos", rm.BaseURL)
|
createURL = fmt.Sprintf("%s/api/v1/user/repos", rm.BaseURL)
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(createRepoRequest{Name: name, Description: description, Private: true, AutoInit: false, DefaultBranch: "main"})
|
body, err := json.Marshal(createRepoRequest{Name: name, Description: description, Private: true, AutoInit: true, DefaultBranch: rm.Branch})
|
||||||
if apiErr := rm.apiRequest(http.MethodPost, createURL, body); apiErr != nil {
|
if err != nil {
|
||||||
return apiErr
|
return err
|
||||||
}
|
}
|
||||||
|
status, err := rm.apiRequest(http.MethodPost, createURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return rm.setupRepository(repoURL, name, createStructure)
|
if status != http.StatusCreated && status != http.StatusConflict {
|
||||||
|
return fmt.Errorf("unexpected Forgejo create repository status %d", status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rm *RepoManager) setupRepository(repoURL, repoName string, createStructure func(string) error) error {
|
func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool, 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 {
|
||||||
|
|
||||||
func commitAndPush(tempDir, repoName string) error {
|
|
||||||
utils.RunCommandInDir(tempDir, "git", "config", "user.name", "Maidn")
|
|
||||||
utils.RunCommandInDir(tempDir, "git", "config", "user.email", "maidn@free-maidn.com")
|
|
||||||
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 {
|
rm.MigrationPending = true
|
||||||
return err
|
return rm.createMigrationPullRequest(repoName, targetBranch)
|
||||||
}
|
|
||||||
fmt.Printf("[SUCCESS] Changes pushed to %s successfully.\n", repoName)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rm *RepoManager) apiRequest(method, endpoint string, body []byte) error {
|
func (rm *RepoManager) createMigrationPullRequest(repo, branch string) error {
|
||||||
client := http.Client{Timeout: 15 * time.Second}
|
body, err := json.Marshal(pullRequestRequest{Title: "feat: bootstrap Maidn CI/CD structure", Head: branch, Base: rm.Branch})
|
||||||
request, err := http.NewRequest(method, endpoint, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if status != http.StatusCreated && status != http.StatusUnprocessableEntity {
|
||||||
|
return fmt.Errorf("unexpected Forgejo pull request status %d", status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) error {
|
||||||
|
if webhookURL == "" || authorization == "" {
|
||||||
|
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 {
|
||||||
|
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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
96
internal/forgejo/repo_test.go
Normal file
96
internal/forgejo/repo_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
274
internal/openbao/bootstrap.go
Normal file
274
internal/openbao/bootstrap.go
Normal 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, ¤t); 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()
|
||||||
|
}
|
||||||
21
internal/openbao/bootstrap_test.go
Normal file
21
internal/openbao/bootstrap_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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), ""),
|
{
|
||||||
|
MACAddress: prompt(reader, fmt.Sprintf("Node %d primary MAC", i+1), ""),
|
||||||
CIDR: cidr,
|
CIDR: cidr,
|
||||||
IP: ip,
|
IP: ip,
|
||||||
Gateway: gateway,
|
Gateway: gateway,
|
||||||
VLANID: vlanID,
|
VLANID: vlanID,
|
||||||
}},
|
},
|
||||||
|
{
|
||||||
|
MACAddress: prompt(reader, fmt.Sprintf("Node %d Cilium traffic MAC", i+1), ""),
|
||||||
|
CIDR: trafficCIDR,
|
||||||
|
IP: trafficIP,
|
||||||
|
VLANID: trafficVLAN,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue