From 7668d0d18056c2ee3f4f26de8f5268f613de49af Mon Sep 17 00:00:00 2001 From: eding Date: Sun, 26 Oct 2025 00:41:59 +0200 Subject: [PATCH] feat: restructure & secret setup --- cmd/repo.go | 57 +++ cmd/root.go | 21 + cmd/vault.go | 137 +++++++ go.mod | 11 +- go.sum | 14 + internal/assets/assets.go | 9 + .../assets/templates}/flux.md.tmpl | 0 .../assets/templates}/manifests.md.tmpl | 0 .../github/repository.go | 117 +++--- internal/k8s/external_secret.go | 131 ++++++ internal/ui/prompts.go | 120 ++++++ internal/utils/helpers.go | 99 +++++ internal/vault/client.go | 167 ++++++++ main.go | 120 +----- utils.go | 46 --- vault.go | 376 ------------------ 16 files changed, 817 insertions(+), 608 deletions(-) create mode 100644 cmd/repo.go create mode 100644 cmd/root.go create mode 100644 cmd/vault.go create mode 100644 internal/assets/assets.go rename {templates => internal/assets/templates}/flux.md.tmpl (100%) rename {templates => internal/assets/templates}/manifests.md.tmpl (100%) rename repository.go => internal/github/repository.go (55%) create mode 100644 internal/k8s/external_secret.go create mode 100644 internal/ui/prompts.go create mode 100644 internal/utils/helpers.go create mode 100644 internal/vault/client.go delete mode 100644 utils.go delete mode 100644 vault.go diff --git a/cmd/repo.go b/cmd/repo.go new file mode 100644 index 0000000..b01a3a7 --- /dev/null +++ b/cmd/repo.go @@ -0,0 +1,57 @@ +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) + repoManager.InitializeManifestsRepo() + repoManager.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) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..6bb6c1f --- /dev/null +++ b/cmd/root.go @@ -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) + } +} diff --git a/cmd/vault.go b/cmd/vault.go new file mode 100644 index 0000000..07918db --- /dev/null +++ b/cmd/vault.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "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.", + 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) + } + + if err := k8s.CreateDockerHubExternalSecret(); err != nil { + // Call our new universal handler for the specific cache error. + utils.HandleKubectlCacheError(err) + + // Provide a generic warning for all other potential errors. + if !strings.Contains(err.Error(), "no matches for kind") { + fmt.Printf("[WARNING] The secret was stored in Vault, but failed to create the ExternalSecret: %v\n", err) + } + os.Exit(1) // Exit after handling the error to prompt the user to re-run. + } +} + +func runCreateGhcrSecret(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, 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) + } +} + +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) + } +} diff --git a/go.mod b/go.mod index 04f17b1..c25db25 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,15 @@ module github.com/Pingu-Studio/MaidnCLI -go 1.25.0 +go 1.24.0 require ( - github.com/spf13/cobra v1.9.1 - golang.org/x/term v0.34.0 + github.com/spf13/cobra v1.10.1 + golang.org/x/term v0.36.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.7 // indirect - golang.org/x/sys v0.35.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index eecca3b..2a04af0 100644 --- a/go.sum +++ b/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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 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/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/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.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= 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/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/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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/assets/assets.go b/internal/assets/assets.go new file mode 100644 index 0000000..743e232 --- /dev/null +++ b/internal/assets/assets.go @@ -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 diff --git a/templates/flux.md.tmpl b/internal/assets/templates/flux.md.tmpl similarity index 100% rename from templates/flux.md.tmpl rename to internal/assets/templates/flux.md.tmpl diff --git a/templates/manifests.md.tmpl b/internal/assets/templates/manifests.md.tmpl similarity index 100% rename from templates/manifests.md.tmpl rename to internal/assets/templates/manifests.md.tmpl diff --git a/repository.go b/internal/github/repository.go similarity index 55% rename from repository.go rename to internal/github/repository.go index c2140cf..9037ce1 100644 --- a/repository.go +++ b/internal/github/repository.go @@ -1,4 +1,4 @@ -package main +package github import ( "fmt" @@ -6,6 +6,9 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/Pingu-Studio/MaidnCLI/internal/assets" + "github.com/Pingu-Studio/MaidnCLI/internal/utils" ) const fluxConfigTmpl = `--- @@ -93,14 +96,30 @@ spec: name: cicd-deployment-manifests ` -func initializeManifestsRepo() { - fullRepoName := fmt.Sprintf("%s/%s", orgName, manifestsRepoName) +// RepoManager holds the state for repository operations +type RepoManager struct { + OrgName string + ManifestsRepoName string + FluxRepoName string +} + +// NewRepoManager creates a new instance of RepoManager +func NewRepoManager(org, manifestsRepo, fluxRepo string) *RepoManager { + return &RepoManager{ + OrgName: org, + ManifestsRepoName: manifestsRepo, + FluxRepoName: fluxRepo, + } +} + +// InitializeManifestsRepo sets up the manifests repository +func (rm *RepoManager) InitializeManifestsRepo() { + fullRepoName := fmt.Sprintf("%s/%s", rm.OrgName, rm.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 { + if err := utils.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 { + if err := utils.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) } @@ -109,18 +128,17 @@ func initializeManifestsRepo() { fmt.Println("[SUCCESS] Manifests repository already exists.") } - // Clone and setup repository structure - setupRepository(fullRepoName, createManifestsStructure) + rm.setupRepository(fullRepoName, rm.createManifestsStructure) } -func initializeFluxRepo() { - fullRepoName := fmt.Sprintf("%s/%s", orgName, fluxRepoName) +// InitializeFluxRepo sets up the flux repository +func (rm *RepoManager) InitializeFluxRepo() { + fullRepoName := fmt.Sprintf("%s/%s", rm.OrgName, rm.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 { + if err := utils.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 { + if err := utils.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) } @@ -129,12 +147,10 @@ func initializeFluxRepo() { fmt.Println("[SUCCESS] Flux repository already exists.") } - // Clone and setup repository structure - setupRepository(fullRepoName, createFluxStructure) + rm.setupRepository(fullRepoName, rm.createFluxStructure) } -func setupRepository(fullRepoName string, structureFunc func(string)) { - // Create temporary directory for cloning +func (rm *RepoManager) setupRepository(fullRepoName string, structureFunc func(string)) { tempDir, err := os.MkdirTemp("", "repo-setup-*") if err != nil { fmt.Printf("[ERROR] Failed to create temporary directory: %v\n", err) @@ -142,97 +158,73 @@ func setupRepository(fullRepoName string, structureFunc func(string)) { } 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 { + if err := utils.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) +func (rm *RepoManager) createManifestsStructure(baseDir string) { + manifestsReadme := fmt.Sprintf(assets.ManifestsReadmeTmpl, rm.ManifestsRepoName) + utils.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) - } + os.MkdirAll(namespacesDir, 0755) - // Create staging namespace stagingNamespace := `apiVersion: v1 kind: Namespace metadata: name: staging ` - writeFile(filepath.Join(namespacesDir, "staging.yaml"), stagingNamespace) + utils.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) + utils.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) + utils.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"), "") + os.MkdirAll(dirPath, 0755) + utils.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) +func (rm *RepoManager) createFluxStructure(baseDir string) { + fluxReadme := fmt.Sprintf(assets.FluxReadmeTmpl, rm.FluxRepoName) + utils.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) - } + os.MkdirAll(clusterDir, 0755) - // 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) + fluxConfig := fmt.Sprintf(fluxConfigTmpl, strings.ToLower(rm.OrgName), rm.OrgName, rm.ManifestsRepoName) + utils.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", ".") + utils.RunCommandInDir(tempDir, "git", "config", "user.name", "Maidn") + utils.RunCommandInDir(tempDir, "git", "config", "user.email", "maidn@free-maidn.com") + utils.RunCommandInDir(tempDir, "git", "add", ".") - // Check if there are changes to commit statusCmd := exec.Command("git", "status", "--porcelain") statusCmd.Dir = tempDir output, _ := statusCmd.Output() @@ -240,13 +232,12 @@ func commitAndPush(tempDir, repoName string) { 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 { + if err := utils.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 { + if err := utils.RunCommandInDir(tempDir, "git", "push", "origin", "main"); err != nil { fmt.Printf("[ERROR] Failed to push changes: %v\n", err) os.Exit(1) } diff --git a/internal/k8s/external_secret.go b/internal/k8s/external_secret.go new file mode 100644 index 0000000..4f57175 --- /dev/null +++ b/internal/k8s/external_secret.go @@ -0,0 +1,131 @@ +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 +} + +// 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/v1beta1", + 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 +} diff --git a/internal/ui/prompts.go b/internal/ui/prompts.go new file mode 100644 index 0000000..8e27e3b --- /dev/null +++ b/internal/ui/prompts.go @@ -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 +} diff --git a/internal/utils/helpers.go b/internal/utils/helpers.go new file mode 100644 index 0000000..d1171e9 --- /dev/null +++ b/internal/utils/helpers.go @@ -0,0 +1,99 @@ +package utils + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "runtime" + "strings" +) + +// WriteFile writes content to a file, exiting on error. +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) + } +} + +// CommandExists checks if a command exists in the system's PATH. +func CommandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +// RunCommand runs a command with its output visible to the user. +func RunCommand(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// RunCommandQuiet runs a command without printing its stdout. +func RunCommandQuiet(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// RunCommandInDir runs 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() +} + +// HandleKubectlCacheError detects a specific kubectl cache error and offers an interactive fix. +func HandleKubectlCacheError(err error) { + // Guard clause: Only proceed if the error is the one we're looking for. + if err == nil || !strings.Contains(err.Error(), "no matches for kind") { + return + } + + 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 + } + + var cmd *exec.Cmd + var shell string + + // Determine the correct command based on the operating system. + if runtime.GOOS == "windows" { + shell = "PowerShell" + // Using -NoProfile and -NonInteractive for cleaner execution + cmd = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", "Remove-Item -Path $env:USERPROFILE\\.kube\\cache -Recurse -Force") + } else { + shell = "shell" + // The user's home directory can be fetched reliably. + homeDir, err := os.UserHomeDir() + if err != nil { + fmt.Printf("[ERROR] Could not determine home directory to clear cache: %v\n", err) + return + } + 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) + } else { + fmt.Println("[SUCCESS] Kubectl cache cleared successfully.") + fmt.Println("[INFO] Please try running the previous command again.") + } +} diff --git a/internal/vault/client.go b/internal/vault/client.go new file mode 100644 index 0000000..9bf9d4f --- /dev/null +++ b/internal/vault/client.go @@ -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 = "docker.io" +) + +// 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 +} diff --git a/main.go b/main.go index ccc4c69..bc9d912 100644 --- a/main.go +++ b/main.go @@ -1,125 +1,9 @@ package main import ( - _ "embed" - "fmt" - "os" - - "github.com/spf13/cobra" + "github.com/Pingu-Studio/MaidnCLI/cmd" ) -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() { - var rootCmd = &cobra.Command{ - 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 Docker Hub.", - Run: runCreateDockerSecret, - } - - var createGhcrSecretCmd = &cobra.Command{ - Use: "create-ghcr-secret", - Short: "Creates a Docker registry secret in Vault for GHCR.", - Run: runCreateGhcrSecret, - } - - 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(createGhcrSecretCmd) - 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 := createDockerHubSecretWorkflow(); err != nil { - fmt.Printf("[ERROR] Failed to create Docker Hub secret: %v\n", err) - os.Exit(1) - } -} - -func runCreateGhcrSecret(cmd *cobra.Command, args []string) { - if err := createGhcrSecretWorkflow(); err != nil { - fmt.Printf("[ERROR] Failed to create GHCR secret: %v\n", err) - os.Exit(1) - } -} - -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!") + cmd.Execute() } diff --git a/utils.go b/utils.go deleted file mode 100644 index 41e4184..0000000 --- a/utils.go +++ /dev/null @@ -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() -} diff --git a/vault.go b/vault.go deleted file mode 100644 index 1142a53..0000000 --- a/vault.go +++ /dev/null @@ -1,376 +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" - dockerHubSecretPath = "secret/dockerhub-auth" - githubAuthSecretPath = "secret/github-auth" - orgManagementSecretPath = "secret/org-management-pat" - dockerConfigJSONKey = ".dockerconfigjson" - orgManagementPATKey = "ORG_MANAGEMENT_PAT" - ghcrRegistry = "ghcr.io" - dockerHubRegistry = "docker.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 Functions --- -func createDockerHubSecretWorkflow() error { - vm, err := NewVaultManager() - if err != nil { - return fmt.Errorf("failed to initialize Vault manager: %w", err) - } - - return createDockerHubSecret(vm) -} - -func createGhcrSecretWorkflow() error { - vm, err := NewVaultManager() - if err != nil { - return fmt.Errorf("failed to initialize Vault manager: %w", err) - } - - return createGhcrSecrets(vm) -} - -func createGhcrSecrets(vm *VaultManager) error { - fmt.Println("\n[INFO] Configuring secret for GitHub Container Registry (GHCR)...") - username, registryToken, orgToken, err := getGHCRUserInput() - if err != nil { - return err - } - - dockerConfigJSON, err := createDockerConfig(ghcrRegistry, 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) - } - - 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 createDockerHubSecret(vm *VaultManager) error { - fmt.Println("\n[INFO] Configuring secret for Docker Hub...") - username, password, err := getUserInputForDockerHub() - if err != nil { - return err - } - - dockerConfigJSON, err := createDockerConfig(dockerHubRegistry, username, password) - if err != nil { - return fmt.Errorf("failed to create Docker config: %w", err) - } - dockerSecretData := map[string]string{dockerConfigJSONKey: dockerConfigJSON} - - if err := vm.StoreSecret(dockerHubSecretPath, dockerSecretData); err != nil { - return fmt.Errorf("failed to store Docker Hub secret: %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 getGHCRUserInput() (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 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 -} - -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(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 -}