709 lines
24 KiB
Go
709 lines
24 KiB
Go
// Package procctl mirrors the process-management half of tui-panel/
|
|
// server-panel.py (git pull, go build, launch/stop, and the shared
|
|
// .server_panel.json PID state file) so the admin web panel can offer the
|
|
// same Restart/Update actions the TUI already has, without requiring an
|
|
// operator to SSH in and use the TUI for that specifically.
|
|
//
|
|
// Restart/Update never restart the admin binary themselves (the process
|
|
// this code typically runs inside, when called from cmd/telesrv-admin) --
|
|
// self-restarting mid-HTTP-request is a materially different, riskier
|
|
// problem (dropped response, no clean signal to the caller that it actually
|
|
// completed) than the TUI's case, where a human is watching an interactive
|
|
// session and re-exec is transparent. Instead they set
|
|
// State.PendingAdminRestart and let the *next* owpengram-server process
|
|
// pick it up via HandlePendingAdminRestart once it's confirmed serving
|
|
// (cmd/telesrv/main.go's OnServing hook) -- that process is unrelated to
|
|
// whatever admin panel is currently running, so it can safely kill the old
|
|
// admin PID and launch a new one with none of the self-restart risk.
|
|
package procctl
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const stateFileName = ".server_panel.json"
|
|
|
|
// Manager operates on one repo checkout (Root), the same layout
|
|
// tui-panel/server-panel.py expects: bin/, logs/, .env, .env.example, and
|
|
// .server_panel.json at the root.
|
|
type Manager struct {
|
|
Root string
|
|
}
|
|
|
|
func NewManager(root string) *Manager {
|
|
return &Manager{Root: root}
|
|
}
|
|
|
|
func (m *Manager) serverExe() string {
|
|
if runtime.GOOS == "windows" {
|
|
return filepath.Join(m.Root, "bin", "owpengram-server.exe")
|
|
}
|
|
return filepath.Join(m.Root, "bin", "owpengram-server")
|
|
}
|
|
|
|
func (m *Manager) adminExe() string {
|
|
if runtime.GOOS == "windows" {
|
|
return filepath.Join(m.Root, "bin", "owpengram-admin-panel.exe")
|
|
}
|
|
return filepath.Join(m.Root, "bin", "owpengram-admin-panel")
|
|
}
|
|
|
|
func (m *Manager) serverLog() string { return filepath.Join(m.Root, "logs", "owpengram-server.log") }
|
|
func (m *Manager) adminLog() string {
|
|
return filepath.Join(m.Root, "logs", "owpengram-admin-panel.log")
|
|
}
|
|
|
|
// --- state file (shared with tui-panel/server-panel.py) --------------------
|
|
|
|
type State struct {
|
|
ServerPID int `json:"server_pid"`
|
|
AdminPID int `json:"admin_pid"`
|
|
DockerProject string `json:"docker_project"`
|
|
DockerPrefix string `json:"docker_prefix"`
|
|
// PendingAdminRestart is how Restart/Update ask the *next*
|
|
// owpengram-server process to bounce the admin panel for them, instead
|
|
// of the admin panel trying to restart itself mid-HTTP-request (see the
|
|
// package doc). Set here, consumed by HandlePendingAdminRestart at the
|
|
// new owpengram-server's startup. server-panel.py doesn't know this key
|
|
// exists -- its own save_state() overwrites the file with only its 4
|
|
// original fields, so a Stop/Start/Restart/Update run from the TUI in
|
|
// the narrow window before the flag is consumed will silently drop it.
|
|
// Rare, and the only consequence is the admin panel not restarting that
|
|
// one time -- not worth coordinating two processes' writes over.
|
|
PendingAdminRestart bool `json:"pending_admin_restart,omitempty"`
|
|
}
|
|
|
|
func (m *Manager) loadState() State {
|
|
var st State
|
|
data, err := os.ReadFile(filepath.Join(m.Root, stateFileName))
|
|
if err != nil {
|
|
return st
|
|
}
|
|
_ = json.Unmarshal(data, &st)
|
|
if st.DockerProject == "" {
|
|
st.DockerProject = "owpengram"
|
|
}
|
|
if st.DockerPrefix == "" {
|
|
st.DockerPrefix = "owpengram"
|
|
}
|
|
return st
|
|
}
|
|
|
|
func (m *Manager) saveState(st State) error {
|
|
data, err := json.Marshal(st)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(filepath.Join(m.Root, stateFileName), data, 0o644)
|
|
}
|
|
|
|
// Status reports whether the server/admin PIDs recorded in the shared state
|
|
// file are still alive.
|
|
type Status struct {
|
|
ServerPID int
|
|
ServerAlive bool
|
|
AdminPID int
|
|
AdminAlive bool
|
|
}
|
|
|
|
func (m *Manager) Status() Status {
|
|
st := m.loadState()
|
|
return Status{
|
|
ServerPID: st.ServerPID,
|
|
ServerAlive: pidAlive(st.ServerPID),
|
|
AdminPID: st.AdminPID,
|
|
AdminAlive: pidAlive(st.AdminPID),
|
|
}
|
|
}
|
|
|
|
// --- process control ---------------------------------------------------
|
|
|
|
func pidAlive(pid int) bool {
|
|
if pid <= 0 {
|
|
return false
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
|
|
hideWindow(cmd)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.Contains(string(out), strconv.Itoa(pid))
|
|
}
|
|
cmd := exec.Command("kill", "-0", strconv.Itoa(pid))
|
|
hideWindow(cmd)
|
|
return cmd.Run() == nil
|
|
}
|
|
|
|
// killPID mirrors kill_pid() in server-panel.py, MINUS its "/T" tree-kill on
|
|
// Windows -- deliberately different here, not an oversight. server-panel.py
|
|
// is always the common parent of both the server and admin processes, so
|
|
// tree-killing one PID never touches the other. This package's callers are
|
|
// not: HandlePendingAdminRestart runs *inside* the freshly launched
|
|
// owpengram-server, which was itself spawned as a child of the *old*
|
|
// owpengram-admin-panel process by the Restart/Update call that got it
|
|
// here. Killing that old admin PID with "/T" would tree-kill its entire
|
|
// descendant chain -- including this very owpengram-server process, since
|
|
// it's a child of the PID being killed. Windows' taskkill walks that chain
|
|
// by recorded parent-PID regardless of any process-group flags on launch,
|
|
// so the only reliable fix is to never tree-kill here: exact-PID kill only,
|
|
// since every process this package launches is started directly via
|
|
// exec.Command (no intermediate shell wrapper), so there's no wrapper-spawned
|
|
// grandchild "/T" would need to catch anyway.
|
|
func killPID(pid int) {
|
|
if pid <= 0 {
|
|
return
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/F")
|
|
hideWindow(cmd)
|
|
_ = cmd.Run()
|
|
return
|
|
}
|
|
term := exec.Command("kill", "-TERM", strconv.Itoa(pid))
|
|
hideWindow(term)
|
|
_ = term.Run()
|
|
time.Sleep(time.Second)
|
|
kill := exec.Command("kill", "-KILL", strconv.Itoa(pid))
|
|
hideWindow(kill)
|
|
_ = kill.Run()
|
|
}
|
|
|
|
// launch starts exePath detached, cwd=Root, stdout/stderr appended to
|
|
// logPath, and returns its PID. Unlike the Python TUI this does not set a
|
|
// new session/process group (that needs OS-specific SysProcAttr) -- started
|
|
// via Start() (not Run()), the child outlives this function's return either
|
|
// way, which is all a request/response HTTP handler needs.
|
|
func (m *Manager) launch(exePath, logPath string) (int, error) {
|
|
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
|
|
return 0, fmt.Errorf("mkdir logs: %w", err)
|
|
}
|
|
logf, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("open log: %w", err)
|
|
}
|
|
defer logf.Close()
|
|
cmd := exec.Command(exePath)
|
|
cmd.Dir = m.Root
|
|
cmd.Stdout = logf
|
|
cmd.Stderr = logf
|
|
cmd.Stdin = nil
|
|
hideWindow(cmd)
|
|
if err := cmd.Start(); err != nil {
|
|
return 0, fmt.Errorf("start %s: %w", exePath, err)
|
|
}
|
|
go func() { _ = cmd.Wait() }() // reap so it doesn't linger as a zombie
|
|
return cmd.Process.Pid, nil
|
|
}
|
|
|
|
// --- Docker infrastructure (Postgres/Redis/MinIO) -------------------------
|
|
|
|
const (
|
|
postgresWaitTimeout = 60 * time.Second
|
|
postgresWaitInterval = 2 * time.Second
|
|
)
|
|
|
|
// ensureDocker mirrors server-panel.py's START_STEPS "docker" + "postgres"
|
|
// steps -- `docker compose up -d` then wait for Postgres to answer
|
|
// pg_isready. Restart/Update run this every time, same as the TUI: it's a
|
|
// no-op when the containers are already up (compose up -d on a running
|
|
// stack just confirms state), but skipping it entirely was the actual bug
|
|
// report this addresses -- a Restart/Update landing while Postgres/Redis/
|
|
// MinIO are down (host reboot, containers manually stopped, etc.) would
|
|
// otherwise relaunch owpengram-server straight into a DB-connect failure
|
|
// with no clear signal why, instead of surfacing "Postgres not ready" here.
|
|
func (m *Manager) ensureDocker(ctx context.Context, st State) (string, error) {
|
|
composeFile := filepath.Join(m.Root, "deploy", "docker-compose.yml")
|
|
if _, err := os.Stat(composeFile); os.IsNotExist(err) {
|
|
return "", nil
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "docker", "compose", "-f", composeFile, "up", "-d")
|
|
cmd.Dir = m.Root
|
|
cmd.Env = append(os.Environ(),
|
|
"TELESRV_DOCKER_PROJECT="+st.DockerProject,
|
|
"TELESRV_DOCKER_PREFIX="+st.DockerPrefix,
|
|
)
|
|
hideWindow(cmd)
|
|
out, err := cmd.CombinedOutput()
|
|
log := "$ docker compose up -d\n" + string(out)
|
|
if err != nil {
|
|
return log, fmt.Errorf("docker compose up failed: %w", err)
|
|
}
|
|
|
|
deadline := time.Now().Add(postgresWaitTimeout)
|
|
for {
|
|
pgCmd := exec.CommandContext(ctx, "docker", "exec", st.DockerPrefix+"-postgres", "pg_isready", "-U", "telesrv", "-d", "telesrv")
|
|
hideWindow(pgCmd)
|
|
if pgCmd.Run() == nil {
|
|
return log + "\nPostgreSQL ready\n", nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return log, fmt.Errorf("PostgreSQL not ready after %s", postgresWaitTimeout)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return log, ctx.Err()
|
|
case <-time.After(postgresWaitInterval):
|
|
}
|
|
}
|
|
}
|
|
|
|
// DockerService is one container's live status, as reported by
|
|
// `docker compose ps`. State is Docker's raw container state ("running",
|
|
// "exited", ...); Health is the healthcheck status ("healthy", "starting",
|
|
// "unhealthy") or "" for a container/image with no healthcheck defined --
|
|
// all three services in deploy/docker-compose.yml (postgres/redis/minio)
|
|
// declare one, so "" in practice means Docker hasn't reported yet.
|
|
type DockerService struct {
|
|
Name string `json:"name"` // compose service name, e.g. "postgres"
|
|
State string `json:"state"`
|
|
Health string `json:"health"`
|
|
}
|
|
|
|
// dockerComposePsRow mirrors the fields `docker compose ps --format json`
|
|
// emits (one JSON object per line, Compose v2's ndjson convention -- NOT a
|
|
// single JSON array).
|
|
type dockerComposePsRow struct {
|
|
Service string `json:"Service"`
|
|
State string `json:"State"`
|
|
Health string `json:"Health"`
|
|
}
|
|
|
|
// DockerStatus reports the live state of every service in
|
|
// deploy/docker-compose.yml, for the admin panel's "Services" tab. Returns
|
|
// an empty slice (not an error) when the compose file doesn't exist, same
|
|
// convention as ensureDocker.
|
|
func (m *Manager) DockerStatus(ctx context.Context) ([]DockerService, error) {
|
|
composeFile := filepath.Join(m.Root, "deploy", "docker-compose.yml")
|
|
if _, err := os.Stat(composeFile); os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
st := m.loadState()
|
|
cmd := exec.CommandContext(ctx, "docker", "compose", "-f", composeFile, "ps", "--all", "--format", "json")
|
|
cmd.Dir = m.Root
|
|
cmd.Env = append(os.Environ(),
|
|
"TELESRV_DOCKER_PROJECT="+st.DockerProject,
|
|
"TELESRV_DOCKER_PREFIX="+st.DockerPrefix,
|
|
)
|
|
hideWindow(cmd)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("docker compose ps failed: %w", err)
|
|
}
|
|
var services []DockerService
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var row dockerComposePsRow
|
|
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
|
continue
|
|
}
|
|
services = append(services, DockerService{Name: row.Service, State: row.State, Health: row.Health})
|
|
}
|
|
return services, nil
|
|
}
|
|
|
|
// CheckUpdates fetches from the remote and reports how many commits the
|
|
// local branch is behind its upstream, WITHOUT pulling or building anything
|
|
// -- the Services tab's "Check updates" button uses this to decide whether
|
|
// to offer a real Update (GitPull + rebuild + restart) or tell the operator
|
|
// they're already current.
|
|
func (m *Manager) CheckUpdates(ctx context.Context) (int, error) {
|
|
fetchCmd := exec.CommandContext(ctx, "git", "fetch")
|
|
fetchCmd.Dir = m.Root
|
|
hideWindow(fetchCmd)
|
|
if out, err := fetchCmd.CombinedOutput(); err != nil {
|
|
return 0, fmt.Errorf("git fetch failed: %w: %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
countCmd := exec.CommandContext(ctx, "git", "rev-list", "--count", "HEAD..@{upstream}")
|
|
countCmd.Dir = m.Root
|
|
hideWindow(countCmd)
|
|
out, err := countCmd.Output()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("git rev-list failed: %w", err)
|
|
}
|
|
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("parse commit count: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// --- build steps ---------------------------------------------------------
|
|
|
|
// GitPull runs `git pull --ff-only`, deliberately never a real merge -- see
|
|
// the identical reasoning in server-panel.py's git_pull().
|
|
func (m *Manager) GitPull(ctx context.Context) (string, error) {
|
|
cmd := exec.CommandContext(ctx, "git", "pull", "--ff-only")
|
|
cmd.Dir = m.Root
|
|
hideWindow(cmd)
|
|
out, err := cmd.CombinedOutput()
|
|
log := "$ git pull --ff-only\n" + string(out)
|
|
return log, err
|
|
}
|
|
|
|
// buildServer builds only bin/owpengram-server.
|
|
func (m *Manager) buildServer(ctx context.Context) (string, error) {
|
|
return m.goBuild(ctx, m.serverExe(), "./cmd/telesrv")
|
|
}
|
|
|
|
// buildBoth builds bin/owpengram-server and bin/owpengram-admin-panel, like
|
|
// server-panel.py's build(). Used by Update, which leaves a fresh admin
|
|
// binary on disk even though it doesn't self-restart into it (see package
|
|
// doc).
|
|
func (m *Manager) buildBoth(ctx context.Context) (string, error) {
|
|
serverLog, err := m.buildServer(ctx)
|
|
if err != nil {
|
|
return serverLog, err
|
|
}
|
|
adminLog, err := m.goBuild(ctx, m.adminExe(), "./cmd/telesrv-admin")
|
|
return serverLog + "\n" + adminLog, err
|
|
}
|
|
|
|
func (m *Manager) goBuild(ctx context.Context, outPath, pkg string) (string, error) {
|
|
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
|
return "", fmt.Errorf("mkdir bin: %w", err)
|
|
}
|
|
cmd := exec.CommandContext(ctx, "go", "build", "-o", outPath, pkg)
|
|
cmd.Dir = m.Root
|
|
hideWindow(cmd)
|
|
out, err := cmd.CombinedOutput()
|
|
return fmt.Sprintf("$ go build -o %s %s\n%s", filepath.Base(outPath), pkg, string(out)), err
|
|
}
|
|
|
|
// --- high-level actions ----------------------------------------------------
|
|
|
|
// Restart rebuilds BOTH bin/owpengram-server and bin/owpengram-admin-panel
|
|
// from the current working tree (no git pull -- see Update for that) and
|
|
// relaunches owpengram-server, which then bounces the admin panel onto its
|
|
// freshly built binary. Never self-restarts the admin process handling this
|
|
// request directly -- see HandlePendingAdminRestart's doc comment for why
|
|
// that handoff happens from the newly launched server instead. Returns a
|
|
// combined build/relaunch log for the admin UI.
|
|
func (m *Manager) Restart(ctx context.Context) (string, error) {
|
|
st := m.loadState()
|
|
if pidAlive(st.ServerPID) {
|
|
killPID(st.ServerPID)
|
|
}
|
|
dockerLog, err := m.ensureDocker(ctx, st)
|
|
if err != nil {
|
|
return dockerLog, err
|
|
}
|
|
buildLog, err := m.buildBoth(ctx)
|
|
fullLog := dockerLog + "\n" + buildLog
|
|
if err != nil {
|
|
return fullLog, fmt.Errorf("build failed: %w", err)
|
|
}
|
|
pid, err := m.launch(m.serverExe(), m.serverLog())
|
|
if err != nil {
|
|
return fullLog, fmt.Errorf("launch failed: %w", err)
|
|
}
|
|
st.ServerPID = pid
|
|
// Ask the process we just launched to bounce the admin panel for us
|
|
// once it's up -- see HandlePendingAdminRestart's doc comment for why
|
|
// that's the safe side of this handoff to do it from.
|
|
st.PendingAdminRestart = true
|
|
if err := m.saveState(st); err != nil {
|
|
return fullLog, fmt.Errorf("save state: %w", err)
|
|
}
|
|
return fullLog + fmt.Sprintf("\nowpengram-server relaunched, pid=%d. Admin panel will restart shortly onto its freshly built binary.\n", pid), nil
|
|
}
|
|
|
|
// Update is Restart plus a `git pull --ff-only` first, so a fresh checkout
|
|
// gets built instead of whatever's already on disk.
|
|
func (m *Manager) Update(ctx context.Context) (string, error) {
|
|
pullLog, err := m.GitPull(ctx)
|
|
if err != nil {
|
|
return pullLog, fmt.Errorf("git pull failed: %w", err)
|
|
}
|
|
st := m.loadState()
|
|
if pidAlive(st.ServerPID) {
|
|
killPID(st.ServerPID)
|
|
}
|
|
dockerLog, err := m.ensureDocker(ctx, st)
|
|
fullLog := pullLog + "\n" + dockerLog
|
|
if err != nil {
|
|
return fullLog, err
|
|
}
|
|
buildLog, err := m.buildBoth(ctx)
|
|
fullLog = fullLog + "\n" + buildLog
|
|
if err != nil {
|
|
return fullLog, fmt.Errorf("build failed: %w", err)
|
|
}
|
|
pid, err := m.launch(m.serverExe(), m.serverLog())
|
|
if err != nil {
|
|
return fullLog, fmt.Errorf("launch failed: %w", err)
|
|
}
|
|
st.ServerPID = pid
|
|
st.PendingAdminRestart = true
|
|
if err := m.saveState(st); err != nil {
|
|
return fullLog, fmt.Errorf("save state: %w", err)
|
|
}
|
|
return fullLog + fmt.Sprintf("\nowpengram-server relaunched, pid=%d. Admin panel will restart shortly onto its freshly built binary.\n", pid), nil
|
|
}
|
|
|
|
// HandlePendingAdminRestart is called once by owpengram-server itself, right
|
|
// after it confirms it's up and serving (see cmd/telesrv/main.go's
|
|
// OnServing hook) -- never by the admin panel on itself. That ordering is
|
|
// the whole point: by the time this runs, the *new* owpengram-server
|
|
// process already exists and is unrelated to whatever admin panel process
|
|
// is currently running, so killing the old admin PID and launching a new
|
|
// one here carries none of the risk self-restarting mid-HTTP-request would
|
|
// (see the package doc). A no-op when no restart was requested.
|
|
func (m *Manager) HandlePendingAdminRestart(ctx context.Context) (bool, error) {
|
|
st := m.loadState()
|
|
if !st.PendingAdminRestart {
|
|
return false, nil
|
|
}
|
|
if pidAlive(st.AdminPID) {
|
|
killPID(st.AdminPID)
|
|
}
|
|
pid, err := m.launch(m.adminExe(), m.adminLog())
|
|
if err != nil {
|
|
return false, fmt.Errorf("launch admin panel: %w", err)
|
|
}
|
|
st.AdminPID = pid
|
|
st.PendingAdminRestart = false
|
|
if err := m.saveState(st); err != nil {
|
|
return true, fmt.Errorf("save state: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// --- .env.example / .env editing -------------------------------------------
|
|
|
|
var (
|
|
activeFieldRe = regexp.MustCompile(`^(TELESRV_[A-Z0-9_]+)=(.*)$`)
|
|
commentedFieldRe = regexp.MustCompile(`^#\s*(TELESRV_[A-Z0-9_]+)=(.*)$`)
|
|
sensitiveKeyRe = regexp.MustCompile(`(PASSWORD|SECRET|_TOKEN|API_KEY)`)
|
|
// sensitiveKeyExceptRe excludes names that trip sensitiveKeyRe on the
|
|
// word "SECRET" while meaning Telegram's secret-chat feature, not a
|
|
// credential (e.g. TELESRV_SECRET_CHAT_DELETE_FILE_AFTER_DOWNLOAD) --
|
|
// there's nothing to mask there, it's a plain boolean toggle.
|
|
sensitiveKeyExceptRe = regexp.MustCompile(`SECRET_CHAT`)
|
|
groupHeaderRe = regexp.MustCompile(`^##\s*(.+?)\s*--\s*(.+)$`)
|
|
sectionBreakRe = regexp.MustCompile(`^#\s*={10,}\s*$`)
|
|
)
|
|
|
|
type EnvField struct {
|
|
Key string `json:"key"`
|
|
DefaultValue string `json:"default_value"`
|
|
Description string `json:"description"`
|
|
EnabledByDefault bool `json:"enabled_by_default"`
|
|
Sensitive bool `json:"sensitive"`
|
|
// Value is the field's current effective value: from .env when set,
|
|
// otherwise DefaultValue (only when EnabledByDefault), else empty --
|
|
// exactly current_env_values()'s semantics in server-panel.py.
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
type EnvGroup struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Fields []EnvField `json:"fields"`
|
|
}
|
|
|
|
// ReadEnvGroups parses .env.example into the same panel-visible groups
|
|
// server-panel.py's parse_env_template() does (identical header/format
|
|
// rules -- see that function's docstring), then fills in each field's
|
|
// current effective value from .env.
|
|
func (m *Manager) ReadEnvGroups() ([]EnvGroup, error) {
|
|
tmplPath := filepath.Join(m.Root, ".env.example")
|
|
tmplData, err := os.ReadFile(tmplPath)
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read .env.example: %w", err)
|
|
}
|
|
envValues, err := m.readEnvFile()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var groups []EnvGroup
|
|
var current *EnvGroup
|
|
var pending []string
|
|
inCommentRun := false
|
|
seen := map[string]bool{}
|
|
|
|
appendField := func(key, defaultValue, description string, enabledByDefault bool) {
|
|
if current == nil || seen[key] {
|
|
return
|
|
}
|
|
seen[key] = true
|
|
value, has := envValues[key]
|
|
if !has {
|
|
if enabledByDefault {
|
|
value = defaultValue
|
|
} else {
|
|
value = ""
|
|
}
|
|
}
|
|
current.Fields = append(current.Fields, EnvField{
|
|
Key: key,
|
|
DefaultValue: defaultValue,
|
|
Description: description,
|
|
EnabledByDefault: enabledByDefault,
|
|
Sensitive: sensitiveKeyRe.MatchString(key) && !sensitiveKeyExceptRe.MatchString(key),
|
|
Value: value,
|
|
})
|
|
}
|
|
|
|
for _, raw := range strings.Split(string(tmplData), "\n") {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "" {
|
|
pending = nil
|
|
inCommentRun = false
|
|
continue
|
|
}
|
|
if h := groupHeaderRe.FindStringSubmatch(line); h != nil {
|
|
groups = append(groups, EnvGroup{Title: strings.TrimSpace(h[1]), Description: strings.TrimSpace(h[2])})
|
|
current = &groups[len(groups)-1]
|
|
pending = nil
|
|
inCommentRun = false
|
|
continue
|
|
}
|
|
if sectionBreakRe.MatchString(line) {
|
|
current = nil
|
|
pending = nil
|
|
inCommentRun = false
|
|
continue
|
|
}
|
|
if a := activeFieldRe.FindStringSubmatch(line); a != nil {
|
|
appendField(a[1], a[2], strings.Join(pending, " "), true)
|
|
inCommentRun = false
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "#") {
|
|
if c := commentedFieldRe.FindStringSubmatch(line); c != nil {
|
|
appendField(c[1], c[2], strings.Join(pending, " "), false)
|
|
inCommentRun = false
|
|
continue
|
|
}
|
|
text := strings.TrimSpace(strings.TrimLeft(line, "#"))
|
|
if inCommentRun {
|
|
pending = append(pending, text)
|
|
} else {
|
|
pending = []string{text}
|
|
}
|
|
inCommentRun = true
|
|
continue
|
|
}
|
|
inCommentRun = false
|
|
}
|
|
|
|
out := groups[:0]
|
|
for _, g := range groups {
|
|
if len(g.Fields) > 0 {
|
|
out = append(out, g)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *Manager) readEnvFile() (map[string]string, error) {
|
|
values := map[string]string{}
|
|
data, err := os.ReadFile(filepath.Join(m.Root, ".env"))
|
|
if os.IsNotExist(err) {
|
|
return values, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read .env: %w", err)
|
|
}
|
|
for _, raw := range strings.Split(string(data), "\n") {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
idx := strings.IndexByte(line, '=')
|
|
if idx <= 0 {
|
|
continue
|
|
}
|
|
values[line[:idx]] = strings.TrimSpace(line[idx+1:])
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
// WriteEnvValues rewrites .env from .env.example's exact text, substituting
|
|
// each known key's value in place -- see save_env()'s docstring in
|
|
// server-panel.py for why this (not a fresh key=value dump) is what
|
|
// preserves comments/layout. Only keys present in values are set to a new
|
|
// value; every other key keeps whatever is already in the current .env
|
|
// (falling back to the template's own default only for a key .env never
|
|
// set) -- previously this fell straight back to the template default for
|
|
// any key not in this particular save's payload, silently wiping out every
|
|
// other customized setting (e.g. TELESRV_ADMIN_UI_PASSWORD) on every save
|
|
// that only touches one section's keys. A template-commented optional field
|
|
// is uncommented when given a non-empty value and left as-is when given an
|
|
// empty one.
|
|
func (m *Manager) WriteEnvValues(values map[string]string) error {
|
|
tmplPath := filepath.Join(m.Root, ".env.example")
|
|
tmplData, err := os.ReadFile(tmplPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read .env.example: %w", err)
|
|
}
|
|
existing, err := m.readEnvFile()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
lines := strings.Split(string(tmplData), "\n")
|
|
// Split() on a trailing "\n" leaves one empty trailing element; drop it
|
|
// so the join below doesn't add a spurious blank line before the final
|
|
// newline this function appends anyway.
|
|
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
out := make([]string, 0, len(lines))
|
|
seen := map[string]bool{}
|
|
for _, raw := range lines {
|
|
line := strings.TrimSpace(raw)
|
|
if a := activeFieldRe.FindStringSubmatch(line); a != nil && !seen[a[1]] {
|
|
if v, ok := values[a[1]]; ok {
|
|
seen[a[1]] = true
|
|
out = append(out, a[1]+"="+v)
|
|
continue
|
|
}
|
|
if v, ok := existing[a[1]]; ok {
|
|
seen[a[1]] = true
|
|
out = append(out, a[1]+"="+v)
|
|
continue
|
|
}
|
|
}
|
|
if c := commentedFieldRe.FindStringSubmatch(line); c != nil && !seen[c[1]] {
|
|
if v, ok := values[c[1]]; ok {
|
|
seen[c[1]] = true
|
|
if v != "" {
|
|
out = append(out, c[1]+"="+v)
|
|
} else {
|
|
out = append(out, raw)
|
|
}
|
|
continue
|
|
}
|
|
// A previously-enabled optional field shows up in the current
|
|
// .env as an active line even though the template still has it
|
|
// commented out -- keep it enabled with its existing value.
|
|
if v, ok := existing[c[1]]; ok {
|
|
seen[c[1]] = true
|
|
out = append(out, c[1]+"="+v)
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, raw)
|
|
}
|
|
return os.WriteFile(filepath.Join(m.Root, ".env"), []byte(strings.Join(out, "\n")+"\n"), 0o644)
|
|
}
|