Files
zero-scale-platform/cmd/zero-scale-ctl/storage.go
claude fdfd615fd9 Add gateway install/status, ingress host routing and delete-app --purge
- gateway install/upgrade renders an HA gateway (2 replicas, RollingUpdate,
  PDB, hostname spread, downward-API namespace, RWX /opt/apps on the platform
  class); a Helm-managed gateway is recorded and left alone
- gateway status shows which apps run on which replica
- ingress add-host/remove-host/list: one Ingress per host or wildcard, TLS
  from a Secret or a cert-manager ClusterIssuer (wildcards require DNS-01),
  upstream-hash-by $host pinning; hosts stored in the platform ConfigMap
- apply skips the per-app Ingress when routed hosts already cover the app
  in the gateway's namespace and no OAuth is used; per-app Ingresses also pin
  by host
- status, logs and binary updates reach every gateway replica
- delete-app --purge [--drop-database]; Postgres drops only databases whose
  owner comment matches; apply records database_engine for later cleanup
2026-10-27 06:44:55 -04:00

236 lines
8.7 KiB
Go

package main
import (
"crypto/sha256"
"debug/elf"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
func fileSHA256(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return ""
}
return hex.EncodeToString(h.Sum(nil))
}
// ValidateBinaryArchitecture verifies that the binary is compatible with the Linux x86-64 gateway container.
// It detects macOS Mach-O, Windows PE, and mismatched Linux ELF architectures (e.g. ARM64) before deployment.
func ValidateBinaryArchitecture(path string) error {
if os.Getenv("ZERO_SCALE_SKIP_ARCH_CHECK") == "1" || os.Getenv("ZERO_SCALE_SKIP_ARCH_CHECK") == "true" {
return nil
}
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open binary '%s': %w", path, err)
}
defer f.Close()
header := make([]byte, 32)
n, _ := io.ReadFull(f, header)
if n < 4 {
return nil
}
// 1. Script with shebang (e.g. #!/bin/sh)
if header[0] == '#' && header[1] == '!' {
return nil
}
// 2. WebAssembly binary (\x00asm)
if header[0] == 0x00 && header[1] == 0x61 && header[2] == 0x73 && header[3] == 0x6d {
return nil
}
// 3. macOS Mach-O binary
// Magic numbers: 0xfeedface (32-bit), 0xfeedfacf (64-bit), 0xcafebabe (Fat/Universal)
magicBE := binary.BigEndian.Uint32(header[0:4])
magicLE := binary.LittleEndian.Uint32(header[0:4])
if magicBE == 0xfeedface || magicBE == 0xfeedfacf || magicBE == 0xcafebabe ||
magicLE == 0xfeedface || magicLE == 0xfeedfacf || magicLE == 0xcafebabe {
return fmt.Errorf("binary '%s' is a macOS (Mach-O) binary.\nThe zero-scale gateway container runs Linux x86-64 (amd64) and cannot execute macOS binaries (will fail with 'exec format error').\n--> To cross-compile for Linux x86-64, run:\n GOOS=linux GOARCH=amd64 go build -o %s ...",
path, filepath.Base(path))
}
// 4. Windows PE executable ("MZ")
if header[0] == 'M' && header[1] == 'Z' {
return fmt.Errorf("binary '%s' is a Windows (PE) executable.\nThe zero-scale gateway container runs Linux x86-64 (amd64) and cannot execute Windows binaries.\n--> To cross-compile for Linux x86-64, run:\n GOOS=linux GOARCH=amd64 go build -o %s ...",
path, filepath.Base(path))
}
// 5. Linux ELF binary
if header[0] == 0x7f && header[1] == 'E' && header[2] == 'L' && header[3] == 'F' {
if n >= 20 {
var machine uint16
if header[5] == 2 { // Big-endian
machine = binary.BigEndian.Uint16(header[18:20])
} else { // Little-endian
machine = binary.LittleEndian.Uint16(header[18:20])
}
if elf.Machine(machine) != elf.EM_X86_64 {
archName := elf.Machine(machine).String()
if elf.Machine(machine) == elf.EM_AARCH64 {
archName = "Linux ARM64 (aarch64)"
}
return fmt.Errorf("binary '%s' is compiled for %s, but the zero-scale gateway container runs Linux x86-64 (amd64).\nDeploying this binary will cause container startup failure with 'exec format error'.\n--> To cross-compile for Linux x86-64, run:\n GOOS=linux GOARCH=amd64 go build -o %s ...",
path, archName, filepath.Base(path))
}
return nil
}
}
return nil
}
// DeployBinary ensures local binary is uploaded to gateway pod /opt/apps/ and symlinks are configured.
func DeployBinary(app *AppConfig, namespace string) error {
if _, err := os.Stat(app.BinaryPath); os.IsNotExist(err) {
return fmt.Errorf("local binary not found at '%s'", app.BinaryPath)
}
if err := ValidateBinaryArchitecture(app.BinaryPath); err != nil {
return err
}
pod, err := GetGatewayPod(namespace)
if err != nil {
return err
}
binBase := filepath.Base(app.BinaryPath)
targetBinPath := fmt.Sprintf("/opt/apps/%s", binBase)
localHash := fileSHA256(app.BinaryPath)
remoteOut, _ := ExecPod(pod, namespace, "systemd-runner", []string{
"sh", "-c", fmt.Sprintf("sha256sum '%s' 2>/dev/null | awk '{print $1}'", targetBinPath),
}, "")
remoteHash := strings.TrimSpace(remoteOut)
if remoteHash == "" || (localHash != "" && localHash != remoteHash) {
fmt.Printf("--> Uploading local binary '%s' to %s:%s...\n", app.BinaryPath, pod, targetBinPath)
err = CpToPod(app.BinaryPath, targetBinPath, pod, namespace, "systemd-runner")
if err != nil {
return fmt.Errorf("failed to copy binary to pod: %w\n--> Ensure the storage volume for /opt/apps (zero-scale-binaries-pvc) is bound and mounted in the gateway pod.\n--> Documentation: https://git.fairfaxmedia.net/open-cream-cheese/zero-scale-platform#persistent-storage-and-volumes", err)
}
_, err = ExecPod(pod, namespace, "systemd-runner", []string{"chmod", "+x", targetBinPath}, "")
if err != nil {
return fmt.Errorf("failed to chmod binary: %w", err)
}
// Stop the running unit on every gateway replica so the next request launches the updated binary
stopAppEverywhere(app.CanonicalHost(), namespace)
fmt.Printf("--> Successfully deployed and set executable: %s\n", targetBinPath)
} else {
fmt.Printf("--> Binary '%s' is up to date on shared persistent volume (sha256: %s).\n", targetBinPath, localHash[:12])
}
// Configure entrypoint for canonical host
canonicalLauncher := fmt.Sprintf("/opt/apps/%s", app.CanonicalHost())
if app.Launcher != "" {
fmt.Printf("--> Writing custom launcher script to %s...\n", canonicalLauncher)
script := app.Launcher
script = strings.ReplaceAll(script, "${HOSTNAME}", app.CanonicalHost())
script = strings.ReplaceAll(script, "${BINARY}", targetBinPath)
cmd := fmt.Sprintf("cat > '%s' && chmod +x '%s'", canonicalLauncher, canonicalLauncher)
_, err = ExecPod(pod, namespace, "systemd-runner", []string{"sh", "-c", cmd}, script)
if err != nil {
return fmt.Errorf("failed to write custom launcher: %w", err)
}
} else {
// Native direct execution: symlink canonical host directly to binary
fmt.Printf("--> Symlinking %s -> %s (direct native binary execution, 0 bash latency)...\n", canonicalLauncher, targetBinPath)
_, err = ExecPod(pod, namespace, "systemd-runner", []string{
"ln", "-sf", targetBinPath, canonicalLauncher,
}, "")
if err != nil {
return fmt.Errorf("failed to create symlink: %w", err)
}
}
// Configure symlinks for any secondary hostnames
for _, secondary := range app.SecondaryHosts() {
secondaryLauncher := fmt.Sprintf("/opt/apps/%s", secondary)
fmt.Printf("--> Symlinking secondary host %s -> %s...\n", secondaryLauncher, canonicalLauncher)
_, err = ExecPod(pod, namespace, "systemd-runner", []string{
"ln", "-sf", canonicalLauncher, secondaryLauncher,
}, "")
if err != nil {
return fmt.Errorf("failed to symlink secondary host: %w", err)
}
}
return nil
}
// DeployConfigFiles templates auxiliary configuration files (replacing ${VAR}) and copies them to /opt/apps/.
func DeployConfigFiles(app *AppConfig, namespace string, envMap map[string]string) error {
if len(app.ConfigFiles) == 0 {
return nil
}
pod, err := GetGatewayPod(namespace)
if err != nil {
return err
}
for _, cfPath := range app.ConfigFiles {
data, err := os.ReadFile(cfPath)
if err != nil {
return fmt.Errorf("failed to read config file '%s': %w", cfPath, err)
}
content := string(data)
// Substitute all ${VAR} tokens from envMap
for k, v := range envMap {
content = strings.ReplaceAll(content, "${"+k+"}", v)
}
// Also provide standard default expansions
content = strings.ReplaceAll(content, "${HOST}", app.CanonicalHost())
content = strings.ReplaceAll(content, "${HOSTNAME}", app.CanonicalHost())
content = strings.ReplaceAll(content, "${PORT}", "8080")
content = strings.ReplaceAll(content, "${NAMESPACE}", namespace)
// Database fallbacks come from the provisioned <app>-db-config values, so they
// match whichever cluster the platform config points at.
if app.Database != nil {
dbHost, dbPort := envMap["DATABASE_HOST"], envMap["DATABASE_PORT"]
content = strings.ReplaceAll(content, "${DATABASE_PORT}", dbPort)
content = strings.ReplaceAll(content, "${DATABASE_HOST}", dbHost)
if app.Database.EnvAddress != "" {
content = strings.ReplaceAll(content, "${"+app.Database.EnvAddress+"_PORT}", dbPort)
content = strings.ReplaceAll(content, "${"+app.Database.EnvAddress+"_HOST}", dbHost)
}
if app.Database.EnvHost != "" {
content = strings.ReplaceAll(content, "${"+app.Database.EnvHost+"_PORT}", dbPort)
}
}
baseName := filepath.Base(cfPath)
targetPath := fmt.Sprintf("/opt/apps/%s", baseName)
fmt.Printf("--> Templating and uploading config '%s' -> %s:%s (permissions 0600)...\n", baseName, pod, targetPath)
// Stream content directly to pod without leaking to local disk or CLI args
cmd := fmt.Sprintf("cat > '%s' && chmod 600 '%s'", targetPath, targetPath)
_, err = ExecPod(pod, namespace, "systemd-runner", []string{"sh", "-c", cmd}, content)
if err != nil {
return fmt.Errorf("failed to write templated config file to pod: %w", err)
}
}
return nil
}