Compare commits

...

10 commits

Author SHA1 Message Date
eding b6dce951ed feat: target forgejo templates 2026-07-18 21:45:17 +02:00
eding 85e87f1334 feat: add bootstrap workflow 2026-07-15 20:39:39 +02:00
eding cc8fab6832 fix: manifest dependencies before install
this is important for external secrets / storage / networking
2025-11-19 15:40:49 +01:00
eding caee4f8a01 feat: ghcr external secret generator 2025-11-19 08:37:06 +01:00
eding 85e0a700cb chore: cache clearning if CRDs do not exist 2025-11-19 08:36:17 +01:00
eding 87e9c98bf1 fix: docker auth domain 2025-10-28 08:08:05 +01:00
eding 10a79d6870 feat: added retry mechanism 2025-10-26 16:13:15 +01:00
eding 4fe103eeb7 fix: updated external secret api 2025-10-26 16:12:15 +01:00
eding 7668d0d180 feat: restructure & secret setup 2025-10-26 00:43:31 +02:00
eding b2b43a98fd feat: updated docker auth secret creation 2025-10-23 11:56:50 +02:00
30 changed files with 2070 additions and 822 deletions

13
.vscode/launch.json vendored
View file

@ -44,6 +44,19 @@
], ],
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"console": "integratedTerminal" "console": "integratedTerminal"
},
{
"name": "Run and Debug CICD Tool ghcr auth creation",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"args": [
"vault",
"create-ghcr-secret"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
} }
] ]
} }

2
How2Auth.md Normal file
View file

@ -0,0 +1,2 @@
edingrech
dckr_pat_bfKKDH4g3qUxchs9UMLxFx2oTiU

1
MaidnCLI Submodule

@ -0,0 +1 @@
Subproject commit cc8fab6832a3a51bd072155f90f91354e440077a

100
README.md
View file

@ -1,4 +1,5 @@
## Commands ## Commands
go mod init github.com/Pingu-Studio/MaidnCLI go mod init github.com/Pingu-Studio/MaidnCLI
go get -u github.com/spf13/cobra@latest go get -u github.com/spf13/cobra@latest
go get golang.org/x/term go get golang.org/x/term
@ -11,94 +12,17 @@ go install github.com/go-delve/delve/cmd/dlv@latest
dlv version dlv version
``` ```
## TODO ## Commands
create the file:
within the main flux repo dynamically changing the org and repo location based on the user's input
```yaml - `cicd-tool repo init --org <org> --flux-repo <repo>` creates the manifests and Flux repos
--- - `cicd-tool bootstrap` runs a shorter Forgejo-first wizard, discovers Proxmox nodes/storage/networks, shows the latest Talos version, writes `terraform.tfvars`, stages Talos images on Proxmox, and can execute Terraform, Talos bootstrap, and Flux bootstrap
apiVersion: source.toolkit.fluxcd.io/v1beta2 - `cicd-tool bootstrap --config maidn-bootstrap.yaml` skips the wizard and uses the saved config
kind: HelmRepository
metadata:
name: ghcr-charts
namespace: flux-system
spec:
interval: 5m
type: oci
url: oci://ghcr.io/pingu-studio
secretRef:
name: github-auth
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: cicd-deployment-manifests
namespace: flux-system
spec:
interval: 1m0s
url: https://github.com/Pingu-Studio/cicd-deployment-manifests
ref:
branch: main
secretRef:
name: github-auth
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: namespaces
namespace: flux-system
spec:
interval: 10m
path: ./namespaces
prune: true
sourceRef:
kind: GitRepository
name: cicd-deployment-manifests
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: preview-apps
namespace: flux-system
spec:
dependsOn:
- name: namespaces
interval: 2m0s
path: ./apps/previews
prune: true
sourceRef:
kind: GitRepository
name: cicd-deployment-manifests
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: staging-apps
namespace: flux-system
spec:
dependsOn:
- name: namespaces
interval: 2m0s
path: ./apps/staging
prune: true
sourceRef:
kind: GitRepository
name: cicd-deployment-manifests
```
and in the cicd-deployment-manifests repo create: ## Forgejo setup
CICD-DEPLOYMENT-MANIFESTS For `https://git.pingu.pw` you need:
├── apps
│ ├── previews - a user token with repo create/push rights
│ │ └── .gitkeep - an owner target (`Maidn` org by default, or your own user/org)
│ ├── production - git/ssh access from the machine running the CLI if you want SSH later
│ │ └── .gitkeep - Flux bootstrap credentials for the repo URL that gets created
│ └── staging
│ └── .gitkeep
├── namespaces
│ ├── kustomization.yaml
│ ├── previews.yaml
│ ├── production.yaml
│ └── staging.yaml
└── .gitkeep

53
cmd/bootstrap.go Normal file
View file

@ -0,0 +1,53 @@
package cmd
import (
"fmt"
"os"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/ui"
"github.com/spf13/cobra"
)
var bootstrapConfigPath string
var bootstrapOutputPath string
var bootstrapCmd = &cobra.Command{
Use: "bootstrap",
Short: "Bootstrap Talos and Flux from config or an interactive wizard.",
Run: runBootstrap,
}
func init() {
rootCmd.AddCommand(bootstrapCmd)
bootstrapCmd.Flags().StringVar(&bootstrapConfigPath, "config", "", "Path to bootstrap config YAML")
bootstrapCmd.Flags().StringVar(&bootstrapOutputPath, "out", "maidn-bootstrap.yaml", "Path to save generated config")
}
func runBootstrap(cmd *cobra.Command, args []string) {
var cfg config.Config
var err error
if bootstrapConfigPath != "" {
cfg, err = config.Load(bootstrapConfigPath)
} else {
cfg, err = ui.RunBootstrapWizard()
if err == nil {
err = config.Save(bootstrapOutputPath, cfg)
if err == nil {
fmt.Printf("[INFO] Saved config to %s\n", bootstrapOutputPath)
}
}
}
if err != nil {
fmt.Printf("[ERROR] %v\n", err)
os.Exit(1)
}
runner := bootstrap.Runner{Config: cfg}
if err = runner.Run(); err != nil {
fmt.Printf("[ERROR] %v\n", err)
os.Exit(1)
}
}

59
cmd/repo.go Normal file
View file

@ -0,0 +1,59 @@
package cmd
import (
"fmt"
"os"
"github.com/Pingu-Studio/MaidnCLI/internal/github"
"github.com/Pingu-Studio/MaidnCLI/internal/utils"
"github.com/spf13/cobra"
)
var orgName, manifestsRepoName, fluxRepoName string
var repoCmd = &cobra.Command{
Use: "repo",
Short: "Manage CI/CD repositories.",
}
var initCmd = &cobra.Command{
Use: "init",
Short: "Initializes the manifests and flux repositories with the required structure.",
Run: runInitRepo,
}
func init() {
rootCmd.AddCommand(repoCmd)
repoCmd.AddCommand(initCmd)
initCmd.Flags().StringVar(&orgName, "org", "", "The GitHub organization (e.g., Free-Maidn)")
initCmd.Flags().StringVar(&manifestsRepoName, "manifests-repo", "cicd-deployment-manifests", "The name of the manifests repository")
initCmd.Flags().StringVar(&fluxRepoName, "flux-repo", "", "The name of the flux repository")
initCmd.MarkFlagRequired("org")
initCmd.MarkFlagRequired("flux-repo")
}
func runInitRepo(cmd *cobra.Command, args []string) {
fmt.Println("[INFO] Checking dependencies (git and gh)...")
if !utils.CommandExists("git") || !utils.CommandExists("gh") {
fmt.Println("[ERROR] 'git' and 'gh' must be installed and in your PATH.")
os.Exit(1)
}
fmt.Println("[INFO] Checking GitHub authentication...")
if err := utils.RunCommandQuiet("gh", "auth", "status"); err != nil {
fmt.Println("[ERROR] You are not logged into the GitHub CLI. Please run 'gh auth login'.")
os.Exit(1)
}
fmt.Println("[SUCCESS] Dependencies and authentication are OK.")
repoManager := github.NewRepoManager(orgName, manifestsRepoName, fluxRepoName)
if err := repoManager.InitializeAll(); err != nil {
fmt.Printf("[ERROR] %v\n", err)
os.Exit(1)
}
fmt.Println("\n🎉 Onboarding complete! 🎉")
fmt.Printf("✅ Manifests repository: https://github.com/%s/%s\n", orgName, manifestsRepoName)
fmt.Printf("✅ Flux repository: https://github.com/%s/%s\n", orgName, fluxRepoName)
}

21
cmd/root.go Normal file
View file

@ -0,0 +1,21 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "cicd-tool",
Short: "A CLI tool to manage CI/CD setup for applications.",
}
// Execute adds all child commands to the root command and sets flags appropriately.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}

186
cmd/vault.go Normal file
View file

@ -0,0 +1,186 @@
package cmd
import (
"fmt"
"os"
"strings"
"time"
"github.com/Pingu-Studio/MaidnCLI/internal/k8s"
"github.com/Pingu-Studio/MaidnCLI/internal/ui"
"github.com/Pingu-Studio/MaidnCLI/internal/utils"
"github.com/Pingu-Studio/MaidnCLI/internal/vault"
"github.com/spf13/cobra"
)
var vaultCmd = &cobra.Command{
Use: "vault",
Short: "Manage Vault secrets for CI/CD.",
}
func init() {
rootCmd.AddCommand(vaultCmd)
vaultCmd.AddCommand(createDockerSecretCmd)
vaultCmd.AddCommand(createGhcrSecretCmd)
vaultCmd.AddCommand(createGithubSecretCmd)
}
var createDockerSecretCmd = &cobra.Command{
Use: "create-docker-secret",
Short: "Creates a secret in Vault for Docker Hub and applies it to Kubernetes.",
Run: runCreateDockerSecret,
}
var createGhcrSecretCmd = &cobra.Command{
Use: "create-ghcr-secret",
Short: "Creates secrets in Vault for GHCR and applies it to Kubernetes.",
Run: runCreateGhcrSecret,
}
var createGithubSecretCmd = &cobra.Command{
Use: "create-github-secret",
Short: "Creates a GitHub authentication secret in Vault.",
Run: runCreateGithubSecret,
}
func runCreateDockerSecret(cmd *cobra.Command, args []string) {
if err := k8s.CheckExternalSecretCRD(); err != nil {
fmt.Printf("[ERROR] Prerequisite check failed: %v\n", err)
os.Exit(1)
}
vm, err := vault.NewVaultManager()
if err != nil {
fmt.Printf("[ERROR] Failed to initialize Vault manager: %v\n", err)
os.Exit(1)
}
username, password, err := ui.GetUserInputForDockerHub()
if err != nil {
fmt.Printf("[ERROR] %v\n", err)
os.Exit(1)
}
dockerConfigJSON, err := vault.CreateDockerConfig(vault.DockerHubRegistry, username, password)
if err != nil {
fmt.Printf("[ERROR] Failed to create Docker config: %v\n", err)
os.Exit(1)
}
secretData := map[string]string{vault.DockerConfigJSONKey: dockerConfigJSON}
if err := vm.StoreSecret(vault.DockerHubSecretPath, secretData); err != nil {
fmt.Printf("[ERROR] Failed to store Docker Hub secret in Vault: %v\n", err)
os.Exit(1)
}
// First attempt to create the secret
err = k8s.CreateDockerHubExternalSecret()
if err == nil {
// Success on the first try
return
}
// If it failed, check for the specific cache error
if strings.Contains(err.Error(), "no matches for kind") {
// Handle the cache error interactively
cacheCleared := utils.HandleKubectlCacheError(err)
// If the user agreed and the cache was cleared, retry the operation
if cacheCleared {
fmt.Println("\n[INFO] Retrying to apply the ExternalSecret after clearing cache...")
time.Sleep(1 * time.Second) // Give the system a moment to breathe
err = k8s.CreateDockerHubExternalSecret()
if err == nil {
fmt.Println("[SUCCESS] ExternalSecret applied successfully on the second attempt.")
return
}
}
}
// If we reach here, either the error was not the cache issue, or the retry failed.
fmt.Printf("\n[ERROR] Operation failed. The secret is saved in Vault, but the ExternalSecret could not be applied to Kubernetes.\n")
fmt.Printf("Final error: %v\n", err)
os.Exit(1)
}
func runCreateGhcrSecret(cmd *cobra.Command, args []string) {
if err := k8s.CheckExternalSecretCRD(); err != nil {
fmt.Printf("[ERROR] Prerequisite check failed: %v\n", err)
os.Exit(1)
}
vm, err := vault.NewVaultManager()
if err != nil {
fmt.Printf("[ERROR] Failed to initialize Vault manager: %v\n", err)
os.Exit(1)
}
username, registryToken, orgToken, err := ui.GetUserInputForGhcr()
if err != nil {
fmt.Printf("[ERROR] %v\n", err)
os.Exit(1)
}
dockerConfigJSON, err := vault.CreateDockerConfig(vault.GhcrRegistry, username, registryToken)
if err != nil {
fmt.Printf("[ERROR] Failed to create Docker config for GHCR: %v\n", err)
os.Exit(1)
}
dockerSecretData := map[string]string{vault.DockerConfigJSONKey: dockerConfigJSON}
if err := vm.StoreSecret(vault.GhcrSecretPath, dockerSecretData); err != nil {
fmt.Printf("[ERROR] Failed to store GHCR secret in Vault: %v\n", err)
os.Exit(1)
}
orgSecretData := map[string]string{vault.OrgManagementPATKey: orgToken}
if err := vm.StoreSecret(vault.OrgManagementSecretPath, orgSecretData); err != nil {
fmt.Printf("[ERROR] Failed to store organization management PAT in Vault: %v\n", err)
os.Exit(1)
}
err = k8s.CreateGhcrExternalSecret()
if err == nil {
return
}
if strings.Contains(err.Error(), "no matches for kind") {
cacheCleared := utils.HandleKubectlCacheError(err)
if cacheCleared {
fmt.Println("\n[INFO] Retrying to apply the ExternalSecret after clearing cache...")
time.Sleep(1 * time.Second)
err = k8s.CreateGhcrExternalSecret()
if err == nil {
fmt.Println("[SUCCESS] ExternalSecret applied successfully on the second attempt.")
return
}
}
}
fmt.Printf("\n[ERROR] Operation failed. The secrets are saved in Vault, but the ExternalSecret for GHCR could not be applied to Kubernetes.\n")
fmt.Printf("Final error: %v\n", err)
os.Exit(1)
}
func runCreateGithubSecret(cmd *cobra.Command, args []string) {
vm, err := vault.NewVaultManager()
if err != nil {
fmt.Printf("[ERROR] Failed to initialize Vault manager: %v\n", err)
os.Exit(1)
}
username, token, err := ui.GetUserInputForGithubAuth()
if err != nil {
fmt.Printf("[ERROR] %v\n", err)
os.Exit(1)
}
secretData := map[string]string{"username": username, "password": token}
if err := vm.StoreSecret(vault.GithubAuthSecretPath, secretData); err != nil {
fmt.Printf("[ERROR] Failed to store GitHub auth secret in Vault: %v\n", err)
os.Exit(1)
}
}

22
docs/maintenance.md Normal file
View file

@ -0,0 +1,22 @@
# Cluster maintenance
## What the CLI handles now
- Bootstraps the Talos IaC repo
- Writes cluster-specific Terraform config
- Can trigger Terraform, Talos bootstrap, kubeconfig generation, and Flux bootstrap
- Can request Proxmox-side Talos ISO staging through Terraform inputs
## Manual operations still required
- Talos upgrades remain operator-driven
- Certificate rotation remains operator-driven
- etcd snapshots and restore drills remain operator-driven
## Candidate automation
- Add a `cicd-tool talos upgrade` workflow that updates version pins, stages images, and rolls nodes one by one
- Add a `cicd-tool talos certs check` workflow that reports expiry windows
- Add a scheduled GitHub Action in the Talos repo that opens PRs for new factory-compatible image pins
Use the standalone Talos repo maintenance guide for the actual runbook: `maidn/maidn-talos-proxmox/docs/maintenance.md`.

11
go.mod
View file

@ -1,14 +1,15 @@
module github.com/Pingu-Studio/MaidnCLI module github.com/Pingu-Studio/MaidnCLI
go 1.25.0 go 1.24.0
require ( require (
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.10.1
golang.org/x/term v0.34.0 golang.org/x/term v0.36.0
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.7 // indirect github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.37.0 // indirect
) )

14
go.sum
View file

@ -1,15 +1,29 @@
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,9 @@
package assets
import _ "embed"
//go:embed templates/manifests.md.tmpl
var ManifestsReadmeTmpl string
//go:embed templates/flux.md.tmpl
var FluxReadmeTmpl string

View file

@ -0,0 +1,150 @@
package bootstrap
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
ghrepo "github.com/Pingu-Studio/MaidnCLI/internal/github"
"github.com/Pingu-Studio/MaidnCLI/internal/utils"
)
type Runner struct {
Config config.Config
}
func (r Runner) Run() error {
workspace := r.Config.WorkspaceDir
if err := os.MkdirAll(workspace, 0755); err != nil {
return err
}
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)
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)
if err := manager.InitializeAll(
func(dir string) error { return ghrepo.WriteManifestsStructure(dir, r.Config.Flux.ManifestsRepo) },
func(dir string) error { return ghrepo.WriteFluxStructure(dir, r.Config.Flux.RepoName, r.Config.Flux.ClusterPath, fluxConfig) },
); err != nil {
return err
}
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 {
return err
}
terraformDir := filepath.Join(repoDir, r.Config.Talos.TerraformDir)
generatedDir := filepath.Join(repoDir, r.Config.Talos.GeneratedDir)
if err := os.MkdirAll(generatedDir, 0755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(terraformDir, r.Config.Talos.ConfigFileName), []byte(renderTerraformTFVars(r.Config)), 0644); err != nil {
return err
}
if r.Config.Talos.AutoRunTerraform {
if err := utils.RunCommandInDir(terraformDir, "terraform", "init"); err != nil {
return err
}
if err := utils.RunCommandInDir(terraformDir, "terraform", "apply", "-auto-approve"); err != nil {
return err
}
}
if r.Config.Talos.AutoBootstrap {
if err := utils.RunCommandInDir(generatedDir, "talosctl", "bootstrap", "--talosconfig=./clusterconfig/talosconfig", "--nodes="+r.Config.Talos.BootstrapNode); err != nil {
return err
}
if err := utils.RunCommandInDir(generatedDir, "talosctl", "kubeconfig", "--talosconfig=./clusterconfig/talosconfig", "--nodes="+r.Config.Talos.KubeconfigNode, "."); err != nil {
return err
}
}
if r.Config.Talos.AutoBootstrapFlux {
if err := utils.RunCommand("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); err != nil {
return err
}
}
return nil
}
func ensureRepo(dir, repoURL, ref string) error {
if _, err := os.Stat(dir); os.IsNotExist(err) {
return utils.RunCommand("git", "clone", "--branch", ref, repoURL, dir)
}
if err := utils.RunCommandInDir(dir, "git", "fetch", "origin"); err != nil {
return err
}
return utils.RunCommandInDir(dir, "git", "checkout", ref)
}
func renderTerraformTFVars(cfg config.Config) string {
var builder strings.Builder
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_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("cluster_name = %q\n", cfg.Talos.Cluster.Name))
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.FactorySchematicID))
builder.WriteString(fmt.Sprintf("talos_version = %q\n", cfg.Talos.Image.TalosVersion))
builder.WriteString("cni_name = \"flannel\"\n")
builder.WriteString(fmt.Sprintf("talos_iso_file = %q\n", fmt.Sprintf("%s:iso/talos-%s.iso", cfg.Talos.Image.Storage, strings.TrimPrefix(cfg.Talos.Image.TalosVersion, "v"))))
builder.WriteString(fmt.Sprintf("talos_image_update_mode = %q\n", cfg.Talos.Image.UpdateMode))
builder.WriteString(fmt.Sprintf("talos_image_storage = %q\n", cfg.Talos.Image.Storage))
builder.WriteString(fmt.Sprintf("talos_image_architecture = %q\n", cfg.Talos.Image.Architecture))
builder.WriteString(fmt.Sprintf("disk_storage = %q\n", cfg.Talos.Cluster.DiskStorage))
builder.WriteString(fmt.Sprintf("additional_disk_storage = %q\n", cfg.Talos.Cluster.AdditionalStorage))
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("node_interfaces = {\n")
for node, iface := range cfg.Talos.Proxmox.NodeInterfaces {
builder.WriteString(fmt.Sprintf(" %q = %q\n", node, iface))
}
builder.WriteString("}\n")
builder.WriteString(fmt.Sprintf("create_vlan_interface = %t\n", cfg.Talos.Cluster.CreateVLANInterface))
builder.WriteString("image_cache_proxy = { enabled = false, ip = \"\", port = 3128 }\n")
builder.WriteString("nodes = [\n")
for _, node := range cfg.Talos.Nodes {
builder.WriteString(" {\n")
builder.WriteString(fmt.Sprintf(" name = %q\n", node.Name))
builder.WriteString(fmt.Sprintf(" vmid = %d\n", node.VMID))
builder.WriteString(fmt.Sprintf(" role = %q\n", node.Role))
builder.WriteString(fmt.Sprintf(" cores = %d\n", node.Cores))
builder.WriteString(fmt.Sprintf(" memory = %d\n", node.Memory))
builder.WriteString(fmt.Sprintf(" disk_size = %q\n", node.DiskSize))
if node.AdditionalDiskSize != "" {
builder.WriteString(fmt.Sprintf(" additional_disk_size = %q\n", node.AdditionalDiskSize))
}
builder.WriteString(fmt.Sprintf(" tags = [%s]\n", quoteList(node.Tags)))
builder.WriteString(fmt.Sprintf(" proxmox_node = %q\n", node.ProxmoxNode))
builder.WriteString(" networks = [\n")
for _, net := range node.Networks {
builder.WriteString(" {\n")
builder.WriteString(fmt.Sprintf(" mac_address = %q\n", net.MACAddress))
builder.WriteString(fmt.Sprintf(" cidr = %q\n", net.CIDR))
if net.IP != "" {
builder.WriteString(fmt.Sprintf(" ip = %q\n", net.IP))
}
if net.Gateway != "" {
builder.WriteString(fmt.Sprintf(" gateway = %q\n", net.Gateway))
}
builder.WriteString(fmt.Sprintf(" vlan_id = %d\n", net.VLANID))
builder.WriteString(" },\n")
}
builder.WriteString(" ]\n")
builder.WriteString(" },\n")
}
builder.WriteString("]\n")
return builder.String()
}
func quoteList(values []string) string {
quoted := make([]string, 0, len(values))
for _, value := range values {
quoted = append(quoted, fmt.Sprintf("%q", value))
}
return strings.Join(quoted, ", ")
}

115
internal/config/config.go Normal file
View file

@ -0,0 +1,115 @@
package config
import (
"errors"
"os"
"gopkg.in/yaml.v3"
)
func Load(path string) (Config, error) {
var cfg Config
data, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, err
}
applyDefaults(&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) {
if cfg.Git.Provider == "" {
cfg.Git.Provider = "forgejo"
}
if cfg.Git.BaseURL == "" {
cfg.Git.BaseURL = "https://git.pingu.pw"
}
if cfg.Git.CloneParent == "" {
cfg.Git.CloneParent = cfg.WorkspaceDir
}
if cfg.Flux.Branch == "" {
cfg.Flux.Branch = "main"
}
if cfg.Flux.ClusterPath == "" {
cfg.Flux.ClusterPath = "./clusters/maidn-cd-0"
}
if cfg.Flux.ManifestsRepo == "" {
cfg.Flux.ManifestsRepo = "cicd-deployment-manifests"
}
if cfg.Templates.TalosRepoURL == "" {
cfg.Templates.TalosRepoURL = "https://git.pingu.pw/Maidn/maidn-talos-proxmox.git"
}
if cfg.Templates.TalosRepoRef == "" {
cfg.Templates.TalosRepoRef = "main"
}
if cfg.Talos.RepoDirName == "" {
cfg.Talos.RepoDirName = "maidn-talos-proxmox"
}
if cfg.Talos.TerraformDir == "" {
cfg.Talos.TerraformDir = "terraform"
}
if cfg.Talos.GeneratedDir == "" {
cfg.Talos.GeneratedDir = "generated"
}
if cfg.Talos.ConfigFileName == "" {
cfg.Talos.ConfigFileName = "terraform.tfvars"
}
if cfg.Talos.Image.UpdateMode == "" {
cfg.Talos.Image.UpdateMode = "download"
}
if cfg.Talos.Image.Storage == "" {
cfg.Talos.Image.Storage = "local"
}
if cfg.Talos.Image.Architecture == "" {
cfg.Talos.Image.Architecture = "amd64"
}
}
func Validate(cfg Config) error {
if cfg.WorkspaceDir == "" {
return errors.New("workspaceDir is required")
}
if cfg.Git.BaseURL == "" || cfg.Git.Username == "" || cfg.Git.Owner == "" {
return errors.New("git baseUrl, username, and owner are required")
}
if cfg.Flux.RepoName == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ManifestsRepo == "" {
return errors.New("flux repoName, manifestsRepo, and clusterPath are required")
}
if cfg.Templates.TalosRepoURL == "" || cfg.Talos.RepoDirName == "" {
return errors.New("talos template repo and checkout dir are required")
}
if cfg.Talos.TerraformDir == "" || cfg.Talos.GeneratedDir == "" || cfg.Talos.ConfigFileName == "" {
return errors.New("talos terraformDir, generatedDir, and configFileName are required")
}
if cfg.Talos.Proxmox.APIURL == "" || cfg.Talos.Proxmox.APITokenID == "" || cfg.Talos.Proxmox.APITokenSecret == "" {
return errors.New("talos proxmox apiUrl, apiTokenId, and apiTokenSecret are required")
}
if cfg.Talos.Cluster.Name == "" || cfg.Talos.Cluster.Domain == "" {
return errors.New("talos cluster name and domain are required")
}
if cfg.Talos.Image.FactorySchematicID == "" || cfg.Talos.Image.TalosVersion == "" {
return errors.New("talos image pins are required")
}
if cfg.Talos.Image.UpdateMode != "manual" && cfg.Talos.Image.UpdateMode != "download" {
return errors.New("talos image updateMode must be manual or download")
}
if len(cfg.Talos.Nodes) == 0 {
return errors.New("at least one talos node is required")
}
return nil
}

98
internal/config/types.go Normal file
View file

@ -0,0 +1,98 @@
package config
type Config struct {
WorkspaceDir string `yaml:"workspaceDir"`
Git GitConfig `yaml:"git"`
Flux FluxConfig `yaml:"flux"`
Talos TalosConfig `yaml:"talos"`
Templates TemplateConfig `yaml:"templates"`
}
type GitConfig struct {
Provider string `yaml:"provider"`
BaseURL string `yaml:"baseUrl"`
Username string `yaml:"username"`
Token string `yaml:"token"`
Owner string `yaml:"owner"`
UseSSH bool `yaml:"useSsh"`
SSHKeyPath string `yaml:"sshKeyPath,omitempty"`
CloneParent string `yaml:"cloneParent"`
}
type FluxConfig struct {
RepoName string `yaml:"repoName"`
Branch string `yaml:"branch"`
ClusterPath string `yaml:"clusterPath"`
ClusterDomain string `yaml:"clusterDomain"`
ManifestsRepo string `yaml:"manifestsRepo"`
}
type TemplateConfig struct {
TalosRepoURL string `yaml:"talosRepoUrl"`
TalosRepoRef string `yaml:"talosRepoRef"`
}
type TalosConfig struct {
RepoDirName string `yaml:"repoDirName"`
TerraformDir string `yaml:"terraformDir"`
GeneratedDir string `yaml:"generatedDir"`
ConfigFileName string `yaml:"configFileName"`
AutoRunTerraform bool `yaml:"autoRunTerraform"`
AutoBootstrap bool `yaml:"autoBootstrap"`
AutoBootstrapFlux bool `yaml:"autoBootstrapFlux"`
BootstrapNode string `yaml:"bootstrapNode"`
KubeconfigNode string `yaml:"kubeconfigNode"`
Proxmox TalosProxmoxConfig `yaml:"proxmox"`
Cluster TalosClusterConfig `yaml:"cluster"`
Image TalosImageConfig `yaml:"image"`
Nodes []TalosNode `yaml:"nodes"`
}
type TalosProxmoxConfig struct {
APIURL string `yaml:"apiUrl"`
APITokenID string `yaml:"apiTokenId"`
APITokenSecret string `yaml:"apiTokenSecret"`
DefaultNode string `yaml:"defaultNode"`
Pool string `yaml:"pool"`
NodeInterfaces map[string]string `yaml:"nodeInterfaces"`
Insecure bool `yaml:"insecure"`
}
type TalosClusterConfig struct {
Name string `yaml:"name"`
Domain string `yaml:"domain"`
ControlPlaneVIP string `yaml:"controlPlaneVip"`
DNSServers []string `yaml:"dnsServers"`
DiskStorage string `yaml:"diskStorage"`
AdditionalStorage string `yaml:"additionalStorage"`
CreateVLANInterface bool `yaml:"createVlanInterface"`
}
type TalosImageConfig struct {
FactorySchematicID string `yaml:"factorySchematicId"`
TalosVersion string `yaml:"talosVersion"`
UpdateMode string `yaml:"updateMode"`
Storage string `yaml:"storage"`
Architecture string `yaml:"architecture"`
}
type TalosNode struct {
Name string `yaml:"name"`
VMID int `yaml:"vmid"`
Role string `yaml:"role"`
Cores int `yaml:"cores"`
Memory int `yaml:"memory"`
DiskSize string `yaml:"diskSize"`
AdditionalDiskSize string `yaml:"additionalDiskSize,omitempty"`
Tags []string `yaml:"tags"`
ProxmoxNode string `yaml:"proxmoxNode"`
Networks []TalosNetwork `yaml:"networks"`
}
type TalosNetwork struct {
MACAddress string `yaml:"macAddress"`
CIDR string `yaml:"cidr"`
IP string `yaml:"ip,omitempty"`
Gateway string `yaml:"gateway,omitempty"`
VLANID int `yaml:"vlanId"`
}

134
internal/forgejo/repo.go Normal file
View file

@ -0,0 +1,134 @@
package forgejo
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/Pingu-Studio/MaidnCLI/internal/utils"
)
type RepoManager struct {
BaseURL string
Token string
Owner string
Username string
ManifestsRepoName string
FluxRepoName string
}
type createRepoRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Private bool `json:"private"`
AutoInit bool `json:"auto_init"`
}
func NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo string) *RepoManager {
return &RepoManager{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
Owner: owner,
Username: username,
ManifestsRepoName: manifestsRepo,
FluxRepoName: fluxRepo,
}
}
func (rm *RepoManager) InitializeAll(createRepo func(string) error, createFlux func(string) error) error {
if err := rm.ensureRepo(rm.ManifestsRepoName, "Centralized deployment manifests for Flux CD", createRepo); err != nil {
return err
}
if err := rm.ensureRepo(rm.FluxRepoName, "Flux CD cluster configurations", createFlux); err != nil {
return err
}
return nil
}
func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error {
repoURL := fmt.Sprintf("%s/%s/%s.git", rm.BaseURL, rm.Owner, name)
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s", rm.BaseURL, rm.Owner, name)
if err := rm.apiRequest(http.MethodGet, apiURL, nil); err != nil {
createURL := fmt.Sprintf("%s/api/v1/orgs/%s/repos", rm.BaseURL, rm.Owner)
if rm.Owner == rm.Username {
createURL = fmt.Sprintf("%s/api/v1/user/repos", rm.BaseURL)
}
body, _ := json.Marshal(createRepoRequest{Name: name, Description: description, Private: true, AutoInit: true})
if apiErr := rm.apiRequest(http.MethodPost, createURL, body); apiErr != nil {
return apiErr
}
}
return rm.setupRepository(repoURL, name, createStructure)
}
func (rm *RepoManager) setupRepository(repoURL, repoName string, createStructure func(string) error) error {
tempDir, err := os.MkdirTemp("", "repo-setup-*")
if err != nil {
return err
}
defer os.RemoveAll(tempDir)
if err := utils.RunCommand("git", "clone", repoURL, tempDir); err != nil {
return err
}
if err := createStructure(tempDir); err != nil {
return err
}
return commitAndPush(tempDir, repoName)
}
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
}
if err := utils.RunCommandInDir(tempDir, "git", "push", "origin", "main"); err != nil {
return err
}
fmt.Printf("[SUCCESS] Changes pushed to %s successfully.\n", repoName)
return nil
}
func (rm *RepoManager) apiRequest(method, endpoint string, body []byte) error {
client := http.Client{Timeout: 15 * time.Second}
request, err := http.NewRequest(method, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Authorization", "token "+rm.Token)
request.Header.Set("Content-Type", "application/json")
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 300 {
return fmt.Errorf("forgejo returned %s for %s", response.Status, endpoint)
}
return nil
}
func FluxSourceURL(baseURL, owner, repo string) string {
return fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(baseURL, "/"), owner, repo)
}
func CloneURL(baseURL, owner, repo string) string {
return fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(baseURL, "/"), owner, repo)
}
func RepoPath(parent, dirName string) string {
return filepath.Join(parent, dirName)
}

View file

@ -0,0 +1,158 @@
package github
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/assets"
"github.com/Pingu-Studio/MaidnCLI/internal/utils"
)
func BuildFluxConfig(chartOwner, gitURL, manifestsRepo string) string {
return fmt.Sprintf(`---
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
name: ghcr-charts
namespace: flux-system
spec:
interval: 5m
type: oci
url: oci://ghcr.io/%s
secretRef:
name: ghcr-auth
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: cicd-deployment-manifests
namespace: flux-system
spec:
interval: 1m0s
url: %s
ref:
branch: main
secretRef:
name: github-auth
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: namespaces
namespace: flux-system
spec:
dependsOn:
- name: vault-instance
- name: democratic-csi
- name: metallb-config
interval: 10m
path: ./namespaces
prune: true
sourceRef:
kind: GitRepository
name: cicd-deployment-manifests
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: preview-apps
namespace: flux-system
spec:
dependsOn:
- name: namespaces
interval: 2m0s
path: ./apps/previews
prune: true
sourceRef:
kind: GitRepository
name: cicd-deployment-manifests
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: staging-apps
namespace: flux-system
spec:
dependsOn:
- name: namespaces
interval: 2m0s
path: ./apps/staging
prune: true
sourceRef:
kind: GitRepository
name: cicd-deployment-manifests
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: production-apps
namespace: flux-system
spec:
dependsOn:
- name: namespaces
interval: 2m0s
path: ./apps/production
prune: true
sourceRef:
kind: GitRepository
name: cicd-deployment-manifests
`, chartOwner, gitURL)
}
type RepoManager struct {
OrgName string
ManifestsRepoName string
FluxRepoName string
}
func NewRepoManager(org, manifestsRepo, fluxRepo string) *RepoManager {
return &RepoManager{OrgName: org, ManifestsRepoName: manifestsRepo, FluxRepoName: fluxRepo}
}
func (rm *RepoManager) InitializeManifestsRepo() {}
func (rm *RepoManager) InitializeFluxRepo() {}
func (rm *RepoManager) InitializeAll() error { return nil }
func WriteManifestsStructure(baseDir, repoName string) error {
manifestsReadme := fmt.Sprintf(assets.ManifestsReadmeTmpl, repoName)
utils.WriteFile(filepath.Join(baseDir, "README.md"), manifestsReadme)
namespacesDir := filepath.Join(baseDir, "namespaces")
os.MkdirAll(namespacesDir, 0755)
utils.WriteFile(filepath.Join(namespacesDir, "staging.yaml"), "apiVersion: v1\nkind: Namespace\nmetadata:\n name: staging\n")
utils.WriteFile(filepath.Join(namespacesDir, "production.yaml"), "apiVersion: v1\nkind: Namespace\nmetadata:\n name: production\n")
utils.WriteFile(filepath.Join(namespacesDir, "kustomization.yaml"), "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - staging.yaml\n - production.yaml\n")
for _, env := range []string{"staging", "production", "previews"} {
dirPath := filepath.Join(baseDir, "apps", env)
os.MkdirAll(dirPath, 0755)
utils.WriteFile(filepath.Join(dirPath, ".gitkeep"), "")
}
return nil
}
func WriteFluxStructure(baseDir, repoName, clusterPath, fluxConfig string) error {
fluxReadme := fmt.Sprintf(assets.FluxReadmeTmpl, repoName)
utils.WriteFile(filepath.Join(baseDir, "README.md"), fluxReadme)
clusterDir := filepath.Join(baseDir, strings.TrimPrefix(clusterPath, "./"))
os.MkdirAll(clusterDir, 0755)
utils.WriteFile(filepath.Join(clusterDir, "cicd-manifests-repo.yaml"), fluxConfig)
return nil
}
func CommitAndPush(tempDir 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 utils.RunCommandInDir(tempDir, "git", "push", "origin", "main")
}

View file

@ -0,0 +1,185 @@
package k8s
import (
"bytes"
"fmt"
"os/exec"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/vault"
"gopkg.in/yaml.v3"
)
// ExternalSecret Structs to represent the YAML structure
type ExternalSecret struct {
ApiVersion string `yaml:"apiVersion"`
Kind string `yaml:"kind"`
Metadata Metadata `yaml:"metadata"`
Spec ExternalSecretSpec `yaml:"spec"`
}
type Metadata struct {
Name string `yaml:"name"`
Namespace string `yaml:"namespace"`
}
type ExternalSecretSpec struct {
SecretStoreRef SecretStoreRef `yaml:"secretStoreRef"`
Target ExternalSecretTarget `yaml:"target"`
Data []ExternalSecretData `yaml:"data"`
}
type SecretStoreRef struct {
Name string `yaml:"name"`
Kind string `yaml:"kind"`
}
type ExternalSecretTarget struct {
Name string `yaml:"name"`
CreationPolicy string `yaml:"creationPolicy"`
Template ExternalSecretTemplate `yaml:"template,omitempty"`
}
type ExternalSecretTemplate struct {
Type string `yaml:"type"`
Data map[string]string `yaml:"data"`
}
type ExternalSecretData struct {
SecretKey string `yaml:"secretKey"`
RemoteRef ExternalSecretDataRemoteRef `yaml:"remoteRef"`
}
type ExternalSecretDataRemoteRef struct {
Key string `yaml:"key"`
Property string `yaml:"property"`
}
// CheckExternalSecretCRD checks if the ExternalSecret CRD is installed on the cluster.
func CheckExternalSecretCRD() error {
fmt.Println("[INFO] Verifying that the ExternalSecret CRD is installed...")
// Use api-resources to check if the resource kind exists in the specified API group
cmd := exec.Command("kubectl", "api-resources", "--api-group=external-secrets.io", "-o", "name")
output, err := cmd.CombinedOutput()
if err != nil {
// This error occurs if kubectl fails or the API group doesn't exist at all.
return fmt.Errorf("failed to query for ExternalSecret CRD. Is kubectl configured correctly? Error: %w\nOutput: %s", err, string(output))
}
// We expect the output to contain "externalsecrets.external-secrets.io"
if !strings.Contains(string(output), "externalsecrets.external-secrets.io") {
return fmt.Errorf("the 'ExternalSecret' CRD was not found on the cluster. Please install the External Secrets Operator first.\nSee: https://external-secrets.io/latest/getting-started/installation/")
}
fmt.Println("[SUCCESS] ExternalSecret CRD found.")
return nil
}
// CreateGhcrExternalSecret generates and applies the ExternalSecret for GHCR.
func CreateGhcrExternalSecret() error {
fmt.Println("[INFO] Creating ExternalSecret for GHCR in 'flux-system' namespace...")
externalSecret := ExternalSecret{
ApiVersion: "external-secrets.io/v1",
Kind: "ExternalSecret",
Metadata: Metadata{
Name: "ghcr-auth",
Namespace: "flux-system",
},
Spec: ExternalSecretSpec{
SecretStoreRef: SecretStoreRef{
Name: "vault-backend",
Kind: "ClusterSecretStore",
},
Target: ExternalSecretTarget{
Name: "ghcr-auth",
CreationPolicy: "Owner",
Template: ExternalSecretTemplate{
Type: "kubernetes.io/dockerconfigjson",
Data: map[string]string{
".dockerconfigjson": `{{ index . ".dockerconfigjson" | toString }}`,
},
},
},
Data: []ExternalSecretData{
{
SecretKey: ".dockerconfigjson",
RemoteRef: ExternalSecretDataRemoteRef{
Key: vault.GhcrSecretPath,
Property: vault.DockerConfigJSONKey,
},
},
},
},
}
yamlData, err := yaml.Marshal(&externalSecret)
if err != nil {
return fmt.Errorf("failed to marshal ExternalSecret to YAML: %w", err)
}
cmd := exec.Command("kubectl", "apply", "-f", "-")
cmd.Stdin = bytes.NewReader(yamlData)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to apply ExternalSecret: %s\n%w", string(output), err)
}
fmt.Printf("[SUCCESS] Applied ExternalSecret 'ghcr-auth' in 'flux-system' namespace.\n")
return nil
}
// CreateDockerHubExternalSecret generates and applies the ExternalSecret for Docker Hub.
func CreateDockerHubExternalSecret() error {
fmt.Println("[INFO] Creating ExternalSecret for Docker Hub in 'flux-system' namespace...")
externalSecret := ExternalSecret{
ApiVersion: "external-secrets.io/v1",
Kind: "ExternalSecret",
Metadata: Metadata{
Name: "dockerhub-auth",
Namespace: "flux-system",
},
Spec: ExternalSecretSpec{
SecretStoreRef: SecretStoreRef{
Name: "vault-backend",
Kind: "ClusterSecretStore",
},
Target: ExternalSecretTarget{
Name: "dockerhub-auth",
CreationPolicy: "Owner",
Template: ExternalSecretTemplate{
Type: "kubernetes.io/dockerconfigjson",
Data: map[string]string{
".dockerconfigjson": "{{ .dockerconfigjson }}",
},
},
},
Data: []ExternalSecretData{
{
SecretKey: "dockerconfigjson",
RemoteRef: ExternalSecretDataRemoteRef{
Key: vault.DockerHubSecretPath,
Property: vault.DockerConfigJSONKey,
},
},
},
},
}
yamlData, err := yaml.Marshal(&externalSecret)
if err != nil {
return fmt.Errorf("failed to marshal ExternalSecret to YAML: %w", err)
}
cmd := exec.Command("kubectl", "apply", "-f", "-")
cmd.Stdin = bytes.NewReader(yamlData)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to apply ExternalSecret: %s\n%w", string(output), err)
}
fmt.Printf("[SUCCESS] Applied ExternalSecret 'dockerhub-auth' in 'flux-system' namespace.\n")
return nil
}

165
internal/proxmox/client.go Normal file
View file

@ -0,0 +1,165 @@
package proxmox
import (
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
type Client struct {
baseURL string
tokenID string
tokenSecret string
httpClient *http.Client
}
type clusterNodesResponse struct {
Data []struct {
Node string `json:"node"`
Status string `json:"status"`
} `json:"data"`
}
type nodeStatusResponse struct {
Data struct {
CPU float64 `json:"cpu"`
Memory struct {
Used uint64 `json:"used"`
Total uint64 `json:"total"`
} `json:"memory"`
RootFS struct {
Avail uint64 `json:"avail"`
Total uint64 `json:"total"`
} `json:"rootfs"`
} `json:"data"`
}
type networkResponse struct {
Data []struct {
Iface string `json:"iface"`
Type string `json:"type"`
Active int `json:"active"`
} `json:"data"`
}
type storageResponse struct {
Data []struct {
Storage string `json:"storage"`
Type string `json:"type"`
Avail uint64 `json:"avail"`
Total uint64 `json:"total"`
} `json:"data"`
}
type NodeInfo struct {
Name string
Networks []string
Storages []StorageInfo
FreeMemoryMB int
TotalMemoryMB int
FreeDiskGB int
}
type StorageInfo struct {
Name string
Type string
FreeGB int
SupportsISO bool
}
func New(baseURL, tokenID, tokenSecret string, insecure bool) *Client {
transport := &http.Transport{}
if insecure {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
tokenID: tokenID,
tokenSecret: tokenSecret,
httpClient: &http.Client{Timeout: 15 * time.Second, Transport: transport},
}
}
func (c *Client) Discover() ([]NodeInfo, error) {
var nodesResp clusterNodesResponse
if err := c.getJSON("/cluster/resources?type=node", &nodesResp); err != nil {
return nil, err
}
result := make([]NodeInfo, 0, len(nodesResp.Data))
for _, node := range nodesResp.Data {
if node.Node == "" || node.Status != "online" {
continue
}
info, err := c.describeNode(node.Node)
if err != nil {
return nil, err
}
result = append(result, info)
}
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
return result, nil
}
func (c *Client) describeNode(node string) (NodeInfo, error) {
var status nodeStatusResponse
if err := c.getJSON("/nodes/"+node+"/status", &status); err != nil {
return NodeInfo{}, err
}
var networks networkResponse
if err := c.getJSON("/nodes/"+node+"/network", &networks); err != nil {
return NodeInfo{}, err
}
var storages storageResponse
if err := c.getJSON("/nodes/"+node+"/storage", &storages); err != nil {
return NodeInfo{}, err
}
info := NodeInfo{
Name: node,
FreeMemoryMB: int((status.Data.Memory.Total - status.Data.Memory.Used) / 1024 / 1024),
TotalMemoryMB: int(status.Data.Memory.Total / 1024 / 1024),
FreeDiskGB: int(status.Data.RootFS.Avail / 1024 / 1024 / 1024),
}
for _, net := range networks.Data {
if net.Active == 1 && (net.Type == "bridge" || net.Type == "eth") {
info.Networks = append(info.Networks, net.Iface)
}
}
for _, storage := range storages.Data {
info.Storages = append(info.Storages, StorageInfo{
Name: storage.Storage,
Type: storage.Type,
FreeGB: int(storage.Avail / 1024 / 1024 / 1024),
SupportsISO: storage.Type == "dir" || storage.Type == "nfs",
})
}
return info, nil
}
func (c *Client) getJSON(path string, target any) error {
request, err := http.NewRequest(http.MethodGet, c.baseURL+"/api2/json"+path, nil)
if err != nil {
return err
}
request.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s=%s", c.tokenID, c.tokenSecret))
response, err := c.httpClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 300 {
return fmt.Errorf("proxmox returned %s", response.Status)
}
return json.NewDecoder(response.Body).Decode(target)
}
func BuildAPIURL(host string) string {
if strings.HasPrefix(host, "http") {
return strings.TrimRight(host, "/")
}
return (&url.URL{Scheme: "https", Host: host, Path: "/api2/json"}).String()
}

View file

@ -0,0 +1,38 @@
package talos
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
type releaseResponse struct {
TagName string `json:"tag_name"`
}
func LatestVersion() string {
client := http.Client{Timeout: 10 * time.Second}
request, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/siderolabs/talos/releases/latest", nil)
if err != nil {
return "v1.13.6"
}
request.Header.Set("Accept", "application/vnd.github+json")
response, err := client.Do(request)
if err != nil {
return "v1.13.6"
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "v1.13.6"
}
var release releaseResponse
if err := json.NewDecoder(response.Body).Decode(&release); err != nil || release.TagName == "" {
return "v1.13.6"
}
return release.TagName
}
func FactoryHint(version string) string {
return fmt.Sprintf("https://factory.talos.dev/?version=%s", version)
}

120
internal/ui/prompts.go Normal file
View file

@ -0,0 +1,120 @@
package ui
import (
"bufio"
"fmt"
"os"
"strings"
"syscall"
"golang.org/x/term"
)
// GetUserInputForGhcr prompts the user for GitHub-related credentials for GHCR.
func GetUserInputForGhcr() (username, registryToken, orgToken string, err error) {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter your GitHub username: ")
username, err = reader.ReadString('\n')
if err != nil {
return
}
username = strings.TrimSpace(username)
if username == "" {
err = fmt.Errorf("username cannot be empty")
return
}
registryToken, err = promptForToken(reader, "Enter your GitHub PAT for container registry")
if err != nil {
return
}
orgToken, err = promptForToken(reader, "Enter your GitHub PAT for organization management")
if err != nil {
return
}
return
}
// GetUserInputForDockerHub prompts the user for Docker Hub credentials.
func GetUserInputForDockerHub() (username, password string, err error) {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter your Docker Hub username: ")
username, err = reader.ReadString('\n')
if err != nil {
return
}
username = strings.TrimSpace(username)
fmt.Print("Enter your Docker Hub password or access token: ")
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return "", "", err
}
password = string(passwordBytes)
if username == "" || password == "" {
err = fmt.Errorf("username and password cannot be empty")
}
return
}
// GetUserInputForGithubAuth prompts the user for a general GitHub PAT.
func GetUserInputForGithubAuth() (username, token string, err error) {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter your GitHub username: ")
username, err = reader.ReadString('\n')
if err != nil {
return
}
username = strings.TrimSpace(username)
if username == "" {
err = fmt.Errorf("username cannot be empty")
return
}
token, err = promptForToken(reader, "Enter your GitHub PAT for repository access")
if err != nil {
return
}
return
}
func promptForToken(reader *bufio.Reader, prompt string) (string, error) {
fmt.Printf("%s: ", prompt)
tokenBytes, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return "", err
}
token := string(tokenBytes)
if token == "" {
return "", fmt.Errorf("token cannot be empty")
}
validPrefixes := []string{"ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_"}
hasValidPrefix := false
for _, prefix := range validPrefixes {
if strings.HasPrefix(token, prefix) {
hasValidPrefix = true
break
}
}
if !hasValidPrefix {
fmt.Printf("[WARNING] The token does not appear to be a valid GitHub PAT (should start with one of %v)\n", validPrefixes)
fmt.Print("Do you want to continue anyway? (y/N): ")
confirm, _ := reader.ReadString('\n')
if strings.TrimSpace(strings.ToLower(confirm)) != "y" {
return "", fmt.Errorf("operation cancelled by user")
}
}
return token, nil
}

229
internal/ui/wizard.go Normal file
View file

@ -0,0 +1,229 @@
package ui
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/proxmox"
"github.com/Pingu-Studio/MaidnCLI/internal/talos"
)
func RunBootstrapWizard() (config.Config, error) {
reader := bufio.NewReader(os.Stdin)
cfg := config.Config{}
cfg.WorkspaceDir = prompt(reader, "Workspace directory", ".\\maidn-workspace")
cfg.Git.Provider = "forgejo"
cfg.Git.BaseURL = prompt(reader, "Git server URL", "https://git.pingu.pw")
cfg.Git.Username = prompt(reader, "Git username", "eding")
cfg.Git.Owner = prompt(reader, "Repo owner/user", "Maidn")
cfg.Git.Token = prompt(reader, "Forgejo access token", "")
cfg.Git.CloneParent = prompt(reader, "Clone directory", cfg.WorkspaceDir)
cfg.Flux.RepoName = prompt(reader, "Flux repo name", "test-maidn-cd-cluster")
cfg.Flux.ManifestsRepo = prompt(reader, "Manifests repo name", "cicd-deployment-manifests")
cfg.Flux.Branch = "main"
cfg.Flux.ClusterPath = "./clusters/maidn-cd-0"
cfg.Templates.TalosRepoURL = prompt(reader, "Talos template repo URL", "https://git.pingu.pw/Maidn/maidn-talos-proxmox.git")
cfg.Templates.TalosRepoRef = "main"
cfg.Talos.RepoDirName = "maidn-talos-proxmox"
cfg.Talos.TerraformDir = "terraform"
cfg.Talos.GeneratedDir = "generated"
cfg.Talos.ConfigFileName = "terraform.tfvars"
cfg.Talos.AutoRunTerraform = promptBool(reader, "Run terraform automatically", true)
cfg.Talos.AutoBootstrap = promptBool(reader, "Run talos bootstrap automatically", true)
cfg.Talos.AutoBootstrapFlux = promptBool(reader, "Run flux bootstrap automatically", true)
cfg.Talos.Proxmox.APIURL = prompt(reader, "Proxmox API URL", "https://192.168.0.15:8006")
cfg.Talos.Proxmox.APITokenID = prompt(reader, "Proxmox API token ID", "root@pam!iac")
cfg.Talos.Proxmox.APITokenSecret = prompt(reader, "Proxmox API token secret", "")
cfg.Talos.Proxmox.Insecure = true
cfg.Talos.Proxmox.Pool = prompt(reader, "Proxmox pool", "")
discovered, err := proxmox.New(cfg.Talos.Proxmox.APIURL, cfg.Talos.Proxmox.APITokenID, cfg.Talos.Proxmox.APITokenSecret, cfg.Talos.Proxmox.Insecure).Discover()
if err != nil {
return cfg, err
}
printNodes(discovered)
cfg.Talos.Proxmox.DefaultNode = prompt(reader, "Default Proxmox node", discovered[0].Name)
cfg.Talos.Proxmox.NodeInterfaces = map[string]string{}
for _, node := range discovered {
fallback := first(node.Networks, "vmbr0")
cfg.Talos.Proxmox.NodeInterfaces[node.Name] = prompt(reader, fmt.Sprintf("Primary interface for %s", node.Name), fallback)
}
cfg.Talos.Cluster.Name = prompt(reader, "Talos cluster name", "proxmox-talos-dev02-cluster")
cfg.Talos.Cluster.Domain = prompt(reader, "Talos cluster domain", "dev02.nid3.com")
cfg.Flux.ClusterDomain = cfg.Talos.Cluster.Domain
cfg.Talos.Cluster.ControlPlaneVIP = prompt(reader, "Control plane VIP", "192.168.45.2")
cfg.Talos.Cluster.DNSServers = promptList(reader, "DNS servers", []string{"192.168.0.222", "1.1.1.1"})
cfg.Talos.Cluster.DiskStorage = prompt(reader, "Primary disk storage", firstStorage(discovered, true, "local-lvm"))
cfg.Talos.Cluster.AdditionalStorage = prompt(reader, "Additional disk storage", cfg.Talos.Cluster.DiskStorage)
cfg.Talos.Cluster.CreateVLANInterface = promptBool(reader, "Create VLAN interfaces", true)
latestTalos := talos.LatestVersion()
cfg.Talos.Image.TalosVersion = prompt(reader, "Talos version", latestTalos)
fmt.Printf("Talos factory: %s\n", talos.FactoryHint(cfg.Talos.Image.TalosVersion))
cfg.Talos.Image.FactorySchematicID = prompt(reader, "Talos factory schematic ID", "")
cfg.Talos.Image.UpdateMode = "download"
cfg.Talos.Image.Storage = prompt(reader, "ISO storage", firstStorage(discovered, false, "local"))
cfg.Talos.Image.Architecture = chooseArchitecture(reader)
nodePreset := prompt(reader, "Cluster size preset (single/ha/custom)", "single")
cfg.Talos.Nodes = buildNodes(reader, cfg, discovered, nodePreset)
cfg.Talos.BootstrapNode = cfg.Talos.Nodes[0].Networks[0].IP
cfg.Talos.KubeconfigNode = cfg.Talos.Nodes[0].Networks[0].IP
return cfg, nil
}
func printNodes(nodes []proxmox.NodeInfo) {
fmt.Println("Available Proxmox nodes:")
for _, node := range nodes {
fmt.Printf("- %s: free %dMB RAM, total %dMB RAM, free root %dGB, networks=%s\n", node.Name, node.FreeMemoryMB, node.TotalMemoryMB, node.FreeDiskGB, strings.Join(node.Networks, ","))
}
}
func chooseArchitecture(reader *bufio.Reader) string {
fmt.Println("Architecture options: 1) amd64 2) arm64")
choice := prompt(reader, "Architecture", "1")
if choice == "2" {
return "arm64"
}
return "amd64"
}
func buildNodes(reader *bufio.Reader, cfg config.Config, discovered []proxmox.NodeInfo, preset string) []config.TalosNode {
switch preset {
case "ha":
return buildPresetNodes(reader, cfg, discovered, 3, 2)
case "custom":
count, _ := strconv.Atoi(prompt(reader, "Number of nodes", "3"))
if count < 1 {
count = 1
}
workers := count - 1
if workers < 0 {
workers = 0
}
return buildPresetNodes(reader, cfg, discovered, count, workers)
default:
return buildPresetNodes(reader, cfg, discovered, 1, 0)
}
}
func buildPresetNodes(reader *bufio.Reader, cfg config.Config, discovered []proxmox.NodeInfo, count, workers int) []config.TalosNode {
result := make([]config.TalosNode, 0, count)
for i := 0; i < count; i++ {
role := "controlplane"
if i > 0 && i <= workers {
role = "worker"
}
name := fmt.Sprintf("talos-%s-%02d", map[bool]string{role == "controlplane": "cp", role != "controlplane": "wk"}[true], i+1)
defaultNode := discovered[min(i, len(discovered)-1)].Name
proxmoxNode := prompt(reader, fmt.Sprintf("Node %d Proxmox node", i+1), defaultNode)
memoryDefault := "4096"
coresDefault := "2"
diskDefault := "48G"
if role == "worker" {
memoryDefault = "2048"
coresDefault = "2"
diskDefault = "32G"
}
ip := prompt(reader, fmt.Sprintf("Node %d IP", i+1), "")
gateway := prompt(reader, fmt.Sprintf("Node %d gateway", i+1), "192.168.45.1")
cidr := prompt(reader, fmt.Sprintf("Node %d CIDR", i+1), "192.168.45.0/24")
vlanID, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d VLAN ID", i+1), "45"))
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))
cores, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d cores", i+1), coresDefault))
result = append(result, config.TalosNode{
Name: name,
VMID: vmid,
Role: role,
Cores: cores,
Memory: memory,
DiskSize: prompt(reader, fmt.Sprintf("Node %d disk size", i+1), diskDefault),
Tags: []string{"talos", role},
ProxmoxNode: proxmoxNode,
Networks: []config.TalosNetwork{{
MACAddress: prompt(reader, fmt.Sprintf("Node %d MAC", i+1), ""),
CIDR: cidr,
IP: ip,
Gateway: gateway,
VLANID: vlanID,
}},
})
}
return result
}
func prompt(reader *bufio.Reader, label, fallback string) string {
if fallback != "" {
fmt.Printf("%s [%s]: ", label, fallback)
} else {
fmt.Printf("%s: ", label)
}
value, _ := reader.ReadString('\n')
fmt.Println()
value = strings.TrimSpace(value)
if value == "" {
return fallback
}
return value
}
func promptBool(reader *bufio.Reader, label string, fallback bool) bool {
defaultValue := "y"
if !fallback {
defaultValue = "n"
}
value := prompt(reader, label+" [y/n, default "+defaultValue+"]", "")
if value == "" {
return fallback
}
value = strings.ToLower(value)
return value == "y" || value == "yes"
}
func promptList(reader *bufio.Reader, label string, fallback []string) []string {
value := prompt(reader, label, strings.Join(fallback, ","))
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func first(values []string, fallback string) string {
if len(values) == 0 {
return fallback
}
return values[0]
}
func firstStorage(nodes []proxmox.NodeInfo, iso bool, fallback string) string {
for _, node := range nodes {
for _, storage := range node.Storages {
if iso && storage.SupportsISO {
return storage.Name
}
if !iso {
return storage.Name
}
}
}
return fallback
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

111
internal/utils/helpers.go Normal file
View file

@ -0,0 +1,111 @@
package utils
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
)
func WriteFile(path, content string) {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
fmt.Printf("[ERROR] Failed to write file %s: %v\n", path, err)
os.Exit(1)
}
}
func CommandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func RunCommand(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func RunCommandQuiet(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stderr = os.Stderr
return cmd.Run()
}
func RunCommandInDir(dir string, name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func RunCommandOutput(name string, args ...string) ([]byte, error) {
cmd := exec.Command(name, args...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return nil, err
}
return stdout.Bytes(), nil
}
func RunCommandJSON(target any, name string, args ...string) error {
output, err := RunCommandOutput(name, args...)
if err != nil {
return err
}
return json.Unmarshal(output, target)
}
func HandleKubectlCacheError(err error) bool {
if err == nil || !strings.Contains(err.Error(), "no matches for kind") {
return false
}
fmt.Println("\n[DIAGNOSIS] A common kubectl cache issue was detected.")
fmt.Println("This happens when kubectl's local cache is out of sync with the cluster, often after installing new CRDs.")
fmt.Print("Would you like to attempt to clear the cache now? (y/N): ")
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
if strings.TrimSpace(strings.ToLower(response)) != "y" {
fmt.Println("[INFO] Operation cancelled by user.")
return false
}
var cmd *exec.Cmd
var shell string
if runtime.GOOS == "windows" {
shell = "PowerShell"
cmd = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", "Remove-Item -Path $env:USERPROFILE\\.kube\\cache -Recurse -Force")
} else {
shell = "shell"
homeDir, homeErr := os.UserHomeDir()
if homeErr != nil {
fmt.Printf("[ERROR] Could not determine home directory to clear cache: %v\n", homeErr)
return false
}
cachePath := fmt.Sprintf("%s/.kube/cache", homeDir)
cmd = exec.Command("rm", "-rf", cachePath)
}
fmt.Printf("[INFO] Attempting to clear kubectl cache using %s...\n", shell)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("[ERROR] Failed to clear kubectl cache: %v\n", err)
return false
}
fmt.Println("[SUCCESS] Kubectl cache cleared successfully.")
return true
}

167
internal/vault/client.go Normal file
View file

@ -0,0 +1,167 @@
package vault
import (
"encoding/base64"
"encoding/json"
"fmt"
"os/exec"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/utils"
)
// Constants for Vault and Kubernetes
const (
vaultNamespace = "vault"
vaultPodLabelSelector = "app.kubernetes.io/name=vault"
vaultRootTokenSecretName = "vault-unseal-keys"
vaultRootTokenSecretKey = "vault-root"
defaultVaultAddress = "https://127.0.0.1:8200"
// Exported constants
GhcrSecretPath = "secret/ghcr-auth"
DockerHubSecretPath = "secret/dockerhub-auth"
GithubAuthSecretPath = "secret/github-auth"
OrgManagementSecretPath = "secret/org-management-pat"
DockerConfigJSONKey = ".dockerconfigjson"
OrgManagementPATKey = "ORG_MANAGEMENT_PAT"
GhcrRegistry = "ghcr.io"
DockerHubRegistry = "https://index.docker.io/v1/"
)
// DockerConfig represents the structure of a Docker config.json
type DockerConfig struct {
Auths map[string]DockerAuth `json:"auths"`
}
// DockerAuth represents the authentication info for a Docker registry
type DockerAuth struct {
Username string `json:"username"`
Password string `json:"password"`
Auth string `json:"auth"`
}
// VaultManager handles all interactions with Kubernetes and Vault.
type VaultManager struct {
podName string
rootToken string
}
// NewVaultManager creates and initializes a new VaultManager.
func NewVaultManager() (*VaultManager, error) {
vm := &VaultManager{}
fmt.Println("[INFO] Checking dependencies (kubectl)...")
if !utils.CommandExists("kubectl") {
return nil, fmt.Errorf("'kubectl' must be installed and in your PATH")
}
fmt.Println("[INFO] Finding Vault pod...")
podName, err := vm.getVaultPodName()
if err != nil {
return nil, err
}
vm.podName = podName
fmt.Printf("[INFO] Using Vault pod: %s\n", vm.podName)
fmt.Println("[INFO] Retrieving Vault root token...")
rootToken, err := vm.getVaultRootToken()
if err != nil {
return nil, err
}
vm.rootToken = rootToken
fmt.Println("[SUCCESS] Found Vault root token.")
fmt.Println("[INFO] Verifying Vault connection and token...")
if err := vm.verifyVaultToken(); err != nil {
return nil, fmt.Errorf("failed to validate Vault token: %w", err)
}
fmt.Println("[SUCCESS] Vault token is valid.")
return vm, nil
}
func (vm *VaultManager) getVaultPodName() (string, error) {
commonNames := []string{"vault-0", "vault-1", "vault-2"}
for _, name := range commonNames {
if err := utils.RunCommandQuiet("kubectl", "get", "pod", name, "-n", vaultNamespace); err == nil {
return name, nil
}
}
cmd := exec.Command("kubectl", "get", "pods", "-n", vaultNamespace, "-l", vaultPodLabelSelector, "-o", "jsonpath={.items[0].metadata.name}")
output, err := cmd.Output()
if err == nil && len(output) > 0 {
return string(output), nil
}
return "", fmt.Errorf("could not find any running Vault pods in the '%s' namespace", vaultNamespace)
}
func (vm *VaultManager) getVaultRootToken() (string, error) {
cmd := exec.Command("kubectl", "get", "secret", vaultRootTokenSecretName, "-n", vaultNamespace, "-o", fmt.Sprintf("jsonpath={.data.%s}", vaultRootTokenSecretKey))
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get '%s' secret from '%s' namespace: %w", vaultRootTokenSecretName, vaultNamespace, err)
}
if len(output) == 0 {
return "", fmt.Errorf("key '%s' not found in secret '%s'", vaultRootTokenSecretKey, vaultRootTokenSecretName)
}
decoded, err := base64.StdEncoding.DecodeString(string(output))
if err != nil {
return "", fmt.Errorf("failed to decode base64 root token: %w", err)
}
return strings.TrimSpace(string(decoded)), nil
}
func (vm *VaultManager) runVaultCommandQuiet(command string) error {
envSetup := fmt.Sprintf("export VAULT_SKIP_VERIFY=true && export VAULT_ADDR='%s' && export VAULT_TOKEN='%s'", defaultVaultAddress, vm.rootToken)
fullCmd := fmt.Sprintf("%s && %s", envSetup, command)
return utils.RunCommandQuiet("kubectl", "exec", "-n", vaultNamespace, vm.podName, "--", "sh", "-c", fullCmd)
}
func (vm *VaultManager) verifyVaultToken() error {
return vm.runVaultCommandQuiet("vault token lookup")
}
// StoreSecret stores a key-value secret at the specified path in Vault quietly.
func (vm *VaultManager) StoreSecret(path string, data map[string]string) error {
fmt.Printf("[INFO] Storing secret in Vault at path: %s...\n", path)
var kvPairs []string
for key, value := range data {
escapedValue := strings.ReplaceAll(value, "'", "'\"'\"'")
kvPairs = append(kvPairs, fmt.Sprintf("%s='%s'", key, escapedValue))
}
vaultCmd := fmt.Sprintf("vault kv put %s %s", path, strings.Join(kvPairs, " "))
if err := vm.runVaultCommandQuiet(vaultCmd); err != nil {
return fmt.Errorf("failed to store secret in Vault: %w", err)
}
fmt.Printf("[SUCCESS] Secret at '%s' stored successfully in Vault!\n", path)
return nil
}
// CreateDockerConfig creates a Docker config JSON string.
func CreateDockerConfig(registry, username, token string) (string, error) {
auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + token))
config := DockerConfig{
Auths: map[string]DockerAuth{
registry: {
Username: username,
Password: token,
Auth: auth,
},
},
}
configJSON, err := json.Marshal(config)
if err != nil {
return "", fmt.Errorf("failed to marshal docker config: %w", err)
}
return string(configJSON), nil
}

107
main.go
View file

@ -1,112 +1,9 @@
package main package main
import ( import (
_ "embed" "github.com/Pingu-Studio/MaidnCLI/cmd"
"fmt"
"os"
"github.com/spf13/cobra"
) )
var orgName, manifestsRepoName, fluxRepoName string
//go:embed templates/manifests.md.tmpl
var manifestsReadmeTmpl string
//go:embed templates/flux.md.tmpl
var fluxReadmeTmpl string
func main() { func main() {
var rootCmd = &cobra.Command{ cmd.Execute()
Use: "cicd-tool",
Short: "A CLI tool to manage CI/CD setup for applications.",
}
var repoCmd = &cobra.Command{
Use: "repo",
Short: "Manage CI/CD repositories.",
}
var vaultCmd = &cobra.Command{
Use: "vault",
Short: "Manage Vault secrets for CI/CD.",
}
var initCmd = &cobra.Command{
Use: "init",
Short: "Initializes the manifests and flux repositories with the required structure.",
Run: runInitRepo,
}
var createDockerSecretCmd = &cobra.Command{
Use: "create-docker-secret",
Short: "Creates a Docker registry secret in Vault for GHCR authentication.",
Run: runCreateDockerSecret,
}
var createGithubSecretCmd = &cobra.Command{
Use: "create-github-secret",
Short: "Creates or updates a GitHub authentication secret in Vault.",
Run: runCreateGithubSecret,
}
// Define command flags for repo init
initCmd.Flags().StringVar(&orgName, "org", "", "The GitHub organization (e.g., Free-Maidn)")
initCmd.Flags().StringVar(&manifestsRepoName, "manifests-repo", "cicd-deployment-manifests", "The name of the manifests repository")
initCmd.Flags().StringVar(&fluxRepoName, "flux-repo", "", "The name of the flux repository")
initCmd.MarkFlagRequired("org")
initCmd.MarkFlagRequired("flux-repo")
// Add commands to their parent commands
repoCmd.AddCommand(initCmd)
vaultCmd.AddCommand(createDockerSecretCmd)
vaultCmd.AddCommand(createGithubSecretCmd)
rootCmd.AddCommand(repoCmd)
rootCmd.AddCommand(vaultCmd)
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func runInitRepo(cmd *cobra.Command, args []string) {
// Check system dependencies
fmt.Println("[INFO] Checking dependencies (git and gh)...")
if !commandExists("git") || !commandExists("gh") {
fmt.Println("[ERROR] 'git' and 'gh' must be installed and in your PATH.")
os.Exit(1)
}
// Verify GitHub authentication
fmt.Println("[INFO] Checking GitHub authentication...")
if err := runCommandQuiet("gh", "auth", "status"); err != nil {
fmt.Println("[ERROR] You are not logged into the GitHub CLI. Please run 'gh auth login'.")
os.Exit(1)
}
fmt.Println("[SUCCESS] Dependencies and authentication are OK.")
// Initialize both repositories
initializeManifestsRepo()
initializeFluxRepo()
fmt.Println("\n🎉 Onboarding complete! 🎉")
fmt.Printf("✅ Manifests repository: https://github.com/%s/%s\n", orgName, manifestsRepoName)
fmt.Printf("✅ Flux repository: https://github.com/%s/%s\n", orgName, fluxRepoName)
}
func runCreateDockerSecret(cmd *cobra.Command, args []string) {
if err := createAndStoreSecrets(); err != nil {
fmt.Printf("[ERROR] Failed to create Docker registry secret: %v\n", err)
os.Exit(1)
}
fmt.Println("[SUCCESS] Docker registry secret created successfully in Vault!")
}
func runCreateGithubSecret(cmd *cobra.Command, args []string) {
if err := createAndStoreGithubAuthSecret(); err != nil {
fmt.Printf("[ERROR] Failed to create GitHub auth secret: %v\n", err)
os.Exit(1)
}
fmt.Println("[SUCCESS] GitHub auth secret created successfully in Vault!")
} }

View file

@ -1,255 +0,0 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
const fluxConfigTmpl = `---
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
name: ghcr-charts
namespace: flux-system
spec:
interval: 5m
type: oci
url: oci://ghcr.io/%s
secretRef:
name: ghcr-auth
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: cicd-deployment-manifests
namespace: flux-system
spec:
interval: 1m0s
url: https://github.com/%s/%s
ref:
branch: main
secretRef:
name: github-auth
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: namespaces
namespace: flux-system
spec:
interval: 10m
path: ./namespaces
prune: true
sourceRef:
kind: GitRepository
name: cicd-deployment-manifests
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: preview-apps
namespace: flux-system
spec:
dependsOn:
- name: namespaces
interval: 2m0s
path: ./apps/previews
prune: true
sourceRef:
kind: GitRepository
name: cicd-deployment-manifests
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: staging-apps
namespace: flux-system
spec:
dependsOn:
- name: namespaces
interval: 2m0s
path: ./apps/staging
prune: true
sourceRef:
kind: GitRepository
name: cicd-deployment-manifests
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: production-apps
namespace: flux-system
spec:
dependsOn:
- name: namespaces
interval: 2m0s
path: ./apps/production
prune: true
sourceRef:
kind: GitRepository
name: cicd-deployment-manifests
`
func initializeManifestsRepo() {
fullRepoName := fmt.Sprintf("%s/%s", orgName, manifestsRepoName)
fmt.Printf("\n[INFO] Setting up manifests repository '%s'...\n", fullRepoName)
// Check if repository exists, create if not
if err := runCommandQuiet("gh", "repo", "view", fullRepoName); err != nil {
fmt.Printf("[INFO] Creating repository '%s'...\n", fullRepoName)
if err := runCommand("gh", "repo", "create", fullRepoName, "--private", "--description", "Centralized deployment manifests for Flux CD"); err != nil {
fmt.Printf("[ERROR] Failed to create repository: %v\n", err)
os.Exit(1)
}
fmt.Println("[SUCCESS] Manifests repository created.")
} else {
fmt.Println("[SUCCESS] Manifests repository already exists.")
}
// Clone and setup repository structure
setupRepository(fullRepoName, createManifestsStructure)
}
func initializeFluxRepo() {
fullRepoName := fmt.Sprintf("%s/%s", orgName, fluxRepoName)
fmt.Printf("\n[INFO] Setting up flux repository '%s'...\n", fullRepoName)
// Check if repository exists, create if not
if err := runCommandQuiet("gh", "repo", "view", fullRepoName); err != nil {
fmt.Printf("[INFO] Creating repository '%s'...\n", fullRepoName)
if err := runCommand("gh", "repo", "create", fullRepoName, "--private", "--description", "Flux CD cluster configurations"); err != nil {
fmt.Printf("[ERROR] Failed to create repository: %v\n", err)
os.Exit(1)
}
fmt.Println("[SUCCESS] Flux repository created.")
} else {
fmt.Println("[SUCCESS] Flux repository already exists.")
}
// Clone and setup repository structure
setupRepository(fullRepoName, createFluxStructure)
}
func setupRepository(fullRepoName string, structureFunc func(string)) {
// Create temporary directory for cloning
tempDir, err := os.MkdirTemp("", "repo-setup-*")
if err != nil {
fmt.Printf("[ERROR] Failed to create temporary directory: %v\n", err)
os.Exit(1)
}
defer os.RemoveAll(tempDir)
// Clone repository
fmt.Printf("[INFO] Cloning repository into temporary directory...\n")
repoURL := fmt.Sprintf("https://github.com/%s.git", fullRepoName)
if err := runCommand("git", "clone", repoURL, tempDir); err != nil {
fmt.Printf("[ERROR] Failed to clone repository: %v\n", err)
os.Exit(1)
}
// Create repository structure
fmt.Println("[INFO] Creating repository structure...")
structureFunc(tempDir)
// Commit and push changes if any exist
fmt.Println("[INFO] Committing and pushing changes...")
commitAndPush(tempDir, fullRepoName)
}
func createManifestsStructure(baseDir string) {
// Create README for manifests repository
manifestsReadme := fmt.Sprintf(manifestsReadmeTmpl, manifestsRepoName)
writeFile(filepath.Join(baseDir, "README.md"), manifestsReadme)
// Create namespaces directory and files
namespacesDir := filepath.Join(baseDir, "namespaces")
if err := os.MkdirAll(namespacesDir, 0755); err != nil {
fmt.Printf("[ERROR] Failed to create namespaces directory: %v\n", err)
os.Exit(1)
}
// Create staging namespace
stagingNamespace := `apiVersion: v1
kind: Namespace
metadata:
name: staging
`
writeFile(filepath.Join(namespacesDir, "staging.yaml"), stagingNamespace)
// Create production namespace
productionNamespace := `apiVersion: v1
kind: Namespace
metadata:
name: production
`
writeFile(filepath.Join(namespacesDir, "production.yaml"), productionNamespace)
// Create namespaces kustomization
namespacesKustomization := `apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- staging.yaml
- production.yaml
`
writeFile(filepath.Join(namespacesDir, "kustomization.yaml"), namespacesKustomization)
// Create apps directory structure with .gitkeep files
environments := []string{"staging", "production", "previews"}
for _, env := range environments {
dirPath := filepath.Join(baseDir, "apps", env)
if err := os.MkdirAll(dirPath, 0755); err != nil {
fmt.Printf("[ERROR] Failed to create directory %s: %v\n", dirPath, err)
os.Exit(1)
}
// Create .gitkeep to ensure empty directories are tracked
writeFile(filepath.Join(dirPath, ".gitkeep"), "")
}
}
func createFluxStructure(baseDir string) {
// Create README for flux repository
fluxReadme := fmt.Sprintf(fluxReadmeTmpl, fluxRepoName)
writeFile(filepath.Join(baseDir, "README.md"), fluxReadme)
// Create clusters/dev-cluster directory
clusterDir := filepath.Join(baseDir, "clusters", "dev-cluster")
if err := os.MkdirAll(clusterDir, 0755); err != nil {
fmt.Printf("[ERROR] Failed to create cluster directory: %v\n", err)
os.Exit(1)
}
// Create the flux configuration file with dynamic organization name
fluxConfig := fmt.Sprintf(fluxConfigTmpl, strings.ToLower(orgName), orgName, manifestsRepoName)
writeFile(filepath.Join(clusterDir, "cicd-manifests-repo.yaml"), fluxConfig)
}
func commitAndPush(tempDir, repoName string) {
// Configure git user for the commit
runCommandInDir(tempDir, "git", "config", "user.name", "Maidn")
runCommandInDir(tempDir, "git", "config", "user.email", "maidn@free-maidn.com")
runCommandInDir(tempDir, "git", "add", ".")
// Check if there are changes to commit
statusCmd := exec.Command("git", "status", "--porcelain")
statusCmd.Dir = tempDir
output, _ := statusCmd.Output()
if len(output) == 0 {
fmt.Println("[INFO] No changes to commit. Repository is already up to date.")
} else {
// Commit and push changes
commitMsg := "feat: Initialize repository structure for CI/CD"
if err := runCommandInDir(tempDir, "git", "commit", "-m", commitMsg); err != nil {
fmt.Printf("[ERROR] Failed to commit changes: %v\n", err)
os.Exit(1)
}
if err := runCommandInDir(tempDir, "git", "push", "origin", "main"); err != nil {
fmt.Printf("[ERROR] Failed to push changes: %v\n", err)
os.Exit(1)
}
fmt.Printf("[SUCCESS] Changes pushed to %s successfully.\n", repoName)
}
}

View file

@ -1,46 +0,0 @@
package main
import (
"fmt"
"os"
"os/exec"
)
// Helper function to write content to a file
func writeFile(path, content string) {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
fmt.Printf("[ERROR] Failed to write file %s: %v\n", path, err)
os.Exit(1)
}
}
// Helper function to check if a command exists in PATH
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
// Helper function to run a command with output visible
func runCommand(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// Helper function to run a command quietly (no stdout)
func runCommandQuiet(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = nil
cmd.Stderr = os.Stderr
return cmd.Run()
}
// Helper function to run a command in a specific directory
func runCommandInDir(dir string, name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

323
vault.go
View file

@ -1,323 +0,0 @@
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"golang.org/x/term"
)
// --- Constants for Vault and Kubernetes ---
const (
vaultNamespace = "vault"
vaultPodLabelSelector = "app.kubernetes.io/name=vault"
vaultRootTokenSecretName = "vault-unseal-keys"
vaultRootTokenSecretKey = "vault-root"
ghcrSecretPath = "secret/ghcr-auth"
githubAuthSecretPath = "secret/github-auth"
orgManagementSecretPath = "secret/org-management-pat"
dockerConfigJSONKey = ".dockerconfigjson"
orgManagementPATKey = "ORG_MANAGEMENT_PAT"
ghcrRegistry = "ghcr.io"
defaultVaultAddress = "https://127.0.0.1:8200"
)
// --- Structs for Docker Config ---
// DockerConfig represents the structure of a Docker config.json
type DockerConfig struct {
Auths map[string]DockerAuth `json:"auths"`
}
// DockerAuth represents the authentication info for a Docker registry
type DockerAuth struct {
Username string `json:"username"`
Password string `json:"password"`
Auth string `json:"auth"`
}
// --- VaultManager to Encapsulate Logic ---
// VaultManager handles all interactions with Kubernetes and Vault.
type VaultManager struct {
podName string
rootToken string
}
// NewVaultManager creates and initializes a new VaultManager.
func NewVaultManager() (*VaultManager, error) {
vm := &VaultManager{}
fmt.Println("[INFO] Checking dependencies (kubectl)...")
if !commandExists("kubectl") {
return nil, fmt.Errorf("'kubectl' must be installed and in your PATH")
}
fmt.Println("[INFO] Finding Vault pod...")
podName, err := vm.getVaultPodName()
if err != nil {
return nil, err
}
vm.podName = podName
fmt.Printf("[INFO] Using Vault pod: %s\n", vm.podName)
fmt.Println("[INFO] Retrieving Vault root token...")
rootToken, err := vm.getVaultRootToken()
if err != nil {
return nil, err
}
vm.rootToken = rootToken
fmt.Println("[SUCCESS] Found Vault root token.")
fmt.Println("[INFO] Verifying Vault connection and token...")
if err := vm.verifyVaultToken(); err != nil {
return nil, fmt.Errorf("failed to validate Vault token: %w", err)
}
fmt.Println("[SUCCESS] Vault token is valid.")
return vm, nil
}
// getVaultPodName finds a running Vault pod in the cluster.
func (vm *VaultManager) getVaultPodName() (string, error) {
// Use `runCommandQuiet` to check for the existence of common pod names.
commonNames := []string{"vault-0", "vault-1", "vault-2"}
for _, name := range commonNames {
if err := runCommandQuiet("kubectl", "get", "pod", name, "-n", vaultNamespace); err == nil {
return name, nil
}
}
// For getting the name via label, we MUST capture the command's output,
// so we use exec.Command().Output() directly.
cmd := exec.Command("kubectl", "get", "pods", "-n", vaultNamespace, "-l", vaultPodLabelSelector, "-o", "jsonpath={.items[0].metadata.name}")
output, err := cmd.Output()
if err == nil && len(output) > 0 {
return string(output), nil
}
return "", fmt.Errorf("could not find any running Vault pods in the '%s' namespace", vaultNamespace)
}
// getVaultRootToken retrieves the Vault root token from the Kubernetes secret.
func (vm *VaultManager) getVaultRootToken() (string, error) {
// We need to capture the secret data from stdout, so we must use exec.Command().Output().
// The utility functions in utils.go do not support this.
cmd := exec.Command("kubectl", "get", "secret", vaultRootTokenSecretName, "-n", vaultNamespace, "-o", fmt.Sprintf("jsonpath={.data.%s}", vaultRootTokenSecretKey))
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get '%s' secret from '%s' namespace: %w", vaultRootTokenSecretName, vaultNamespace, err)
}
if len(output) == 0 {
return "", fmt.Errorf("key '%s' not found in secret '%s'", vaultRootTokenSecretKey, vaultRootTokenSecretName)
}
decoded, err := base64.StdEncoding.DecodeString(string(output))
if err != nil {
return "", fmt.Errorf("failed to decode base64 root token: %w", err)
}
return strings.TrimSpace(string(decoded)), nil
}
// runVaultCommandQuiet executes a command inside the Vault pod without printing its output.
func (vm *VaultManager) runVaultCommandQuiet(command string) error {
envSetup := fmt.Sprintf("export VAULT_SKIP_VERIFY=true && export VAULT_ADDR='%s' && export VAULT_TOKEN='%s'", defaultVaultAddress, vm.rootToken)
fullCmd := fmt.Sprintf("%s && %s", envSetup, command)
// Use the runCommandQuiet utility to suppress command stdout.
return runCommandQuiet("kubectl", "exec", "-n", vaultNamespace, vm.podName, "--", "sh", "-c", fullCmd)
}
// verifyVaultToken checks if the root token is valid by running a quiet command.
func (vm *VaultManager) verifyVaultToken() error {
return vm.runVaultCommandQuiet("vault token lookup")
}
// StoreSecret stores a key-value secret at the specified path in Vault quietly.
func (vm *VaultManager) StoreSecret(path string, data map[string]string) error {
fmt.Printf("[INFO] Storing secret in Vault at path: %s...\n", path)
var kvPairs []string
for key, value := range data {
// Escape single quotes for shell command robustness.
escapedValue := strings.ReplaceAll(value, "'", "'\"'\"'")
kvPairs = append(kvPairs, fmt.Sprintf("%s='%s'", key, escapedValue))
}
vaultCmd := fmt.Sprintf("vault kv put %s %s", path, strings.Join(kvPairs, " "))
if err := vm.runVaultCommandQuiet(vaultCmd); err != nil {
return fmt.Errorf("failed to store secret in Vault: %w", err)
}
fmt.Printf("[SUCCESS] Secret at '%s' stored successfully in Vault!\n", path)
return nil
}
// --- Main Workflow Function ---
func createAndStoreSecrets() error {
// 1. Get User Input
username, registryToken, orgToken, err := getUserInput()
if err != nil {
return err
}
// 2. Initialize Vault Manager
// This handles kubectl checks, pod discovery, and token retrieval.
vm, err := NewVaultManager()
if err != nil {
return fmt.Errorf("failed to initialize Vault manager: %w", err)
}
// 3. Create and Store Docker Registry Secret
dockerConfigJSON, err := createDockerConfig(username, registryToken)
if err != nil {
return fmt.Errorf("failed to create Docker config: %w", err)
}
dockerSecretData := map[string]string{dockerConfigJSONKey: dockerConfigJSON}
if err := vm.StoreSecret(ghcrSecretPath, dockerSecretData); err != nil {
return fmt.Errorf("failed to store Docker registry secret: %w", err)
}
// 4. Create and Store Organization Management PAT
orgSecretData := map[string]string{orgManagementPATKey: orgToken}
if err := vm.StoreSecret(orgManagementSecretPath, orgSecretData); err != nil {
return fmt.Errorf("failed to store organization management PAT: %w", err)
}
return nil
}
func createAndStoreGithubAuthSecret() error {
username, token, err := getUserInputForGithubAuth()
if err != nil {
return err
}
vm, err := NewVaultManager()
if err != nil {
return fmt.Errorf("failed to initialize Vault manager: %w", err)
}
secretData := map[string]string{
"username": username,
"password": token,
}
if err := vm.StoreSecret(githubAuthSecretPath, secretData); err != nil {
return fmt.Errorf("failed to store github-auth secret: %w", err)
}
return nil
}
// --- User Input and Helper Functions ---
func getUserInput() (username, registryToken, orgToken string, err error) {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter your GitHub username: ")
username, err = reader.ReadString('\n')
if err != nil {
return
}
username = strings.TrimSpace(username)
if username == "" {
err = fmt.Errorf("username cannot be empty")
return
}
registryToken, err = promptForToken(reader, "Enter your GitHub personal access token for container registry")
if err != nil {
return
}
orgToken, err = promptForToken(reader, "Enter your GitHub personal access token for organization management")
if err != nil {
return
}
return
}
func getUserInputForGithubAuth() (username, token string, err error) {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter your GitHub username: ")
username, err = reader.ReadString('\n')
if err != nil {
return
}
username = strings.TrimSpace(username)
if username == "" {
err = fmt.Errorf("username cannot be empty")
return
}
token, err = promptForToken(reader, "Enter your GitHub personal access token for repository access")
if err != nil {
return
}
return
}
// promptForToken provides a generic way to ask for a token with validation.
func promptForToken(reader *bufio.Reader, prompt string) (string, error) {
fmt.Printf("%s: ", prompt)
tokenBytes, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return "", err
}
token := string(tokenBytes)
if token == "" {
return "", fmt.Errorf("token cannot be empty")
}
validPrefixes := []string{"ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_"}
hasValidPrefix := false
for _, prefix := range validPrefixes {
if strings.HasPrefix(token, prefix) {
hasValidPrefix = true
break
}
}
if !hasValidPrefix {
fmt.Printf("[WARNING] The token does not appear to be a valid GitHub personal access token (should start with one of %v)\n", validPrefixes)
fmt.Print("Do you want to continue anyway? (y/N): ")
confirm, _ := reader.ReadString('\n')
if strings.TrimSpace(strings.ToLower(confirm)) != "y" {
return "", fmt.Errorf("operation cancelled by user")
}
}
return token, nil
}
// createDockerConfig creates a Docker config JSON string.
func createDockerConfig(username, token string) (string, error) {
auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + token))
config := DockerConfig{
Auths: map[string]DockerAuth{
ghcrRegistry: {
Username: username,
Password: token,
Auth: auth,
},
},
}
configJSON, err := json.Marshal(config)
if err != nil {
return "", fmt.Errorf("failed to marshal docker config: %w", err)
}
return string(configJSON), nil
}