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
442 lines
14 KiB
Go
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
|
|
}
|