Files
zero-scale-platform/cmd/zero-scale-ctl/config.go
claude e33abed59b Add QA and feature-branch environments
env create/delete/gc/list manage a namespace per environment, labelled
zero-scale.io/env, with a ResourceQuota, its own gateway and an optional
wildcard route. A zero-scale-env ConfigMap records the env and may override
the platform's database clusters. Hosts use ${ENV_SUFFIX}; databases in a
non-primary env are named db_<env>_<app>_<hash> and carry an owner comment,
so env delete drops only what the env owns. oauth-setup --auth-host serves
every app's login from one callback host. deploy/ manifests no longer
hardcode a namespace; deploy/kustomization.yaml sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uKdmp7tLySXvQXALz2x9V
2026-10-27 06:44:55 -04:00

442 lines
14 KiB
Go

package main
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"fmt"
"git.fairfaxmedia.net/open-cream-cheese/zero-scale-platform/internal/appname"
"git.fairfaxmedia.net/open-cream-cheese/zero-scale-platform/internal/env"
"git.fairfaxmedia.net/open-cream-cheese/zero-scale-platform/internal/volumes"
"os"
"path/filepath"
"strings"
)
// DatabaseConfig holds database mapping parameters.
type DatabaseConfig struct {
Engine string // "mysql" or "postgres"
EnvAddress string // e.g. FLEET_MYSQL_ADDRESS
EnvHost string
EnvPort string
EnvDatabase string // e.g. FLEET_MYSQL_DATABASE
EnvUser string // e.g. FLEET_MYSQL_USERNAME
EnvPassword string // e.g. FLEET_MYSQL_PASSWORD
}
// RedisConfig holds Redis mapping parameters.
type RedisConfig struct {
EnvAddress string // e.g. FLEET_REDIS_ADDRESS
EnvHost string
EnvPort string
}
// GatewayConfig holds ingress-nginx / gateway level settings such as shared OAuth2 setup.
type GatewayConfig struct {
OAuthSetup string // Name of shared oauth-setup profile (e.g. "mdm")
OAuthURL string // OIDC/OAuth2 provider base URL (e.g. "https://git.fairfaxmedia.net")
OAuthGroup string // Required Gitea group(s)/organization(s) (e.g. "open-cream-cheese")
}
// AppConfig represents an application configuration.
type AppConfig struct {
Hostnames []string
BinaryPath string
Database *DatabaseConfig
Redis *RedisConfig
Gateway *GatewayConfig
Mounts string
ScaleToZero bool
IdleTimeout string // e.g. "60s", "5m" (optional custom idle timeout)
// Process execution & launcher configuration
Args string // CLI arguments or subcommand (e.g. "serve")
InitCmd string // One-time initialization run during apply (e.g. migrations)
PreStart string // Alias for InitCmd
User string // Execution user in container (e.g. "root", "nobody")
DynamicUser bool // Whether to use transient DynamicUser=yes (default true)
Env map[string]string // Custom environment variables
Secrets map[string]string // Custom managed secrets (key -> type, e.g. "generate")
ConfigFiles []string // Auxiliary config files to template and upload to /opt/apps/
Launcher string // Explicit full launcher script override (optional)
Environment *env.Config // the target namespace's environment; nil outside one (see bindEnv)
envBound bool
}
// RedirectConfig represents an edge NGINX redirect rule.
type RedirectConfig struct {
SourceHost string
TargetHost string
}
// CanonicalHost returns the primary hostname.
func (a *AppConfig) CanonicalHost() string {
if len(a.Hostnames) > 0 {
return a.Hostnames[0]
}
return "app"
}
// SecondaryHosts returns any additional hostnames.
func (a *AppConfig) SecondaryHosts() []string {
if len(a.Hostnames) > 1 {
return a.Hostnames[1:]
}
return nil
}
// AppName derives a clean short name from the canonical host (e.g. fdm.ffxm.cc -> fdm).
// The rule lives in internal/appname so the gateway resolves the same ConfigMap name.
func (a *AppConfig) AppName() string {
return appname.Name(a.CanonicalHost())
}
func sha8(s string) string {
h := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(s))))
return hex.EncodeToString(h[:])[:8]
}
// DBName returns the deterministic, conflict-free database name.
func (a *AppConfig) DBName() string {
db, _ := a.Environment.DBNames(a.AppName(), sha8(a.CanonicalHost()))
return db
}
// DBUser returns the unique, unguessable database username.
func (a *AppConfig) DBUser() string {
_, user := a.Environment.DBNames(a.AppName(), sha8(a.CanonicalHost()))
return user
}
// SecretName returns the Kubernetes Secret name for this app.
func (a *AppConfig) SecretName() string {
return fmt.Sprintf("%s-db-secret", a.AppName())
}
// ConfigMapName returns the Kubernetes ConfigMap name for this app.
func (a *AppConfig) ConfigMapName() string {
return fmt.Sprintf("%s-db-config", a.AppName())
}
// OAuthSetupName returns the resolved shared oauth-setup profile name if OAuth is configured in gateway, or "".
func (a *AppConfig) OAuthSetupName() string {
if a.Gateway == nil {
return ""
}
if a.Gateway.OAuthSetup != "" {
return strings.ToLower(strings.TrimSpace(a.Gateway.OAuthSetup))
}
if a.Gateway.OAuthURL != "" {
return a.AppName()
}
return ""
}
// NeedsLauncher returns true if the application requires a custom launcher script
// rather than a direct binary symlink.
func (a *AppConfig) NeedsLauncher() bool {
return a.Launcher != "" || a.PreStart != "" || a.Args != "" || len(a.Env) > 0
}
// LoadConfig parses a simple 10-line YAML/conf file into AppConfig or RedirectConfig.
func LoadConfig(filePath string) (interface{}, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open config file: %w", err)
}
defer file.Close()
var (
rawLines []string
hostnames []string
binaryPath string
targetHost string
args string
initCmd string
user string
dynamicUser = true
envVars = make(map[string]string)
launcherLines []string
mounts string
scaleToZero = true
idleTimeout string
configFiles []string
secretsMap = make(map[string]string)
currentSection = ""
currentList = ""
dbCfg *DatabaseConfig
redisCfg *RedisConfig
gatewayCfg *GatewayConfig
)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
rawLines = append(rawLines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
for _, line := range rawLines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
isIndented := strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
// If in launcher multiline mode, collect indented lines
if currentSection == "launcher" {
if isIndented {
launcherLines = append(launcherLines, strings.TrimRight(line, "\r\n"))
continue
}
// Unindented line breaks out of launcher section
currentSection = ""
}
// If not indented, reset nested section unless it's a section header
if !isIndented && !strings.HasSuffix(trimmed, ":") {
currentSection = ""
currentList = ""
}
// List items (e.g. "- fdm.kube.fairfaxmedia.net" or "- ./headscale-config.yaml")
if strings.HasPrefix(trimmed, "- ") {
val := strings.Trim(trimmed[2:], " \t\"'")
if currentList == "hostname" || currentList == "hostnames" || currentList == "host" {
hostnames = append(hostnames, val)
} else if currentList == "config_file" || currentList == "config_files" || currentList == "configs" || currentList == "config" {
configFiles = append(configFiles, val)
} else if currentSection == "volumes" || currentSection == "mounts" || currentList == "volumes" || currentList == "mounts" {
mounts = strings.TrimSpace(mounts + " " + val)
} else if currentSection == "secrets" || currentList == "secrets" || currentList == "secret" {
if strings.Contains(val, ":") {
sp := strings.SplitN(val, ":", 2)
secretsMap[strings.TrimSpace(sp[0])] = strings.Trim(strings.TrimSpace(sp[1]), "\"'\t ")
} else {
secretsMap[val] = "generate"
}
}
continue
}
// Section headers (e.g. "database:", "redis:", "gateway:", "env:", "launcher:")
if strings.HasSuffix(trimmed, ":") && !strings.Contains(trimmed[:len(trimmed)-1], ":") {
key := strings.TrimSuffix(trimmed, ":")
currentSection = key
currentList = ""
if key == "database" && dbCfg == nil {
dbCfg = &DatabaseConfig{Engine: "mysql"}
} else if key == "redis" && redisCfg == nil {
redisCfg = &RedisConfig{}
} else if key == "gateway" && gatewayCfg == nil {
gatewayCfg = &GatewayConfig{}
}
continue
}
// Key-value pairs
if strings.Contains(trimmed, ":") {
parts := strings.SplitN(trimmed, ":", 2)
k := strings.TrimSpace(parts[0])
v := strings.Trim(strings.TrimSpace(parts[1]), "\"'\t ")
if v == "" {
currentList = k
continue
}
if currentSection == "database" && dbCfg != nil {
switch k {
case "engine":
dbCfg.Engine = strings.ToLower(v)
case "env_address":
dbCfg.EnvAddress = v
case "env_host", "env_hostname":
dbCfg.EnvHost = v
case "env_port":
dbCfg.EnvPort = v
case "env_database":
dbCfg.EnvDatabase = v
case "env_user":
dbCfg.EnvUser = v
case "env_password":
dbCfg.EnvPassword = v
}
} else if currentSection == "redis" && redisCfg != nil {
switch k {
case "env_address":
redisCfg.EnvAddress = v
case "env_host", "env_hostname":
redisCfg.EnvHost = v
case "env_port":
redisCfg.EnvPort = v
}
} else if currentSection == "gateway" && gatewayCfg != nil {
switch k {
case "oauth_setup", "auth_setup", "setup", "oauth_name", "oauth_app":
gatewayCfg.OAuthSetup = v
case "oauth", "oauth_url", "oauth_issuer", "oauth_provider_url":
gatewayCfg.OAuthURL = strings.TrimRight(v, "/")
case "oauth_group", "oauth_groups", "allowed_group", "allowed_groups", "group", "groups":
gatewayCfg.OAuthGroup = v
}
} else if currentSection == "env" {
envVars[k] = v
} else if currentSection == "secrets" || currentSection == "secret" {
secretsMap[k] = v
} else {
currentSection = ""
currentList = ""
switch k {
case "hostname", "host":
hostnames = append(hostnames, v)
case "target_host", "target":
targetHost = v
case "binary_path", "binary":
binaryPath = v
case "args", "subcommand", "cmd":
args = v
case "init_cmd", "init", "initialize", "pre_start", "prestart":
initCmd = v
case "user":
user = v
case "dynamic_user", "dynamic-user":
dynamicUser = strings.ToLower(v) != "false"
case "launcher":
if v == "|" || v == ">" {
currentSection = "launcher"
} else {
launcherLines = append(launcherLines, v)
}
case "mounts", "volumes":
mounts = strings.TrimSpace(mounts + " " + v)
case "scale_to_zero":
scaleToZero = strings.ToLower(v) != "false"
case "idle_timeout", "idle-timeout", "timeout":
idleTimeout = v
case "config_file", "config":
configFiles = append(configFiles, v)
case "config_files", "configs":
// Could be comma-separated or space-separated
for _, item := range strings.Fields(strings.ReplaceAll(v, ",", " ")) {
if item != "" {
configFiles = append(configFiles, item)
}
}
}
}
}
}
// 1. Redirect Mode Check
if targetHost != "" {
if len(hostnames) == 0 {
return nil, fmt.Errorf("redirect config must specify 'hostname'")
}
return &RedirectConfig{
SourceHost: hostnames[0],
TargetHost: targetHost,
}, nil
}
// 2. Application Mode Check
if len(hostnames) == 0 {
return nil, fmt.Errorf("config must specify at least one 'hostname'")
}
if binaryPath == "" {
return nil, fmt.Errorf("config must specify 'binary_path'")
}
// Resolve binaryPath relative to config file directory if not absolute
if !filepath.IsAbs(binaryPath) {
cfgDir := filepath.Dir(filePath)
absCfgDir, err := filepath.Abs(cfgDir)
if err == nil {
binaryPath = filepath.Clean(filepath.Join(absCfgDir, binaryPath))
}
}
var launcherScript string
if len(launcherLines) > 0 {
launcherScript = strings.Join(launcherLines, "\n")
}
// Resolve and validate configFiles relative to config file directory
cfgDir := filepath.Dir(filePath)
absCfgDir, _ := filepath.Abs(cfgDir)
var resolvedConfigFiles []string
binBase := filepath.Base(binaryPath)
binBase = strings.TrimSuffix(binBase, filepath.Ext(binBase))
primaryName := strings.ToLower(binBase)
// Also extract short name from hostname (e.g. hs from hs.ffxm.cc)
var hostShort string
if len(hostnames) > 0 {
// Templated hosts (fdm${ENV_SUFFIX}.example.com) take the primary env's name.
hostShort = appname.Name((*env.Config)(nil).Expand(hostnames[0]))
}
for _, cf := range configFiles {
cf = strings.TrimSpace(cf)
if cf == "" {
continue
}
base := filepath.Base(cf)
baseLower := strings.ToLower(base)
// Verification: file must start with binary name prefix or app short name prefix
// e.g. headscale-*, headscale_*, headscale.*, hs-*, hs_*, hs.*
validPrefix := false
if strings.HasPrefix(baseLower, primaryName+"-") ||
strings.HasPrefix(baseLower, primaryName+"_") ||
strings.HasPrefix(baseLower, primaryName+".") {
validPrefix = true
} else if hostShort != "" && (strings.HasPrefix(baseLower, hostShort+"-") ||
strings.HasPrefix(baseLower, hostShort+"_") ||
strings.HasPrefix(baseLower, hostShort+".")) {
validPrefix = true
}
if !validPrefix {
return nil, fmt.Errorf("config_file '%s' must start with service prefix '%s-' or '%s-' (e.g. '%s-config.yaml') to avoid conflicts on shared volume",
base, primaryName, hostShort, primaryName)
}
resolvedPath := cf
if !filepath.IsAbs(resolvedPath) {
resolvedPath = filepath.Clean(filepath.Join(absCfgDir, cf))
}
resolvedConfigFiles = append(resolvedConfigFiles, resolvedPath)
}
vols, err := volumes.Parse(mounts)
if err != nil {
return nil, fmt.Errorf("volumes: %w", err)
}
return &AppConfig{
Hostnames: hostnames,
BinaryPath: binaryPath,
Database: dbCfg,
Redis: redisCfg,
Gateway: gatewayCfg,
Mounts: volumes.Format(vols),
ScaleToZero: scaleToZero,
IdleTimeout: idleTimeout,
Args: args,
InitCmd: initCmd,
PreStart: initCmd,
User: user,
DynamicUser: dynamicUser,
Env: envVars,
Secrets: secretsMap,
ConfigFiles: resolvedConfigFiles,
Launcher: launcherScript,
}, nil
}