253 lines
7.0 KiB
Go
253 lines
7.0 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"kforge/internal/config"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// ------------------------------------------------------------
|
|
// validate
|
|
// ------------------------------------------------------------
|
|
|
|
var validateCmd = &cobra.Command{
|
|
Use: "validate",
|
|
Short: "Validate kforge.yml and check required secrets",
|
|
Long: `Parses kforge.yml and checks for:
|
|
- Structural errors (missing required fields, invalid types)
|
|
- Required Gitea/CI secrets (checks current process environment)
|
|
- Interpolation tokens that can't be resolved
|
|
|
|
Exits 0 if valid, non-zero if any issue is found.`,
|
|
RunE: runValidate,
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(validateCmd)
|
|
}
|
|
|
|
func runValidate(cmd *cobra.Command, args []string) error {
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("✓ kforge.yml is valid\n")
|
|
fmt.Printf(" app: %s/%s\n", cfg.Meta.Tenant, cfg.Meta.Name)
|
|
fmt.Printf(" envs: %s\n", strings.Join(config.EnvironmentKeys(cfg), ", "))
|
|
|
|
// Check required secrets.
|
|
missing := checkRequiredSecrets(cfg)
|
|
if len(missing) > 0 {
|
|
fmt.Println("\n⚠ Missing required secrets (not found in environment):")
|
|
for _, s := range missing {
|
|
fmt.Printf(" ✗ %s (%s)\n", s.Name, s.Location)
|
|
}
|
|
return fmt.Errorf("%d required secret(s) not set", len(missing))
|
|
}
|
|
|
|
fmt.Println("\n✓ All required secrets are present")
|
|
return nil
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// secrets list
|
|
// ------------------------------------------------------------
|
|
|
|
var secretsCmd = &cobra.Command{
|
|
Use: "secrets",
|
|
Short: "Manage and document kforge secrets",
|
|
}
|
|
|
|
var secretsListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List all secrets required for this repository",
|
|
Long: `Prints a categorised table of every secret kforge needs,
|
|
where each one should live, and whether it is currently set
|
|
in the process environment.
|
|
|
|
Use this as an onboarding checklist when setting up a new repo.`,
|
|
RunE: runSecretsList,
|
|
}
|
|
|
|
func init() {
|
|
secretsCmd.AddCommand(secretsListCmd)
|
|
rootCmd.AddCommand(secretsCmd)
|
|
}
|
|
|
|
func runSecretsList(cmd *cobra.Command, args []string) error {
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
required := buildRequiredSecrets(cfg)
|
|
|
|
// Group by location.
|
|
groups := map[string][]requiredSecret{}
|
|
for _, s := range required {
|
|
groups[s.Location] = append(groups[s.Location], s)
|
|
}
|
|
|
|
locations := []string{
|
|
"Gitea org secret",
|
|
"Gitea repo secret",
|
|
"Cluster secret (auto-generated)",
|
|
}
|
|
|
|
for _, loc := range locations {
|
|
secrets := groups[loc]
|
|
if len(secrets) == 0 {
|
|
continue
|
|
}
|
|
fmt.Printf("\n── %s ──\n", loc)
|
|
for _, s := range secrets {
|
|
status := "✓ set"
|
|
if loc != "Cluster secret (auto-generated)" {
|
|
if _, ok := os.LookupEnv(s.Name); !ok {
|
|
status = "✗ missing"
|
|
}
|
|
} else {
|
|
status = "— managed by kforge"
|
|
}
|
|
fmt.Printf(" %-40s %s\n", s.Name, status)
|
|
if s.Description != "" {
|
|
fmt.Printf(" %s\n", s.Description)
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
return nil
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// Secret definitions
|
|
// ------------------------------------------------------------
|
|
|
|
type requiredSecret struct {
|
|
Name string
|
|
Location string // "Gitea org secret" | "Gitea repo secret" | "Cluster secret (auto-generated)"
|
|
Description string
|
|
Required bool
|
|
}
|
|
|
|
// buildRequiredSecrets returns the full list of secrets this
|
|
// kforge.yml requires, based on what's enabled.
|
|
func buildRequiredSecrets(cfg *config.KforgeConfig) []requiredSecret {
|
|
secrets := []requiredSecret{
|
|
// Always required — org level
|
|
{Name: "DOCKER_USERNAME", Location: "Gitea org secret", Required: true},
|
|
{Name: "DOCKER_PASSWORD", Location: "Gitea org secret", Required: true},
|
|
{Name: "SOPS_AGE_KEY", Location: "Gitea org secret", Description: "Decrypts .kforge/secrets.enc.yml", Required: true},
|
|
{Name: "KFORGE_NODE_IP", Location: "Gitea org secret", Description: "MicroK8s node IP for DNS A records"},
|
|
|
|
// Always required — repo level
|
|
{Name: "KUBE_HOST", Location: "Gitea repo secret", Required: true},
|
|
{Name: "KUBE_TOKEN", Location: "Gitea repo secret", Required: true},
|
|
{Name: "KUBE_CERTIFICATE", Location: "Gitea repo secret"},
|
|
}
|
|
|
|
// DNS secrets
|
|
if cfg.DNS.Provider != "" && !cfg.DNS.SkipDNS {
|
|
secrets = append(secrets, requiredSecret{
|
|
Name: "CLOUDFLARE_API_TOKEN",
|
|
Location: "Gitea org secret",
|
|
Description: "Zone:Read + DNS:Edit permissions",
|
|
Required: true,
|
|
})
|
|
for _, zone := range cfg.DNS.Cloudflare.Zones {
|
|
varName := "CF_ZONE_ID_" + strings.ToUpper(
|
|
strings.NewReplacer(".", "_", "-", "_").Replace(zone.Name),
|
|
)
|
|
secrets = append(secrets, requiredSecret{
|
|
Name: varName,
|
|
Location: "Gitea org secret",
|
|
Description: "Zone ID for " + zone.Name,
|
|
Required: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Per-environment infrastructure secrets
|
|
envKeys := config.EnvironmentKeys(cfg)
|
|
sort.Strings(envKeys)
|
|
|
|
for _, envKey := range envKeys {
|
|
env, err := config.ResolveEnvironment(cfg, envKey)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
fullName := env.FullName
|
|
infra := env.Infrastructure
|
|
|
|
if infra.Database != nil {
|
|
secrets = append(secrets, requiredSecret{
|
|
Name: fullName + "-db-credentials",
|
|
Location: "Cluster secret (auto-generated)",
|
|
Description: "CNPG-managed database credentials (" + envKey + ")",
|
|
})
|
|
}
|
|
if env.Ingress.Auth.Enabled {
|
|
secrets = append(secrets, requiredSecret{
|
|
Name: env.Ingress.Auth.SecretName,
|
|
Location: "Cluster secret (auto-generated)",
|
|
Description: "Basic auth htpasswd (" + envKey + ")",
|
|
})
|
|
}
|
|
if infra.Cache != nil {
|
|
secrets = append(secrets, requiredSecret{
|
|
Name: fullName + "-cache-credentials",
|
|
Location: "Cluster secret (auto-generated)",
|
|
Description: "Cache password (" + envKey + ")",
|
|
})
|
|
}
|
|
if infra.Storage != nil {
|
|
secrets = append(secrets, requiredSecret{
|
|
Name: fullName + "-storage-credentials",
|
|
Location: "Cluster secret (auto-generated)",
|
|
Description: "Minio access/secret keys (" + envKey + ")",
|
|
})
|
|
}
|
|
if infra.Queue != nil && infra.Queue.Provider == "rabbitmq" {
|
|
secrets = append(secrets, requiredSecret{
|
|
Name: fullName + "-queue-credentials",
|
|
Location: "Cluster secret (auto-generated)",
|
|
Description: "RabbitMQ credentials (" + envKey + ")",
|
|
})
|
|
}
|
|
if infra.Search != nil {
|
|
secrets = append(secrets, requiredSecret{
|
|
Name: fullName + "-search-credentials",
|
|
Location: "Cluster secret (auto-generated)",
|
|
Description: "Meilisearch master key (" + envKey + ")",
|
|
})
|
|
}
|
|
}
|
|
|
|
return secrets
|
|
}
|
|
|
|
// checkRequiredSecrets returns secrets that are required but not
|
|
// set in the current process environment.
|
|
func checkRequiredSecrets(cfg *config.KforgeConfig) []requiredSecret {
|
|
var missing []requiredSecret
|
|
for _, s := range buildRequiredSecrets(cfg) {
|
|
if s.Location == "Cluster secret (auto-generated)" {
|
|
continue // Cluster secrets aren't checked in CI env.
|
|
}
|
|
if !s.Required {
|
|
continue
|
|
}
|
|
if _, ok := os.LookupEnv(s.Name); !ok {
|
|
missing = append(missing, s)
|
|
}
|
|
}
|
|
return missing
|
|
}
|