This commit is contained in:
+252
@@ -0,0 +1,252 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user