231 lines
7.3 KiB
Go
231 lines
7.3 KiB
Go
// Command dogama runs the DoGaMa main application.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"syscall"
|
|
"time"
|
|
|
|
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentclient"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/web"
|
|
)
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
if err := run(logger); err != nil {
|
|
logger.Error("application stopped", "event", "application.failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(logger *slog.Logger) error {
|
|
listenAddress := environment("DOGAMA_LISTEN_ADDRESS", ":8080")
|
|
databasePath := environment("DOGAMA_DATABASE_PATH", "dogama.db")
|
|
serversRoot := environment("DOGAMA_SERVERS_ROOT", "/srv/game-servers")
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
db, err := sqlite.Open(ctx, databasePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
repository := sqlite.NewRepository(db)
|
|
if err := repository.Sync(ctx, snapshots); err != nil {
|
|
return err
|
|
}
|
|
logger.Info("local catalog synchronized", "event", "catalog.synchronized", "template_count", len(snapshots))
|
|
var handler http.Handler
|
|
var lifecycle *instance.LifecycleService
|
|
var backupService *backup.Service
|
|
auditService := audit.New(db)
|
|
var notificationService *notification.Service
|
|
if keyFile := os.Getenv("DOGAMA_MASTER_KEY_FILE"); keyFile != "" {
|
|
key, keyErr := os.ReadFile(keyFile)
|
|
if keyErr != nil {
|
|
return errors.New("read encryption key file")
|
|
}
|
|
key = bytes.TrimSpace(key)
|
|
notificationService, keyErr = notification.New(db, key)
|
|
if keyErr != nil {
|
|
return keyErr
|
|
}
|
|
} else {
|
|
logger.Warn("notification channel configuration disabled: DOGAMA_MASTER_KEY_FILE is unset", "event", "notification.disabled")
|
|
}
|
|
importService, err := importexport.New(repository, environment("DOGAMA_IMPORTS_ROOT", "/var/lib/dogama/imports/staging"), serversRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
agentURL, tokenFile := os.Getenv("DOGAMA_AGENT_URL"), os.Getenv("DOGAMA_AGENT_TOKEN_FILE")
|
|
if agentURL == "" && tokenFile == "" {
|
|
logger.Warn("instance lifecycle disabled", "event", "lifecycle.disabled")
|
|
} else {
|
|
if agentURL == "" || tokenFile == "" {
|
|
return errors.New("DOGAMA_AGENT_URL and DOGAMA_AGENT_TOKEN_FILE must be configured together")
|
|
}
|
|
secret, readErr := os.ReadFile(tokenFile)
|
|
if readErr != nil {
|
|
return errors.New("read agent token file")
|
|
}
|
|
secret = bytes.TrimSuffix(bytes.TrimSuffix(secret, []byte("\n")), []byte("\r"))
|
|
agent, clientErr := agentclient.New(agentURL, secret, &http.Client{Timeout: 15 * time.Minute})
|
|
if clientErr != nil {
|
|
return clientErr
|
|
}
|
|
lifecycle = instance.NewLifecycleService(repository, agent)
|
|
backupService, err = backup.New(repository, agent, serversRoot, environment("DOGAMA_BACKUPS_ROOT", "/srv/game-backups"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reconcileCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
if recoverErr := lifecycle.RecoverInterruptedOperations(reconcileCtx); recoverErr != nil {
|
|
cancel()
|
|
return recoverErr
|
|
}
|
|
if reconcileErr := lifecycle.ReconcileAll(reconcileCtx); reconcileErr != nil {
|
|
logger.Warn("instance reconciliation incomplete", "event", "lifecycle.reconcile.failed")
|
|
}
|
|
cancel()
|
|
}
|
|
handler, err = web.NewHandlerComplete(auth.New(db), repository, lifecycle, backupService, importService, auditService, notificationService, logger)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if lifecycle != nil {
|
|
go reconcileInstances(ctx, lifecycle, logger)
|
|
}
|
|
if backupService != nil {
|
|
go runBackupScheduler(ctx, backupService, logger)
|
|
}
|
|
go runImportCleanup(ctx, importService, logger)
|
|
go runObservabilityScheduler(ctx, auditService, notificationService, logger)
|
|
server := &http.Server{
|
|
Addr: listenAddress,
|
|
Handler: handler,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
ReadTimeout: 15 * time.Second,
|
|
WriteTimeout: 15 * time.Minute,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
logger.Info("application listening", "event", "application.started", "address", listenAddress)
|
|
errCh <- server.ListenAndServe()
|
|
}()
|
|
select {
|
|
case err := <-errCh:
|
|
if errors.Is(err, http.ErrServerClosed) {
|
|
return nil
|
|
}
|
|
return err
|
|
case <-ctx.Done():
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
return server.Shutdown(shutdownCtx)
|
|
}
|
|
}
|
|
|
|
func runObservabilityScheduler(ctx context.Context, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) {
|
|
ticker := time.NewTicker(time.Minute)
|
|
defer ticker.Stop()
|
|
lastPurgeDay := ""
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case now := <-ticker.C:
|
|
if notificationService != nil {
|
|
if err := notificationService.RunDue(ctx); err != nil {
|
|
logger.Warn("notification delivery run incomplete", "event", "notification.scheduler.failed")
|
|
}
|
|
}
|
|
day := now.UTC().Format("2006-01-02")
|
|
if day != lastPurgeDay {
|
|
if deleted, err := auditService.RunRetention(ctx); err != nil {
|
|
logger.Warn("audit retention incomplete", "event", "audit.retention.failed")
|
|
} else if deleted > 0 {
|
|
_ = auditService.Record(ctx, audit.Event{ActorLabel: "system", Action: "audit.retention.purge", Outcome: "allowed", Summary: map[string]string{"deleted_count": strconv.FormatInt(deleted, 10)}})
|
|
}
|
|
lastPurgeDay = day
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func runImportCleanup(ctx context.Context, service *importexport.Service, logger *slog.Logger) {
|
|
ticker := time.NewTicker(time.Hour)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := service.CleanupExpired(ctx); err != nil {
|
|
logger.Warn("expired import cleanup incomplete", "event", "import.cleanup.failed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func runBackupScheduler(ctx context.Context, service *backup.Service, logger *slog.Logger) {
|
|
ticker := time.NewTicker(time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
runCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
|
if err := service.RunDue(runCtx); err != nil {
|
|
logger.Warn("scheduled backup run incomplete", "event", "backup.scheduler.failed")
|
|
}
|
|
cancel()
|
|
}
|
|
}
|
|
}
|
|
|
|
func reconcileInstances(ctx context.Context, lifecycle *instance.LifecycleService, logger *slog.Logger) {
|
|
ticker := time.NewTicker(time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
reconcileCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
if err := lifecycle.ReconcileAll(reconcileCtx); err != nil {
|
|
logger.Warn("periodic instance reconciliation incomplete", "event", "lifecycle.reconcile.failed")
|
|
}
|
|
cancel()
|
|
}
|
|
}
|
|
}
|
|
|
|
func environment(name, fallback string) string {
|
|
if value := os.Getenv(name); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|