Apps declare volumes: name:/path[:ro] (mounts: still accepted). The gateway binds /srv/zero-scale-volumes/<app>/<name> into the unit, running it as a fixed zs-<app> dynamic user so ownership is stable. gateway install creates zero-scale-volumes-pvc; delete-app --purge --delete-volumes removes the data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uKdmp7tLySXvQXALz2x9V
291 lines
9.5 KiB
Go
291 lines
9.5 KiB
Go
package runner
|
|
|
|
import (
|
|
"fmt"
|
|
"git.fairfaxmedia.net/open-cream-cheese/zero-scale-platform/internal/appname"
|
|
"git.fairfaxmedia.net/open-cream-cheese/zero-scale-platform/internal/volumes"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.fairfaxmedia.net/open-cream-cheese/zero-scale-platform/internal/jwtauth"
|
|
)
|
|
|
|
// IsServiceActive checks if the dynamic systemd unit is running
|
|
func IsServiceActive(unit string) (bool, error) {
|
|
cmd := exec.Command("systemctl", "is-active", unit)
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
if _, ok := err.(*exec.ExitError); ok {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// GetActiveServicePort reads PORT from running systemd service environment
|
|
func GetActiveServicePort(unit string) int {
|
|
out, err := exec.Command("systemctl", "show", "-p", "Environment", unit).Output()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
str := string(out)
|
|
for _, env := range strings.Fields(str) {
|
|
if strings.HasPrefix(env, "PORT=") || strings.HasPrefix(env, "Environment=PORT=") {
|
|
parts := strings.Split(env, "=")
|
|
if len(parts) >= 2 {
|
|
val := parts[len(parts)-1]
|
|
if p, err := strconv.Atoi(val); err == nil && p > 0 {
|
|
return p
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func isValidEnvName(name string) bool {
|
|
if len(name) == 0 {
|
|
return false
|
|
}
|
|
for i, c := range name {
|
|
if i == 0 {
|
|
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_') {
|
|
return false
|
|
}
|
|
} else {
|
|
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_') {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// StartTransientService executes systemd-run with dynamic environment injection
|
|
func StartTransientService(host string, port int, binaryPath, namespace string, appData map[string]string) error {
|
|
unitName := fmt.Sprintf("app-%s", host)
|
|
typePath := binaryPath + ".type"
|
|
|
|
runType := "elf"
|
|
if FileExists(typePath) {
|
|
tBytes, err := os.ReadFile(typePath)
|
|
if err == nil {
|
|
runType = strings.TrimSpace(string(tBytes))
|
|
}
|
|
}
|
|
|
|
var appArgs string
|
|
if appData != nil {
|
|
appArgs = appData["args"]
|
|
}
|
|
|
|
baseArgs := []string{
|
|
fmt.Sprintf("--unit=%s", unitName),
|
|
"-p", "PrivateTmp=yes",
|
|
fmt.Sprintf("--setenv=PORT=%d", port),
|
|
}
|
|
|
|
vols, err := volumes.Parse(appData["volumes"])
|
|
if err != nil {
|
|
return fmt.Errorf("app %s: %w", host, err)
|
|
}
|
|
appName := appname.Name(host)
|
|
|
|
// Dynamic user configuration (default: DynamicUser=yes, can be disabled via dynamic-user=false or user=<user>)
|
|
volumeOwner := ""
|
|
if appData != nil && (appData["dynamic-user"] == "false" || appData["dynamic_user"] == "false") {
|
|
if u := appData["user"]; u != "" {
|
|
baseArgs = append(baseArgs, "-p", "User="+u)
|
|
volumeOwner = u
|
|
}
|
|
} else if appData != nil && appData["user"] != "" {
|
|
baseArgs = append(baseArgs, "-p", "User="+appData["user"])
|
|
volumeOwner = appData["user"]
|
|
} else {
|
|
baseArgs = append(baseArgs, "-p", "DynamicUser=yes", "-p", "ProtectSystem=strict")
|
|
if len(vols) > 0 {
|
|
// A fixed dynamic user name per app, so its volumes keep one owner across restarts.
|
|
volumeOwner = "zs-" + appName
|
|
baseArgs = append(baseArgs, "-p", "User="+volumeOwner)
|
|
}
|
|
}
|
|
|
|
// Persistent volumes: per-app subdirectories of the shared RWX volume.
|
|
if len(vols) > 0 {
|
|
root := os.Getenv("VOLUMES_ROOT")
|
|
if root == "" {
|
|
root = volumes.DefaultRoot
|
|
}
|
|
if info, err := os.Stat(root); err != nil || !info.IsDir() {
|
|
return fmt.Errorf("app %s declares volumes but %s is not mounted in the gateway (redeploy it with 'zero-scale-ctl gateway upgrade')", host, root)
|
|
}
|
|
for _, v := range vols {
|
|
if err := os.MkdirAll(volumes.Source(root, appName, v), 0700); err != nil {
|
|
return fmt.Errorf("creating volume %s for %s: %w", v.Name, host, err)
|
|
}
|
|
}
|
|
for _, prop := range volumes.SystemdProperties(root, appName, volumeOwner, vols) {
|
|
baseArgs = append(baseArgs, "-p", prop)
|
|
}
|
|
} else if _, err := os.Stat("/app/uploads"); err == nil {
|
|
// Legacy: bind the gateway's /app/uploads when present and no volumes are declared
|
|
baseArgs = append(baseArgs, "-p", "BindPaths=/app/uploads:/app/uploads")
|
|
}
|
|
|
|
// Forward standard database environment variables if present on host
|
|
envKeys := []string{
|
|
"DATABASE_URL", "DATABASE_HOST", "DATABASE_PORT", "DATABASE_USER", "DATABASE_PASSWORD", "DATABASE_NAME",
|
|
"PGHOST", "PGPORT", "PGUSER", "PGPASSWORD", "PGDATABASE",
|
|
}
|
|
for _, k := range envKeys {
|
|
if v := os.Getenv(k); v != "" {
|
|
baseArgs = append(baseArgs, fmt.Sprintf("--setenv=%s=%s", k, v))
|
|
}
|
|
}
|
|
|
|
if appData != nil {
|
|
// Set of internal platform configuration keys that should never be passed as process environment variables
|
|
ignoredKeys := map[string]bool{
|
|
"host": true, "hosts": true, "url": true, "type": true, "mounts": true, "volumes": true,
|
|
"scale-to-zero": true, "scale_to_zero": true,
|
|
"idle-timeout": true, "idle_timeout": true,
|
|
"database_configmap": true, "database_secret": true, "database_engine": true,
|
|
"args": true, "user": true,
|
|
"dynamic-user": true, "dynamic_user": true,
|
|
"oauth-setup": true, "oauth_setup": true, "oauth": true,
|
|
}
|
|
|
|
for k, v := range appData {
|
|
if ignoredKeys[k] || !isValidEnvName(k) {
|
|
continue
|
|
}
|
|
v = strings.ReplaceAll(v, "${PORT}", strconv.Itoa(port))
|
|
v = strings.ReplaceAll(v, "${HOST}", host)
|
|
baseArgs = append(baseArgs, fmt.Sprintf("--setenv=%s=%s", k, v))
|
|
}
|
|
if dbCM := appData["database_configmap"]; dbCM != "" {
|
|
if cmData, err := FetchK8sResource("configmaps", dbCM, namespace); err == nil {
|
|
for k, v := range cmData {
|
|
if !isValidEnvName(k) {
|
|
continue
|
|
}
|
|
v = strings.ReplaceAll(v, "${PORT}", strconv.Itoa(port))
|
|
v = strings.ReplaceAll(v, "${HOST}", host)
|
|
baseArgs = append(baseArgs, fmt.Sprintf("--setenv=%s=%s", k, v))
|
|
}
|
|
}
|
|
}
|
|
if dbSec := appData["database_secret"]; dbSec != "" {
|
|
if secData, err := FetchK8sResource("secrets", dbSec, namespace); err == nil {
|
|
for k, v := range secData {
|
|
if !isValidEnvName(k) {
|
|
continue
|
|
}
|
|
baseArgs = append(baseArgs, fmt.Sprintf("--setenv=%s=%s", k, v))
|
|
}
|
|
}
|
|
}
|
|
|
|
// If this app is fronted by OAuth, inject OAUTH_PUBLIC_KEY and LOGOUT_URL
|
|
if appData["oauth-setup"] != "" || appData["oauth_setup"] != "" || appData["oauth"] != "" {
|
|
signer := jwtauth.GetSigner()
|
|
if pubKey := signer.PublicPEM(); pubKey != "" {
|
|
baseArgs = append(baseArgs, fmt.Sprintf("--setenv=OAUTH_PUBLIC_KEY=%s", pubKey))
|
|
}
|
|
logoutURL := fmt.Sprintf("https://%s/oauth2/sign_out", host)
|
|
baseArgs = append(baseArgs, fmt.Sprintf("--setenv=LOGOUT_URL=%s", logoutURL))
|
|
}
|
|
}
|
|
|
|
var cmd *exec.Cmd
|
|
if runType == "wasm" {
|
|
args := append(baseArgs,
|
|
"--setenv=WASM_PORT="+strconv.Itoa(port),
|
|
"/usr/local/bin/wasmtime", "run",
|
|
"--env", fmt.Sprintf("PORT=%d", port),
|
|
"--tcplisten", fmt.Sprintf("127.0.0.1:%d", port),
|
|
binaryPath,
|
|
)
|
|
log.Printf("Executing WASM runner: /usr/bin/systemd-run %s", strings.Join(args, " "))
|
|
cmd = exec.Command("systemd-run", args...)
|
|
} else {
|
|
execArgs := []string{binaryPath}
|
|
if appArgs != "" {
|
|
for _, arg := range strings.Fields(appArgs) {
|
|
arg = strings.ReplaceAll(arg, "${PORT}", strconv.Itoa(port))
|
|
arg = strings.ReplaceAll(arg, "${HOST}", host)
|
|
execArgs = append(execArgs, arg)
|
|
}
|
|
} else {
|
|
execArgs = append(execArgs, "-port", strconv.Itoa(port))
|
|
}
|
|
args := append(baseArgs, execArgs...)
|
|
log.Printf("Executing: /usr/bin/systemd-run %s", strings.Join(args, " "))
|
|
cmd = exec.Command("systemd-run", args...)
|
|
}
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
if strings.Contains(string(out), "already loaded") || strings.Contains(string(out), "fragment file") {
|
|
log.Printf("Transient unit %s has stale fragment, cleaning up and retrying...", unitName)
|
|
_ = exec.Command("systemctl", "stop", unitName).Run()
|
|
_ = exec.Command("systemctl", "reset-failed", unitName).Run()
|
|
_ = os.Remove(fmt.Sprintf("/run/systemd/transient/%s.service", unitName))
|
|
_ = exec.Command("systemctl", "daemon-reload").Run()
|
|
cmdRetry := exec.Command("systemd-run", cmd.Args[1:]...)
|
|
outRetry, errRetry := cmdRetry.CombinedOutput()
|
|
if errRetry == nil {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("systemd-run retry failed: %v, output: %s", errRetry, string(outRetry))
|
|
}
|
|
return fmt.Errorf("systemd-run failed: %v, output: %s", err, string(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StopService stops the dynamic systemd unit
|
|
func StopService(host string) error {
|
|
unitName := fmt.Sprintf("app-%s", host)
|
|
cmd := exec.Command("systemctl", "stop", unitName)
|
|
return cmd.Run()
|
|
}
|
|
|
|
// ListRunningAppUnits returns the hostnames of all running app-*.service units
|
|
func ListRunningAppUnits() []string {
|
|
out, err := exec.Command("systemctl", "list-units", "--type=service", "--state=running", "--no-legend", "--no-pager", "app-*.service").Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var hosts []string
|
|
lines := strings.Split(string(out), "\n")
|
|
for _, line := range lines {
|
|
fields := strings.Fields(line)
|
|
if len(fields) > 0 && strings.HasPrefix(fields[0], "app-") && strings.HasSuffix(fields[0], ".service") {
|
|
host := strings.TrimPrefix(fields[0], "app-")
|
|
host = strings.TrimSuffix(host, ".service")
|
|
hosts = append(hosts, host)
|
|
}
|
|
}
|
|
return hosts
|
|
}
|
|
|
|
// GetServiceStartTime returns the ActiveEnterTimestamp for a unit
|
|
func GetServiceStartTime(unit string) (time.Time, error) {
|
|
out, err := exec.Command("systemctl", "show", "-p", "ActiveEnterTimestamp", unit).Output()
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
str := strings.TrimSpace(string(out))
|
|
str = strings.TrimPrefix(str, "ActiveEnterTimestamp=")
|
|
if str == "" {
|
|
return time.Time{}, fmt.Errorf("empty timestamp")
|
|
}
|
|
return time.Parse("Mon 2006-01-02 15:04:05 MST", str)
|
|
}
|