Compare commits
10 commits
972646295a
...
b6dce951ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6dce951ed | ||
|
|
85e87f1334 | ||
|
|
cc8fab6832 | ||
|
|
caee4f8a01 | ||
|
|
85e0a700cb | ||
|
|
87e9c98bf1 | ||
|
|
10a79d6870 | ||
|
|
4fe103eeb7 | ||
|
|
7668d0d180 | ||
|
|
b2b43a98fd |
13
.vscode/launch.json
vendored
13
.vscode/launch.json
vendored
|
|
@ -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
2
How2Auth.md
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
edingrech
|
||||||
|
dckr_pat_bfKKDH4g3qUxchs9UMLxFx2oTiU
|
||||||
1
MaidnCLI
Submodule
1
MaidnCLI
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit cc8fab6832a3a51bd072155f90f91354e440077a
|
||||||
100
README.md
100
README.md
|
|
@ -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
53
cmd/bootstrap.go
Normal 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
59
cmd/repo.go
Normal 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
21
cmd/root.go
Normal 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
186
cmd/vault.go
Normal 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
22
docs/maintenance.md
Normal 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
11
go.mod
|
|
@ -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
14
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
9
internal/assets/assets.go
Normal file
9
internal/assets/assets.go
Normal 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
|
||||||
150
internal/bootstrap/bootstrap.go
Normal file
150
internal/bootstrap/bootstrap.go
Normal 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
115
internal/config/config.go
Normal 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
98
internal/config/types.go
Normal 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
134
internal/forgejo/repo.go
Normal 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)
|
||||||
|
}
|
||||||
158
internal/github/repository.go
Normal file
158
internal/github/repository.go
Normal 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")
|
||||||
|
}
|
||||||
185
internal/k8s/external_secret.go
Normal file
185
internal/k8s/external_secret.go
Normal 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
165
internal/proxmox/client.go
Normal 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()
|
||||||
|
}
|
||||||
38
internal/talos/releases.go
Normal file
38
internal/talos/releases.go
Normal 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
120
internal/ui/prompts.go
Normal 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
229
internal/ui/wizard.go
Normal 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
111
internal/utils/helpers.go
Normal 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
167
internal/vault/client.go
Normal 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
107
main.go
|
|
@ -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!")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
255
repository.go
255
repository.go
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
46
utils.go
46
utils.go
|
|
@ -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
323
vault.go
|
|
@ -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
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue