Squash integration of the reviewed Go core, frontend audit fixes, release tooling, and deployment hygiene. CI passed all Server/Client jobs.
169 lines
5.4 KiB
Go
169 lines
5.4 KiB
Go
// Command screener-server is the Hosted Screener entry point: it loads the
|
|
// environment, serves the embedded Browser UI together with the signaling and
|
|
// room API, and stops on SIGINT or SIGTERM. It replaces src/server/index.ts.
|
|
//
|
|
// Two maintenance modes exit without serving: --check-config validates the
|
|
// environment (the release wrapper runs it as the service user before cutover)
|
|
// and --check-release compares the deployed revision with the latest published
|
|
// release (see release.go).
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/TNTcraftHIM/Screener/internal/server/app"
|
|
"github.com/TNTcraftHIM/Screener/internal/server/config"
|
|
"github.com/TNTcraftHIM/Screener/internal/server/webassets"
|
|
)
|
|
|
|
// shutdownTimeout bounds the ordered stop (signaling, listener, room store).
|
|
// systemd's tracked TimeoutStopSec is 20s, so the process must be gone first.
|
|
const shutdownTimeout = 15 * time.Second
|
|
|
|
// environmentFile is read from the working directory, which systemd pins to the
|
|
// release directory. Hosted keeps its real values in the service secret store.
|
|
const environmentFile = ".env"
|
|
|
|
// BuildRevision is the full Git revision the release packager links in with
|
|
// -ldflags. It matches the REVISION file shipped beside the binary and is
|
|
// reported at startup so one journal line identifies the running release.
|
|
var BuildRevision = "development"
|
|
|
|
func main() {
|
|
checkConfig := flag.Bool("check-config", false, "validate the environment and exit")
|
|
checkRelease := flag.Bool("check-release", false,
|
|
"report whether a newer published release exists and exit")
|
|
currentFile := flag.String("current-file", "",
|
|
"path to the deployed REVISION file read by --check-release")
|
|
apiURL := flag.String("api-url", defaultReleaseAPIURL,
|
|
"release metadata endpoint used by --check-release")
|
|
flag.Parse()
|
|
|
|
if err := loadEnvironmentFile(environmentFile); err != nil {
|
|
fail(err)
|
|
}
|
|
switch {
|
|
case *checkRelease:
|
|
os.Exit(checkDeployedRelease(context.Background(), *currentFile, *apiURL, os.Stdout))
|
|
case *checkConfig:
|
|
if _, err := config.Load(environment()); err != nil {
|
|
fail(err)
|
|
}
|
|
fmt.Println("config=ok")
|
|
default:
|
|
if err := serve(); err != nil {
|
|
fail(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func fail(err error) {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// serve runs the application until the first SIGINT or SIGTERM, then stops it
|
|
// with Close rather than End: Hosted rooms are leased in the database and must
|
|
// survive a restart (DECISIONS D6).
|
|
func serve() error {
|
|
configuration, err := config.Load(environment())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
server, err := app.New(app.Options{
|
|
Config: configuration,
|
|
Assets: webassets.FS(),
|
|
Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Startup does not take the stop signal: index.ts registered its SIGINT and
|
|
// SIGTERM handlers only after `await server.listen()` resolved, so a stop
|
|
// during the LiveKit reconciliation ended the process by the signal's
|
|
// default disposition. Cancelling startup instead would abort it and exit
|
|
// non-zero, which systemd records as a failed unit. Each LiveKit call keeps
|
|
// its own 5 s bound (D1).
|
|
port, err := server.Listen(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("Screener %s is listening on %s:%d; public URL %s\n",
|
|
BuildRevision, configuration.ListenHost, port, config.Origin(configuration.PublicBaseURL))
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
<-ctx.Done()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
|
defer cancel()
|
|
return server.Close(shutdownCtx)
|
|
}
|
|
|
|
// environment is the process environment in the form config.Load expects.
|
|
func environment() map[string]string {
|
|
values := make(map[string]string)
|
|
for _, entry := range os.Environ() {
|
|
if name, value, found := strings.Cut(entry, "="); found {
|
|
values[name] = value
|
|
}
|
|
}
|
|
return values
|
|
}
|
|
|
|
// loadEnvironmentFile ports the src/server/index.ts loadEnvFile() call: an
|
|
// absent file is the ordinary case and every other read failure stops startup.
|
|
// Like Node, a name the process already carries is never overwritten, so a
|
|
// systemd EnvironmentFile still wins over a stale file in the release
|
|
// directory. The accepted syntax is DECISIONS D8: KEY=value lines, whole-line
|
|
// '#' comments, an optional 'export ' prefix, and a single- or double-quoted
|
|
// value. Quoted values keep their content verbatim; nothing is expanded.
|
|
func loadEnvironmentFile(name string) error {
|
|
content, err := os.ReadFile(name)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, line := range strings.Split(string(content), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
key, value, assigned := strings.Cut(line, "=")
|
|
key = strings.TrimSpace(key)
|
|
if !assigned || key == "" {
|
|
continue
|
|
}
|
|
if _, present := os.LookupEnv(key); present {
|
|
continue
|
|
}
|
|
if err := os.Setenv(key, unquote(strings.TrimSpace(value))); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unquote removes one matching pair of surrounding single or double quotes.
|
|
func unquote(value string) string {
|
|
if len(value) < 2 || value[0] != value[len(value)-1] {
|
|
return value
|
|
}
|
|
if value[0] != '"' && value[0] != '\'' {
|
|
return value
|
|
}
|
|
return value[1 : len(value)-1]
|
|
}
|