Merge branch 'review/quality-memory-safety' into main
CI / build (push) Successful in 20m38s

This commit is contained in:
MrFastwind
2026-07-25 03:04:06 +02:00
18 changed files with 638 additions and 88 deletions
+7 -2
View File
@@ -12,12 +12,17 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
cache: true
cache-dependency-path: backend/go.sum
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- run: cd frontend && npm ci && npm run build
- run: mkdir -p backend/embed/dist && cp -r frontend/dist/. backend/embed/dist/
- run: cd backend && go build ./...
- run: cd backend && go test ./...
- run: cd backend && go test -race ./...
- run: cd frontend && npm run lint
- run: cd frontend && npm test
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- uses: actions/setup-node@v4
with:
node-version: "22"
+8 -7
View File
@@ -1,10 +1,3 @@
FROM golang:1.25-bookworm AS backend
WORKDIR /src
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
RUN CGO_ENABLED=0 go build -o /server ./cmd/server/
FROM node:22-alpine AS frontend
WORKDIR /src
COPY frontend/package.json frontend/package-lock.json ./
@@ -12,6 +5,14 @@ RUN npm ci
COPY frontend/ .
RUN npm run build
FROM golang:1.24-bookworm AS backend
WORKDIR /src
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY --from=frontend /src/dist /src/embed/dist
COPY backend/ .
RUN CGO_ENABLED=0 go build -o /server ./cmd/server/
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
+1 -1
View File
@@ -42,7 +42,7 @@ clean:
rm -rf frontend/dist backend/embed/dist bin
test-backend:
cd backend && go test ./...
cd backend && go test -race ./...
test-frontend:
cd frontend && npm test
+17 -10
View File
@@ -24,18 +24,22 @@ The web UI is served on `http://localhost:8080`.
### Manual
```bash
# Backend
cd backend
go build -o arma3-web-server ./cmd/server
./arma3-web-server
**Prerequisites:**
- [Go](https://go.dev/dl/) >= 1.24
- [Node.js](https://nodejs.org/) >= 22 with npm
- GNU Make (optional, for `make` targets)
# Frontend (development)
cd frontend
npm install
npm run dev
```bash
# Build for production (frontend + backend in one binary)
make build
./backend/arma3-web-server
# Or develop with hot reload (uses test stubs, no real SteamCMD needed)
make dev
```
The web UI is served on `http://localhost:8080`.
## Configuration
All paths are configurable via environment variables:
@@ -51,6 +55,9 @@ All paths are configurable via environment variables:
| `SERVER_BINARY` | `arma3server_x64` | Server binary filename (overrides platform default) |
| `SERVER_PARAMS` | `-server -world=empty ...` | Override server launch parameters |
| `STEAMCMD_PATH` | `steamcmd` | Path to steamcmd binary |
| `AUTH_TOKEN` | _(empty)_ | Bearer token for API/WS auth. When set, all requests must include `Authorization: Bearer <token>`. When empty, no auth required. |
| `GIN_MODE` | `debug` | Gin framework mode. Use `release` for production (set automatically in Docker). |
| `SERVERS_DIR` | `./serverfiles` | Docker Compose only: host directory mounted as `/servers` in the container. |
## Automation
@@ -66,7 +73,7 @@ Configured in the **Automation** section of the Settings UI:
| **Auto-update mods on startup** | Downloads workshop updates for every enabled mod in the active modlist when the web service starts. |
| **Auto-start server on startup** | Restarts the game server if it was running when the web service last stopped. Useful for recovery after host backup cycles or container restarts. |
All startup tasks run asynchronously — the web UI is available immediately.
All startup tasks run asynchronously — the web UI is available immediately. Server and mod updates run in parallel; auto-start waits for both to complete before launching the game server.
### Scheduled updates
+15
View File
@@ -10,6 +10,7 @@ import (
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -91,8 +92,12 @@ func main() {
if err != nil {
log.Printf("startup: load settings: %v", err)
} else {
var updatesWg sync.WaitGroup
if s.AutoUpdateOnStartup && s.SteamUser != "" {
updatesWg.Add(1)
go func() {
defer updatesWg.Done()
log.Print("startup: auto-updating gameserver")
if err := steamcmd.UpdateGame(s.SteamBranch, s.SteamUser); err != nil {
log.Printf("startup: auto-update game failed: %v", err)
@@ -101,7 +106,9 @@ func main() {
}
if s.AutoUpdateModsOnStartup && s.ActiveModlist != "" {
updatesWg.Add(1)
go func() {
defer updatesWg.Done()
log.Print("startup: auto-updating mods")
ml, err := modlistMgr.Get(s.ActiveModlist)
if err != nil {
@@ -124,6 +131,7 @@ func main() {
if s.AutoStartOnStartup && s.WasRunning {
go func() {
updatesWg.Wait()
log.Print("startup: auto-restarting server (was running before)")
if err := process.Start(); err != nil {
log.Printf("startup: auto-start failed: %v", err)
@@ -152,6 +160,13 @@ func main() {
log.Print("shutting down server...")
scheduler.Stop()
if process.IsRunning() {
log.Print("stopping game server...")
if err := process.Stop(); err != nil {
log.Printf("stop game server: %v", err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
+17 -1
View File
@@ -195,7 +195,13 @@ func (h *Handler) StreamRPTLogs(c *gin.Context) {
if _, err := f.Seek(currentOffset, io.SeekStart); err != nil {
return nil
}
buf := make([]byte, fi.Size()-currentOffset)
remaining := fi.Size() - currentOffset
const maxBuf = 64 * 1024
bufSize := remaining
if bufSize > maxBuf {
bufSize = maxBuf
}
buf := make([]byte, bufSize)
n, _ := io.ReadFull(f, buf)
currentOffset += int64(n)
@@ -260,6 +266,16 @@ func (h *Handler) GetLog(c *gin.Context) {
}
}
fi, err := os.Stat(path)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
const maxLogSize = 10 * 1024 * 1024
if fi.Size() > maxLogSize {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "log file too large, use live streaming"})
return
}
data, err := os.ReadFile(path)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+9 -1
View File
@@ -49,6 +49,14 @@ func (h *Handler) ListMods(c *gin.Context) {
c.JSON(http.StatusOK, all)
}
func isUnderDir(path, dir string) bool {
rel, err := filepath.Rel(dir, path)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func (h *Handler) DeleteMod(c *gin.Context) {
var input deleteModInput
if err := c.ShouldBindJSON(&input); err != nil {
@@ -62,7 +70,7 @@ func (h *Handler) DeleteMod(c *gin.Context) {
return
}
if !strings.HasPrefix(absPath, h.serverfileDir) && !strings.HasPrefix(absPath, h.modsDir) {
if !isUnderDir(absPath, h.serverfileDir) && !isUnderDir(absPath, h.modsDir) {
c.JSON(http.StatusForbidden, gin.H{"error": "path outside allowed directories"})
return
}
+43 -4
View File
@@ -1,6 +1,10 @@
package api
import (
"net/http"
"os"
"strings"
"arma3-web-server/internal/services"
"github.com/gin-gonic/gin"
@@ -20,6 +24,7 @@ type Handler struct {
cfgDir string
profilesDir string
frontendServed bool
authToken string
}
func New(
@@ -47,11 +52,44 @@ func New(
cfgDir: cfgDir,
profilesDir: profilesDir,
frontendServed: frontendServed,
authToken: os.Getenv("AUTH_TOKEN"),
}
}
func (h *Handler) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if h.authToken == "" {
c.Next()
return
}
auth := c.GetHeader("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or invalid authorization header"})
return
}
token := strings.TrimPrefix(auth, "Bearer ")
if !secureCompare(token, h.authToken) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Next()
}
}
func secureCompare(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
if a[i] != b[i] {
return false
}
}
return true
}
func (h *Handler) SetupRoutes(r *gin.Engine) {
api := r.Group("/api")
api := r.Group("/api", h.authMiddleware())
{
api.GET("/server/settings", h.GetSettings)
api.PUT("/server/settings", h.UpdateSettings)
@@ -93,7 +131,8 @@ func (h *Handler) SetupRoutes(r *gin.Engine) {
api.POST("/mods/cleanup", h.CleanupMods)
}
r.GET("/ws/server/logs", h.StreamLogs)
r.GET("/ws/steamcmd/logs", h.StreamSteamCMDLogs)
r.GET("/ws/server/rpt", h.StreamRPTLogs)
ws := r.Group("/ws", h.authMiddleware())
ws.GET("/server/logs", h.StreamLogs)
ws.GET("/steamcmd/logs", h.StreamSteamCMDLogs)
ws.GET("/server/rpt", h.StreamRPTLogs)
}
+76 -49
View File
@@ -1,9 +1,15 @@
package api
import (
"fmt"
"net"
"net/http"
"strings"
"arma3-web-server/internal/models"
"github.com/gin-gonic/gin"
"github.com/robfig/cron/v3"
)
type updateSettingsInput struct {
@@ -39,65 +45,86 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
return
}
s, err := h.settings.Load()
if input.IPPort != nil && *input.IPPort != "" {
if _, _, err := net.SplitHostPort(*input.IPPort); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid ip_port: must be host:port format: %v", err)})
return
}
}
if input.ServerParameters != nil && len(*input.ServerParameters) > 8192 {
c.JSON(http.StatusBadRequest, gin.H{"error": "server_parameters too long (max 8192)"})
return
}
if input.ScheduledUpdate != nil && *input.ScheduledUpdate != "" {
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
if _, err := parser.Parse(*input.ScheduledUpdate); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid cron expression: %v", err)})
return
}
}
if input.ServerParameters != nil {
for _, forbidden := range []string{"--dry-run", "--rm", "--privileged"} {
if strings.Contains(*input.ServerParameters, forbidden) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("server_parameters contains forbidden flag: %s", forbidden)})
return
}
}
}
s, err := h.settings.Update(func(s *models.ServerSettings) {
if input.IPPort != nil {
s.IPPort = *input.IPPort
}
if input.ServerParameters != nil {
s.ServerParameters = *input.ServerParameters
}
if input.SteamBranch != nil {
s.SteamBranch = *input.SteamBranch
}
if input.SteamUser != nil {
s.SteamUser = *input.SteamUser
}
if input.Platform != nil {
s.Platform = *input.Platform
}
if input.CBASettings != nil {
s.CBASettings = *input.CBASettings
}
if input.AILevelPresets != nil {
s.AILevelPresets = *input.AILevelPresets
}
if input.DifficultyPresets != nil {
s.DifficultyPresets = *input.DifficultyPresets
}
if input.ActiveConfig != nil {
s.ActiveConfig = *input.ActiveConfig
}
if input.ActiveModlist != nil {
s.ActiveModlist = *input.ActiveModlist
}
if input.AutoUpdateOnStartup != nil {
s.AutoUpdateOnStartup = *input.AutoUpdateOnStartup
}
if input.AutoStartOnStartup != nil {
s.AutoStartOnStartup = *input.AutoStartOnStartup
}
if input.AutoUpdateModsOnStartup != nil {
s.AutoUpdateModsOnStartup = *input.AutoUpdateModsOnStartup
}
if input.ScheduledUpdate != nil {
s.ScheduledUpdate = *input.ScheduledUpdate
}
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if input.IPPort != nil {
s.IPPort = *input.IPPort
}
if input.ServerParameters != nil {
s.ServerParameters = *input.ServerParameters
}
if input.SteamBranch != nil {
s.SteamBranch = *input.SteamBranch
}
if input.SteamUser != nil {
s.SteamUser = *input.SteamUser
}
if input.Platform != nil {
s.Platform = *input.Platform
}
if input.CBASettings != nil {
s.CBASettings = *input.CBASettings
}
if input.AILevelPresets != nil {
s.AILevelPresets = *input.AILevelPresets
}
if input.DifficultyPresets != nil {
s.DifficultyPresets = *input.DifficultyPresets
}
if input.ActiveConfig != nil {
s.ActiveConfig = *input.ActiveConfig
}
if input.ActiveModlist != nil {
s.ActiveModlist = *input.ActiveModlist
}
if input.AutoUpdateOnStartup != nil {
s.AutoUpdateOnStartup = *input.AutoUpdateOnStartup
}
if input.AutoStartOnStartup != nil {
s.AutoStartOnStartup = *input.AutoStartOnStartup
}
if input.AutoUpdateModsOnStartup != nil {
s.AutoUpdateModsOnStartup = *input.AutoUpdateModsOnStartup
}
if input.ScheduledUpdate != nil {
s.ScheduledUpdate = *input.ScheduledUpdate
}
if err := h.process.WriteUserconfigFiles(s); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "write userconfig: " + err.Error()})
return
}
if err := h.settings.Save(s); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if h.scheduler != nil {
h.scheduler.Refresh()
}
+2 -2
View File
@@ -59,11 +59,11 @@ func (cm *ConfigManager) Create(name, content string) error {
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("config already exists")
}
return os.WriteFile(path, []byte(content), 0644)
return writeFileAtomic(path, []byte(content))
}
func (cm *ConfigManager) Update(name, content string) error {
return os.WriteFile(cm.path(name), []byte(content), 0644)
return writeFileAtomic(cm.path(name), []byte(content))
}
func (cm *ConfigManager) Delete(name string) error {
@@ -0,0 +1,209 @@
package services
import (
"os"
"path/filepath"
"testing"
"arma3-web-server/internal/models"
)
func TestListWorkshopMods_Empty(t *testing.T) {
dir := t.TempDir()
mods := ListWorkshopMods(dir)
if len(mods) != 0 {
t.Fatalf("expected 0 mods, got %d", len(mods))
}
}
func TestListWorkshopMods_WithMods(t *testing.T) {
dir := t.TempDir()
workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410")
if err := os.MkdirAll(filepath.Join(workshopDir, "123456"), 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(workshopDir, "789012"), 0755); err != nil {
t.Fatal(err)
}
mods := ListWorkshopMods(dir)
if len(mods) != 2 {
t.Fatalf("expected 2 mods, got %d", len(mods))
}
ids := map[string]bool{}
for _, m := range mods {
ids[m.ID] = true
if m.Source != "workshop" {
t.Errorf("expected source 'workshop', got %q", m.Source)
}
}
if !ids["123456"] || !ids["789012"] {
t.Errorf("expected both mod IDs, got %v", ids)
}
}
func TestListWorkshopMods_SkipsFiles(t *testing.T) {
dir := t.TempDir()
workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410")
if err := os.MkdirAll(workshopDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(workshopDir, "notadir.txt"), []byte("hi"), 0644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(workshopDir, "111111"), 0755); err != nil {
t.Fatal(err)
}
mods := ListWorkshopMods(dir)
if len(mods) != 1 {
t.Fatalf("expected 1 mod, got %d", len(mods))
}
if mods[0].ID != "111111" {
t.Errorf("expected ID '111111', got %q", mods[0].ID)
}
}
func TestListLocalMods_Empty(t *testing.T) {
dir := t.TempDir()
mods := ListLocalMods(dir)
if len(mods) != 0 {
t.Fatalf("expected 0 mods, got %d", len(mods))
}
}
func TestListLocalMods_WithMods(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "@ACE3"), 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "@TFAR"), 0755); err != nil {
t.Fatal(err)
}
mods := ListLocalMods(dir)
if len(mods) != 2 {
t.Fatalf("expected 2 mods, got %d", len(mods))
}
for _, m := range mods {
if m.Source != "local" {
t.Errorf("expected source 'local', got %q", m.Source)
}
if len(m.Name) > 0 && m.Name[0] != '@' {
t.Errorf("expected name to start with '@', got %q", m.Name)
}
}
}
func TestListLocalMods_SkipsFiles(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("hi"), 0644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "@Mod"), 0755); err != nil {
t.Fatal(err)
}
mods := ListLocalMods(dir)
if len(mods) != 1 {
t.Fatalf("expected 1 mod, got %d", len(mods))
}
}
func TestBuildUsageMap(t *testing.T) {
dataDir := t.TempDir()
mm := NewModlistManager(dataDir)
ml, err := mm.Create("Test List")
if err != nil {
t.Fatal(err)
}
ml, err = mm.Update(ml.ID, ml.Name, []models.ModEntry{
{ID: "111111", Name: "ACE3", Enabled: true},
{ID: "222222", Name: "", Enabled: true},
})
if err != nil {
t.Fatal(err)
}
_ = ml
usedWorkshop, usedLocal := BuildUsageMap(mm)
if lists, ok := usedWorkshop["111111"]; !ok || len(lists) == 0 {
t.Errorf("expected workshop mod 111111 to be in use")
}
if _, ok := usedWorkshop["222222"]; !ok {
t.Errorf("expected workshop mod 222222 to be in use")
}
if _, ok := usedLocal["ACE3"]; !ok {
t.Errorf("expected local mod ACE3 to be in use")
}
if _, ok := usedLocal["@ACE3"]; !ok {
t.Errorf("expected local mod @ACE3 to be in use")
}
}
func TestBuildUsageMap_Empty(t *testing.T) {
dataDir := t.TempDir()
mm := NewModlistManager(dataDir)
usedWorkshop, usedLocal := BuildUsageMap(mm)
if len(usedWorkshop) != 0 || len(usedLocal) != 0 {
t.Errorf("expected empty maps, got workshop=%v local=%v", usedWorkshop, usedLocal)
}
}
func TestRemoveMod(t *testing.T) {
dir := t.TempDir()
modDir := filepath.Join(dir, "@TestMod")
if err := os.MkdirAll(modDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(modDir, "config.cpp"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := RemoveMod(modDir); err != nil {
t.Fatalf("RemoveMod failed: %v", err)
}
if _, err := os.Stat(modDir); !os.IsNotExist(err) {
t.Errorf("expected directory to be removed")
}
}
func TestRemoveMod_Nonexistent(t *testing.T) {
err := RemoveMod("/nonexistent/path/that/does/not/exist")
if err != nil {
t.Errorf("RemoveMod on nonexistent path should not error, got: %v", err)
}
}
func TestDirSize(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0644); err != nil {
t.Fatal(err)
}
sub := filepath.Join(dir, "sub")
if err := os.MkdirAll(sub, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "b.txt"), []byte("world!"), 0644); err != nil {
t.Fatal(err)
}
size := dirSize(dir)
if size != 11 {
t.Errorf("expected size 11, got %d", size)
}
}
func TestDirSize_Empty(t *testing.T) {
dir := t.TempDir()
size := dirSize(dir)
if size != 0 {
t.Errorf("expected size 0, got %d", size)
}
}
+3 -3
View File
@@ -103,7 +103,7 @@ func (mm *ModlistManager) Create(name string) (*models.Modlist, error) {
if err := os.MkdirAll(mm.dir, 0755); err != nil {
return nil, err
}
if err := os.WriteFile(mm.path(m.ID), data, 0644); err != nil {
if err := writeFileAtomic(mm.path(m.ID), data); err != nil {
return nil, err
}
return m, nil
@@ -126,7 +126,7 @@ func (mm *ModlistManager) Update(id, name string, mods []models.ModEntry) (*mode
if err != nil {
return nil, err
}
if err := os.WriteFile(mm.path(id), data, 0644); err != nil {
if err := writeFileAtomic(mm.path(id), data); err != nil {
return nil, err
}
return m, nil
@@ -160,7 +160,7 @@ func (mm *ModlistManager) Duplicate(id, newName string) (*models.Modlist, error)
if err != nil {
return nil, err
}
if err := os.WriteFile(mm.path(dup.ID), data, 0644); err != nil {
if err := writeFileAtomic(mm.path(dup.ID), data); err != nil {
return nil, err
}
return dup, nil
+136
View File
@@ -0,0 +1,136 @@
package services
import (
"testing"
"time"
"arma3-web-server/internal/models"
)
func TestNewScheduler(t *testing.T) {
dataDir := t.TempDir()
settings := NewSettingsManager(dataDir)
modlists := NewModlistManager(dataDir)
streamer := NewLogStreamer()
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
s := NewScheduler(settings, modlists, steamcmd)
if s == nil {
t.Fatal("expected non-nil scheduler")
}
if s.cron == nil {
t.Fatal("expected non-nil cron")
}
}
func TestScheduler_StartStop(t *testing.T) {
dataDir := t.TempDir()
settings := NewSettingsManager(dataDir)
modlists := NewModlistManager(dataDir)
streamer := NewLogStreamer()
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
s := NewScheduler(settings, modlists, steamcmd)
s.Start()
time.Sleep(50 * time.Millisecond)
s.Stop()
}
func TestScheduler_Refresh_Empty(t *testing.T) {
dataDir := t.TempDir()
settings := NewSettingsManager(dataDir)
modlists := NewModlistManager(dataDir)
streamer := NewLogStreamer()
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
s := NewScheduler(settings, modlists, steamcmd)
s.Start()
defer s.Stop()
s.Refresh()
if s.entryID != 0 {
t.Errorf("expected entryID 0 with empty settings, got %d", s.entryID)
}
}
func TestScheduler_Refresh_InvalidCron(t *testing.T) {
dataDir := t.TempDir()
settings := NewSettingsManager(dataDir)
modlists := NewModlistManager(dataDir)
streamer := NewLogStreamer()
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
_, err := settings.Update(func(s *models.ServerSettings) {
s.ScheduledUpdate = "not-a-cron"
})
if err != nil {
t.Fatal(err)
}
sched := NewScheduler(settings, modlists, steamcmd)
sched.Start()
defer sched.Stop()
sched.Refresh()
if sched.entryID != 0 {
t.Errorf("expected entryID 0 with invalid cron, got %d", sched.entryID)
}
}
func TestScheduler_Refresh_ValidCron(t *testing.T) {
dataDir := t.TempDir()
settings := NewSettingsManager(dataDir)
modlists := NewModlistManager(dataDir)
streamer := NewLogStreamer()
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
_, err := settings.Update(func(s *models.ServerSettings) {
s.ScheduledUpdate = "0 4 * * *"
})
if err != nil {
t.Fatal(err)
}
sched := NewScheduler(settings, modlists, steamcmd)
sched.Start()
defer sched.Stop()
sched.Refresh()
if sched.entryID == 0 {
t.Errorf("expected non-zero entryID with valid cron")
}
}
func TestScheduler_Refresh_Replace(t *testing.T) {
dataDir := t.TempDir()
settings := NewSettingsManager(dataDir)
modlists := NewModlistManager(dataDir)
streamer := NewLogStreamer()
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
sched := NewScheduler(settings, modlists, steamcmd)
sched.Start()
defer sched.Stop()
_, err := settings.Update(func(s *models.ServerSettings) {
s.ScheduledUpdate = "0 4 * * *"
})
if err != nil {
t.Fatal(err)
}
sched.Refresh()
id1 := sched.entryID
_, err = settings.Update(func(s *models.ServerSettings) {
s.ScheduledUpdate = "0 5 * * *"
})
if err != nil {
t.Fatal(err)
}
sched.Refresh()
id2 := sched.entryID
if id1 == id2 {
t.Errorf("expected different entryIDs after refresh, both got %d", id1)
}
}
+5 -2
View File
@@ -3,6 +3,7 @@ package services
import (
"context"
"fmt"
"log"
"net"
"os"
"os/exec"
@@ -184,7 +185,9 @@ func (pm *ProcessManager) Stop() error {
s, err := pm.settings.Load()
if err == nil {
s.WasRunning = false
pm.settings.Save(s)
if err := pm.settings.Save(s); err != nil {
log.Printf("stop: save was_running: %v", err)
}
}
return nil
@@ -307,7 +310,7 @@ func (pm *ProcessManager) WriteUserconfigFiles(s *models.ServerSettings) error {
}
for name, content := range files {
path := filepath.Join(userconfigDir, name)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
if err := writeFileAtomic(path, []byte(content)); err != nil {
return fmt.Errorf("write %s: %w", name, err)
}
}
@@ -983,3 +983,29 @@ func TestStart_StubWritesRPTLog(t *testing.T) {
pm.Stop()
time.Sleep(100 * time.Millisecond)
}
func TestRestart(t *testing.T) {
pm, _ := setupStubPM(t, "-t 10")
if err := pm.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
if !pm.IsRunning() {
t.Fatal("IsRunning() should be true after Start()")
}
if err := pm.Restart(); err != nil {
t.Fatalf("Restart() error = %v", err)
}
if !pm.IsRunning() {
t.Fatal("IsRunning() should be true after Restart()")
}
if err := pm.Stop(); err != nil {
t.Fatalf("Stop() error = %v", err)
}
time.Sleep(100 * time.Millisecond)
if pm.IsRunning() {
t.Fatal("IsRunning() should be false after Stop()")
}
}
+62 -1
View File
@@ -10,6 +10,29 @@ import (
"arma3-web-server/internal/models"
)
func writeFileAtomic(path string, data []byte) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
type SettingsManager struct {
path string
mu sync.Mutex
@@ -49,7 +72,45 @@ func (sm *SettingsManager) Save(s *models.ServerSettings) error {
if err != nil {
return err
}
return os.WriteFile(sm.path, data, 0644)
return writeFileAtomic(sm.path, data)
}
func (sm *SettingsManager) Update(fn func(*models.ServerSettings)) (*models.ServerSettings, error) {
sm.mu.Lock()
defer sm.mu.Unlock()
data, err := os.ReadFile(sm.path)
if err != nil {
if os.IsNotExist(err) {
s := sm.defaults()
fn(s)
s.UpdatedAt = time.Now().UTC()
out, err := json.MarshalIndent(s, "", " ")
if err != nil {
return nil, err
}
if err := writeFileAtomic(sm.path, out); err != nil {
return nil, err
}
return s, nil
}
return nil, err
}
var s models.ServerSettings
if err := json.Unmarshal(data, &s); err != nil {
return nil, err
}
fn(&s)
s.UpdatedAt = time.Now().UTC()
out, err := json.MarshalIndent(s, "", " ")
if err != nil {
return nil, err
}
if err := writeFileAtomic(sm.path, out); err != nil {
return nil, err
}
return &s, nil
}
func (sm *SettingsManager) defaults() *models.ServerSettings {
+1 -4
View File
@@ -25,7 +25,6 @@ type SteamCmdManager struct {
serverfileDir string
streamer *LogStreamer
running atomic.Bool
cancel context.CancelFunc
}
func NewSteamCmdManager(serverfileDir string, streamer *LogStreamer) *SteamCmdManager {
@@ -115,15 +114,13 @@ func (s *SteamCmdManager) run(label string, args []string) error {
return fmt.Errorf("start steamcmd: %w", err)
}
s.cancel = cancel
go s.streamer.Stream(ctx, "steamcmd", stdout, "")
go s.streamer.Stream(ctx, "steamcmd", stderr, "")
go func() {
err := cmd.Wait()
cancel()
s.running.Store(false)
s.cancel = nil
if err == nil {
s.streamer.Broadcast("steamcmd", "[STEAMCMD] SUCCESS: "+label+" finished")
} else {