Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d68d97d00a | ||
|
|
349d34ce20 | ||
|
|
3fbaa65eea | ||
|
|
1e226d3ada | ||
|
|
92c5e11bbc | ||
|
|
9eb4fe2cd8 | ||
|
|
7f5fa30d0f | ||
|
|
f9676ef9cd | ||
|
|
b30e6c5122 | ||
|
|
c820c9c0e0 | ||
|
|
5783474955 | ||
|
|
1e0628974c | ||
|
|
e65aef8315 | ||
|
|
6187178444 | ||
|
|
455ad36f83 | ||
|
|
83165cc4ad | ||
|
|
71d542a60f | ||
|
|
9fdf7a0b09 | ||
|
|
52eaf4a0e2 | ||
|
|
76f72c82ed | ||
|
|
66ff5c9af5 | ||
|
|
a0551d8790 | ||
|
|
6a83216bf8 | ||
|
|
6e22a70f1c |
@@ -1,97 +1,79 @@
|
||||
# Instructions for Codex and automated contributors
|
||||
|
||||
Read `README.md` and the relevant documents under `docs/` before changing implementation or contracts.
|
||||
Work incrementally. Do not perform a repository-wide audit unless the user explicitly requests one.
|
||||
|
||||
## Non-negotiable rules
|
||||
## Start every task
|
||||
|
||||
1. Preserve the product invariants in `README.md`.
|
||||
2. Do not give the main application direct Docker-socket access.
|
||||
3. Do not implement integrations as native plugins, host executables, scripts, or sidecar containers. Integrations are WebAssembly adapters only.
|
||||
4. Do not add arbitrary command execution, arbitrary Docker API proxying, arbitrary host paths, or unrestricted network access.
|
||||
5. Treat templates and module manifests as untrusted input. Validate them against `specs/*.schema.json` before persistence or execution.
|
||||
6. Keep secrets out of API responses, logs, audit payloads, exports, error messages, and test fixtures.
|
||||
7. Preserve player data and backups by default in every deletion, update, restore, and migration workflow.
|
||||
8. Keep `compose.yaml` minimal. Product settings belong in the database and web interface unless they are bootstrap secrets, bind roots, or network/listen settings required before startup.
|
||||
9. SQLite is the V1 database. Do not introduce an external database, message broker, Kubernetes, or distributed-node design without an accepted architecture decision.
|
||||
10. Palworld is the reference integration. Any contract change affecting templates, modules, backups, permissions, or instance lifecycles must be checked against both Palworld examples.
|
||||
1. Run `git status --short --branch` and `git log --oneline -5`.
|
||||
2. Read this file and `docs/PROJECT-STATE.md`.
|
||||
3. Read only the current milestone/request and the domain documents it directly affects.
|
||||
4. Use `git show`, `git diff`, `rg` and file-specific reads to locate the relevant implementation and tests.
|
||||
5. Treat existing working-tree changes as user-owned. Never overwrite, discard, stage or reformat unrelated work.
|
||||
|
||||
## Change workflow
|
||||
Do not reread all documentation, list every source file, concatenate large files, or emit thousands of log lines when a targeted query is sufficient. Start with targeted tests and concise output; expand diagnostics only after a failure.
|
||||
|
||||
- Locate the normative document first.
|
||||
- State assumptions when requirements are ambiguous; do not silently invent security-sensitive behavior.
|
||||
- Update documentation, schema, example, implementation, and tests together when a contract changes.
|
||||
- Prefer small Go packages with explicit interfaces and dependency direction.
|
||||
- Add migrations for persisted data changes. Never edit an already released migration.
|
||||
- Use deterministic serialization and stable identifiers.
|
||||
- Validate JSON Schemas and YAML examples in automated checks.
|
||||
- Add negative tests for authorization, path validation, module capabilities, archive extraction, and agent operation scope.
|
||||
- Report what was validated and what still needs physical or integration testing.
|
||||
## Product invariants
|
||||
|
||||
## Codex operating workflow
|
||||
- The main application never accesses the Docker socket. Only the private restricted agent does.
|
||||
- Integrations are WebAssembly adapters only: no native plugins, host scripts, executables or sidecars.
|
||||
- Never add arbitrary commands, Docker API proxying, host paths or unrestricted network access.
|
||||
- Validate untrusted templates and manifests against `specs/*.schema.json`.
|
||||
- Never expose secrets in APIs, logs, audit events, exports, errors or fixtures.
|
||||
- Preserve player data and backups by default in deletion, update, restore and migration workflows.
|
||||
- Keep `compose.yaml` minimal; ordinary product settings belong in SQLite and the web UI.
|
||||
- SQLite is the V1 database. Keep the single-host architecture unless an accepted decision changes it.
|
||||
- Palworld is the reference integration. Check both Palworld examples when a contract affects templates, modules, backups, permissions or instance lifecycle.
|
||||
|
||||
These rules are permanent repository policy. A `codex exec` prompt should name
|
||||
the objective and constraints specific to the task, then rely on this file
|
||||
instead of repeating the repository workflow.
|
||||
Stop and report any request that would weaken these boundaries.
|
||||
|
||||
### Git, branches and releases
|
||||
## Change rules
|
||||
|
||||
- Start by reading the current branch and working-tree state. Treat existing
|
||||
changes as user-owned and do not overwrite, discard, stage or reformat
|
||||
unrelated work.
|
||||
- Git commands required by the current task are allowed on working branches.
|
||||
Before any Git write, verify the active branch. Never switch to, modify,
|
||||
commit on, merge into, rebase, reset, delete or push `main`; if work starts
|
||||
on `main`, stop before writing and use or request a working branch.
|
||||
- Commits and pushes on non-`main` branches are allowed only when they are
|
||||
necessary for the stated task. Do not infer that implementation alone
|
||||
requires publication, and always report the operations performed.
|
||||
- Codex may create merge/pull requests from working branches when delivery
|
||||
requires review. Codex must never approve or merge them; leave approval and
|
||||
fusion into `main` to an authorized user in Gitea.
|
||||
- Do not alter remotes, credentials or repository-wide Git configuration
|
||||
unless the task explicitly requires it.
|
||||
- Never use destructive recovery commands such as `git reset --hard`,
|
||||
`git clean`, or checkout-based file restoration without explicit approval
|
||||
and a verified target list.
|
||||
- Work only inside this repository unless the task explicitly names another location. Never send repository contents or local data to external services.
|
||||
- Treat network access, dependency installation, host configuration and persistent services as opt-in; request approval when required.
|
||||
- State assumptions instead of inventing security-sensitive behavior.
|
||||
- Make the smallest coherent change; avoid unrelated redesigns.
|
||||
- Enforce authorization and validation in the backend, not only the UI.
|
||||
- Update documentation, schema, examples, implementation and tests together when a contract changes.
|
||||
- Prefer small Go packages, explicit interfaces, deterministic serialization and stable identifiers.
|
||||
- Add a new migration for persisted changes; never edit a released migration.
|
||||
- Add negative tests for authorization, paths, archives, module capabilities and agent operation scope when relevant.
|
||||
- Open detailed documents under `docs/` only when their domain is affected. `README.md` is required only when product invariants, the documentation map or top-level status changes.
|
||||
|
||||
### Security and scope
|
||||
## Git workflow
|
||||
|
||||
- Work only inside this repository unless the task explicitly names another
|
||||
location. Do not expose credentials, tokens, cookies, database contents or
|
||||
private keys in commands, logs, fixtures or reports.
|
||||
- Preserve every trust boundary and non-negotiable product rule above. Stop and
|
||||
report a conflict instead of weakening authentication, authorization,
|
||||
validation, isolation, redaction or data-preservation behavior.
|
||||
- Inspect before editing, make the smallest coherent change, and preserve
|
||||
user-owned changes. Do not modify generated or synchronized files unless the
|
||||
repository documents that workflow.
|
||||
- Do not install system packages, start persistent services, modify host
|
||||
configuration or use elevated privileges without explicit approval.
|
||||
- Work only on a non-`main` feature branch. If the task starts on `main`, create or request a working branch before editing.
|
||||
- Never modify, commit on, merge into, rebase, reset, delete or push `main`.
|
||||
- Develop each milestone on its own dedicated working branch.
|
||||
- After a milestone's validations and commits, always push its working branch to Gitea using the `codex` account. The milestone is not complete until the remote branch exists.
|
||||
- After pushing, create a pull request from the working branch to `main` when the available tools permit it. If automatic creation is unavailable, provide the URL or exact information needed to open it immediately.
|
||||
- Never approve or merge a Gitea pull request.
|
||||
- Do not alter remotes, credentials or repository-wide Git configuration unless explicitly requested.
|
||||
- Never use destructive recovery commands such as `git reset --hard`, `git clean`, or checkout-based restoration without explicit approval and a verified target list.
|
||||
- Before committing, review `git status`, `git diff --stat`, the complete relevant diff and `git diff --check`.
|
||||
|
||||
### Network and dependencies
|
||||
## Standard milestone procedure
|
||||
|
||||
- Treat network access as opt-in. Use it only when the task requires current
|
||||
primary documentation or dependency retrieval.
|
||||
- Prefer existing pinned dependencies and repository tools. Review changes to
|
||||
`go.mod` and `go.sum`; do not add an unrelated dependency or execute code
|
||||
fetched from an untrusted source.
|
||||
- Never send repository contents, secrets or local data to external services.
|
||||
Record any validation skipped because the network was unavailable.
|
||||
1. Read `AGENTS.md` and `docs/PROJECT-STATE.md`.
|
||||
2. Read only the current milestone specification.
|
||||
3. Inspect recent commits and the diff from the relevant baseline.
|
||||
4. Locate affected files with targeted searches.
|
||||
5. Implement the smallest complete change.
|
||||
6. Run targeted tests first.
|
||||
7. Run the applicable global validations.
|
||||
8. Update `docs/PROJECT-STATE.md` with the new baseline, delivered behavior, durable decisions, limitations and next work.
|
||||
9. Review the final diff and validation status, then commit the completed milestone.
|
||||
10. Push the working branch to Gitea with the `codex` account and create, or provide the exact link to create, a pull request to `main`.
|
||||
|
||||
### Caches and temporary files
|
||||
## Validation
|
||||
|
||||
- Keep Codex-created caches and temporary artifacts outside tracked source
|
||||
paths, preferably under `.cache/codex/` or an OS temporary directory.
|
||||
- Reuse caches when safe. Do not delete or purge shared Go, Python, linter,
|
||||
container or user caches unless explicitly requested.
|
||||
- Do not leave binaries, databases, coverage files, logs or temporary patches
|
||||
in the repository. Before finishing, remove only artifacts created by the
|
||||
current task and confirmed safe to remove.
|
||||
For documentation-only changes:
|
||||
|
||||
### Required validation
|
||||
```sh
|
||||
python tools/validate_spec.py
|
||||
git diff --check
|
||||
```
|
||||
|
||||
For Go implementation changes, run the applicable complete validation set from
|
||||
the repository root:
|
||||
For Go changes, run the applicable full set from the repository root after targeted tests:
|
||||
|
||||
```sh
|
||||
gofmt -w <changed-go-files>
|
||||
@@ -102,33 +84,20 @@ go test -race ./...
|
||||
go vet ./...
|
||||
staticcheck ./...
|
||||
golangci-lint run
|
||||
python3 tools/validate_spec.py
|
||||
python tools/validate_spec.py
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Use already installed tools when possible. If a required tool or Python module
|
||||
is unavailable, do not silently install it or omit the check: report the exact
|
||||
blocker and request approval when installation or network access is needed.
|
||||
Run narrower tests during development, but run the full applicable set before
|
||||
declaring completion. A passing build does not replace tests, race detection,
|
||||
static analysis or specification validation.
|
||||
Use installed tools and pinned dependencies. Do not silently install missing tools; report the exact blocker. Keep caches under `.cache/codex/` or an OS temporary directory and remove only artifacts created by the current task.
|
||||
|
||||
### Completion report
|
||||
For changes that affect the web interface, exercise the relevant screens and states in a real browser when the environment permits it. Capture screenshots and use them to check at least the overall rendering, alignment, overflow, labels, primary states, relevant responsive behavior and obvious visual regressions. Screenshots are local validation artifacts and must not be committed unless explicitly requested or another project rule requires it. If browser validation or screenshots are technically unavailable, state that explicitly in the completion report.
|
||||
|
||||
- Summarize behavior changed and list every modified, created or removed file.
|
||||
- List each validation command with pass, fail or not-run status and the exact
|
||||
blocker for anything incomplete.
|
||||
- Report the final branch and working-tree state, while distinguishing changes
|
||||
made by Codex from changes that were already present.
|
||||
- State explicitly whether commits, tags, pushes, branch changes, external
|
||||
writes or persistent host changes occurred. Never claim success for a check
|
||||
that was not run to completion.
|
||||
## Completion report
|
||||
|
||||
## Definition of done for a change
|
||||
- Summarize behavior and contract changes.
|
||||
- List modified, created and removed files.
|
||||
- Report each validation as pass, fail or not run with the exact blocker.
|
||||
- Report the final branch and working-tree state, distinguishing prior changes from yours.
|
||||
- Report commits, pushes, branch changes, external writes and persistent host changes explicitly.
|
||||
|
||||
- Relevant requirements and acceptance criteria are satisfied.
|
||||
- Authorization is enforced in the backend, not only hidden in the UI.
|
||||
- Audit and notification behavior is deliberate.
|
||||
- Failure and rollback behavior is covered.
|
||||
- Documentation and machine-readable examples agree.
|
||||
- Tests cover success, denial, and interruption paths.
|
||||
A change is complete only when success, denial and interruption behavior relevant to its scope are deliberate, documentation and machine-readable contracts agree, and unfinished integration or physical validation is reported.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# DoGaMa
|
||||
|
||||

|
||||
|
||||
DoGaMa is a lightweight, self-hosted manager for private game servers running as Docker containers. It is designed for families and small groups of friends, not for commercial hosting or general Docker administration.
|
||||
|
||||
This repository currently contains the normative product and engineering specification. Implementation must follow the documents and machine-readable contracts linked below.
|
||||
@@ -44,6 +46,7 @@ This repository currently contains the normative product and engineering specifi
|
||||
|
||||
### Contributor contracts
|
||||
|
||||
- [Current operational project state](docs/PROJECT-STATE.md)
|
||||
- [Development conventions](docs/contributing/development.md)
|
||||
- [AI and Codex contributor guide](docs/contributing/ai-codex-guide.md)
|
||||
- [Template schema](specs/template.schema.json)
|
||||
@@ -72,7 +75,7 @@ Only the main application's HTTP port is published. The agent and game-managemen
|
||||
|
||||
## Status
|
||||
|
||||
The first two roadmap foundations are implemented: the main Go binary, embedded server-rendered UI, SQLite migrations, first-administrator bootstrap, local session authentication, and the restricted agent boundary with authenticated private requests, replay defense, canonical allowed-root enforcement, authenticated local registry and bounded Docker health/disk inspection. Deployment plans, container lifecycle operations and the WebAssembly runtime remain later roadmap work.
|
||||
The first seven roadmap foundations are implemented: application/authentication, the restricted agent boundary, the validated catalog, registered instance lifecycle, per-instance authorization, recoverable game-data backups, and the WebAssembly integration runtime. DoGaMa creates atomic `tar.zst` archives with manifests and SHA-256 metadata, selectively retains scheduled backups, supports five-field cron policies with IANA timezones, stages hostile imports under strict limits, and restores through validated staging with a default `pre_restore` safety backup. The module runtime executes typed, capability-checked adapters with bounded resources and instance-pinned networking; the bundled Palworld REST reference adapter is compiled reproducibly and covered by sandbox integration tests. Updates remain later roadmap work.
|
||||
|
||||
## Validate the specification
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Package catalogdata embeds the built-in local game catalog.
|
||||
package catalogdata
|
||||
|
||||
import "embed"
|
||||
|
||||
// Files contains built-in templates and their packaged assets.
|
||||
//
|
||||
//go:embed */template.yaml */assets/*
|
||||
var Files embed.FS
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -11,7 +11,9 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agent"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -35,21 +37,29 @@ func run(logger *slog.Logger) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plans, err := agent.NewPlanPolicy(snapshots, catalogdata.Files)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authenticator, err := agent.NewAuthenticator(config.Secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
docker, err := agent.NewDockerPinger(config.DockerSocket)
|
||||
docker, err := agent.NewDockerRuntime(config.DockerSocket, config.DockerNetwork)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
handler := agent.NewHandler(authenticator, paths, registry, docker, logger)
|
||||
handler := agent.NewHandler(authenticator, paths, plans, registry, docker, logger)
|
||||
server := &http.Server{
|
||||
Addr: config.ListenAddress,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 3 * time.Second,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
WriteTimeout: 15 * time.Minute,
|
||||
IdleTimeout: 30 * time.Second,
|
||||
MaxHeaderBytes: 16 << 10,
|
||||
}
|
||||
|
||||
+159
-2
@@ -2,16 +2,26 @@
|
||||
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"
|
||||
)
|
||||
@@ -27,6 +37,7 @@ func main() {
|
||||
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()
|
||||
@@ -35,16 +46,86 @@ func run(logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
handler, err := web.NewHandler(auth.New(db), logger)
|
||||
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: 30 * time.Second,
|
||||
WriteTimeout: 15 * time.Minute,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
errCh := make(chan error, 1)
|
||||
@@ -65,6 +146,82 @@ func run(logger *slog.Logger) error {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -9,6 +9,9 @@ services:
|
||||
DOGAMA_AGENT_URL: http://agent:8081
|
||||
DOGAMA_AGENT_TOKEN_FILE: /run/secrets/agent_token
|
||||
DOGAMA_MASTER_KEY_FILE: /run/secrets/master_key
|
||||
DOGAMA_SERVERS_ROOT: /srv/game-servers
|
||||
DOGAMA_BACKUPS_ROOT: /srv/game-backups
|
||||
DOGAMA_IMPORTS_ROOT: /var/lib/dogama/imports/staging
|
||||
secrets:
|
||||
- agent_token
|
||||
- master_key
|
||||
@@ -33,6 +36,7 @@ services:
|
||||
DOGAMA_AGENT_TOKEN_FILE: /run/secrets/agent_token
|
||||
DOGAMA_AGENT_REGISTRY_PATH: /var/lib/dogama-agent/registry.json
|
||||
DOGAMA_DOCKER_SOCKET: /var/run/docker.sock
|
||||
DOGAMA_DOCKER_NETWORK: dogama-games
|
||||
DOGAMA_ALLOWED_SERVER_ROOT: /srv/game-servers
|
||||
DOGAMA_ALLOWED_BACKUP_ROOT: /srv/game-backups
|
||||
secrets:
|
||||
@@ -55,6 +59,7 @@ networks:
|
||||
control:
|
||||
internal: true
|
||||
games:
|
||||
name: dogama-games
|
||||
|
||||
volumes:
|
||||
agent_state:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# DoGaMa project state
|
||||
|
||||
Read this compact operational baseline before starting a milestone. Open detailed domain documents only when the current work affects them.
|
||||
|
||||
## Baseline
|
||||
|
||||
- Current reference: milestone 9 working branch after merged milestone 8 baseline `1e226d3`.
|
||||
- Released SQLite migrations: `0001` through `0009`; never rewrite them.
|
||||
- Roadmap milestones 1-9 are implemented.
|
||||
|
||||
## Architecture
|
||||
|
||||
- Go main application: HTTP API, embedded server-rendered UI, authentication/authorization, SQLite, workflows, backups and WASM runtime.
|
||||
- Private restricted Go agent: sole Docker-socket owner; authenticated typed API; registered-instance and plan-digest binding; no generic Docker proxy.
|
||||
- Integrations: capability-scoped WebAssembly adapters only. Palworld REST is the reference module.
|
||||
- Data: SQLite plus canonical allowed server, import and backup roots. Persistent game data lives outside containers.
|
||||
- Contracts: declarative YAML templates and module manifests validated against JSON Schemas; released snapshots are immutable.
|
||||
|
||||
## Implemented capabilities
|
||||
|
||||
- Bootstrap administrator, local authentication, secure sessions and CSRF protection.
|
||||
- Validated embedded catalog, deterministic deployment previews and instance registry.
|
||||
- Restricted instance create/inspect/start/stop/restart/delete and reconciliation.
|
||||
- Per-instance memberships, overrides and installation requests with backend authorization.
|
||||
- Backup scheduling/retention, safe imports, export and restore with safety backups.
|
||||
- Sandboxed WASM runtime and normalized module API with Palworld reference adapter.
|
||||
- Game-container configuration: global and per-instance labels, safe label variables, derived instance slug, immutable Docker-user selection, tracked/pinned image tags, immediate or deferred container recreation, and public game-icon route.
|
||||
- Controlled digest-aware game updates with confirmation, policy-driven pre-update backups, readiness verification, mod warnings and automatic container-plan rollback.
|
||||
- Redacted configuration history retained to the latest 10 revisions, with pinned-template revalidation and immediate or deferred rollback.
|
||||
- Declarative Steam Workshop item configuration with numeric-ID validation, stable ordering and backend `mods.manage` enforcement.
|
||||
- Encrypted write-only SMTP, generic HTTPS webhook and Discord channels with event filters, queued test delivery, bounded retry and redacted terminal errors.
|
||||
- SSRF-resistant HTTPS webhook delivery with redirect/address revalidation, event IDs, timestamps and optional HMAC-SHA256 signatures.
|
||||
- Compact allow-listed audit events for authentication and significant mutations, administrator filtering, bounded manual purge, daily retention and maximum-count enforcement.
|
||||
|
||||
## Durable decisions
|
||||
|
||||
- Editable Docker labels apply only to game-server instance containers.
|
||||
- Labels on the DoGaMa application container remain Compose configuration and are never read, copied or edited by DoGaMa.
|
||||
- Merge order is global labels, then instance labels; instance values win. Internal technical labels are applied last and cannot be overridden.
|
||||
- `dogama.*` and `io.dogama.*` are reserved label namespaces.
|
||||
- Label values support only the explicit allowlist in `internal/instance/container_config.go`; unknown variables are errors, not arbitrary templates.
|
||||
- `{{game.icon_url}}` is the public icon for the game. `{{instance.slug}}` remains supported.
|
||||
- Instance slugs are derived from the display name, not canonical IDs. Accents are normalized to ASCII; whitespace, `/`, punctuation and special characters become safe hyphen separators; repeated and edge hyphens are removed.
|
||||
- Docker user mode is fixed at creation to DoGaMa UID/GID, custom numeric UID/GID, or image-defined user. Never perform automatic recursive ownership changes.
|
||||
- A pinned image tag is an explicit mutable tag, not an immutable digest. Tracked mode follows the template's declared default tag.
|
||||
- Replacement-requiring changes use the generic `container_config_pending` desired-versus-applied state. Replacements preserve bind-mounted data and prior running/stopped intent.
|
||||
- The main app never gains Docker-socket access; the agent remains deny-by-default and independently validates privileged plan fields.
|
||||
- Update candidates are explicit `tag@sha256:digest` references. Mutable tags alone are rejected; automatic updates remain disabled.
|
||||
- Mod configuration is data-only. Provider commands, scripts and arbitrary download URLs are forbidden.
|
||||
- Notification configuration is unavailable unless `DOGAMA_MASTER_KEY_FILE` contains exactly 32 bytes; ciphertext is authenticated AES-GCM and secrets are never returned by list APIs.
|
||||
- Notification delivery attempts are capped at five with exponential minute-scale backoff and never determine the originating operation result.
|
||||
- Audit retention defaults to 30 days and 10,000 entries; zero explicitly selects unlimited retention/count within documented bounds.
|
||||
|
||||
## Known limitations and debt
|
||||
|
||||
- Release hardening remains roadmap work.
|
||||
- Scheduled backup outcomes and repeated authentication blocks are audited/logged, but broader scheduler-origin notification coverage remains intentionally limited to events emitted by implemented workflows.
|
||||
- The web interface is intentionally modest; several advanced workflows are API-first.
|
||||
- Linux is the deployment target. Native Windows execution of the full Go suite is blocked by Unix `Statfs` code; use Linux/WSL/CI for complete execution.
|
||||
- `staticcheck`, `golangci-lint` and Python specification dependencies may not be installed on every development host; report missing tooling rather than silently skipping or installing it.
|
||||
|
||||
## Validation and CI
|
||||
|
||||
- No repository-hosted Gitea/GitHub workflow files are currently present.
|
||||
- Normal completion gate for Go changes is the validation set in `AGENTS.md` on Linux.
|
||||
- Specification validation is `python tools/validate_spec.py` with `tools/requirements-validation.txt` available.
|
||||
- Start with package/file-specific tests, then run global tests, build, race detection, vet, static analysis and schema validation as applicable.
|
||||
|
||||
## Next known work
|
||||
|
||||
- Roadmap milestone 10: security hardening, end-to-end tests, contributor documentation and release packaging.
|
||||
- Update this file at the end of every merged milestone or durable architectural change; keep it compact and remove stale statements.
|
||||
@@ -7,19 +7,24 @@ The agent reduces the chance that an application bug becomes arbitrary Docker co
|
||||
## Private API foundation
|
||||
|
||||
The same-host V1 agent listens on the private control network only. The current
|
||||
foundation exposes three authenticated routes:
|
||||
foundation exposes authenticated health, capacity and typed lifecycle routes:
|
||||
|
||||
- `GET /v1/health` verifies that the configured Docker Unix socket answers its
|
||||
bounded `_ping` request without exposing daemon details;
|
||||
- `POST /v1/check-disk` accepts at most 16 existing absolute paths and returns
|
||||
capacity only after symlink-aware allowed-root validation;
|
||||
- `GET /v1/instances` returns only entries from the authenticated agent-local
|
||||
registry.
|
||||
registry;
|
||||
- `POST /v1/check-ports` reports only whether requested bindings are available;
|
||||
- `POST /v1/instances` creates a validated and registered container;
|
||||
- `GET /v1/instances/{id}` and `/stats` inspect only a bound registration;
|
||||
- typed `start`, `stop`, `restart` and container `DELETE` routes operate only on
|
||||
that registered identity.
|
||||
|
||||
Every route, including health, requires request authentication. Container
|
||||
creation and mutation routes remain closed until the canonical deployment-plan
|
||||
contract and template validation are implemented in the following roadmap
|
||||
milestones. The agent never exposes its internal Docker HTTP client as a proxy.
|
||||
Every route, including health, requires request authentication. The agent never
|
||||
exposes its internal Docker HTTP client as a proxy. Lifecycle requests are
|
||||
idempotent where meaningful and re-inspect the container identity and binding
|
||||
labels before mutation.
|
||||
|
||||
## Allowed V1 operations
|
||||
|
||||
@@ -64,8 +69,16 @@ Before create or replace, the agent verifies:
|
||||
- port protocols and container ports match the template and host ports do not conflict;
|
||||
- resource limits are present and within administrator limits;
|
||||
- labels use the reserved namespace and cannot be overridden;
|
||||
- custom labels are bounded, may not use either `dogama.*` or the internal `io.dogama.*` namespace, and are merged before immutable technical labels;
|
||||
- the optional Docker `User` is either an already validated numeric `UID:GID` value or omitted so the image `USER` applies;
|
||||
- only approved DoGaMa networks are attached.
|
||||
|
||||
The canonical plan digest alone is not treated as approval. The agent embeds and
|
||||
validates the same catalog, then independently compares every privileged field
|
||||
to the pinned immutable template snapshot before checking paths or pulling an
|
||||
image. A caller cannot make a substituted image or mount valid merely by
|
||||
recomputing a digest.
|
||||
|
||||
V1 templates do not expose arbitrary Docker security options. The agent applies a secure fixed baseline: no-new-privileges where compatible, dropped capabilities by default, bounded PIDs and a non-host network mode.
|
||||
|
||||
## Authentication and replay defense
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -28,7 +28,7 @@ tests/integration/
|
||||
|
||||
## Initial application development
|
||||
|
||||
The initial main application requires Go 1.25. SQLite is provided by the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. It reads only bootstrap settings from `DOGAMA_LISTEN_ADDRESS` (default `:8080`) and `DOGAMA_DATABASE_PATH` (default `dogama.db`). Run it with:
|
||||
The initial main application requires Go 1.25. SQLite is provided by the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. It reads bootstrap settings from `DOGAMA_LISTEN_ADDRESS` (default `:8080`), `DOGAMA_DATABASE_PATH` (default `dogama.db`), `DOGAMA_AGENT_URL`, `DOGAMA_AGENT_TOKEN_FILE` and `DOGAMA_MASTER_KEY_FILE`. The two agent settings must either both be present or both be absent; lifecycle routes remain disabled when developing without an agent. The master-key file must contain exactly 32 bytes and enables encrypted notification-channel configuration; audit remains available without it. Run it with:
|
||||
|
||||
```sh
|
||||
go run ./cmd/dogama
|
||||
@@ -45,12 +45,67 @@ go run ./cmd/dogama-agent
|
||||
It fails closed unless `DOGAMA_AGENT_TOKEN_FILE` references a 32-byte-or-longer
|
||||
secret and at least one of `DOGAMA_ALLOWED_SERVER_ROOT` or
|
||||
`DOGAMA_ALLOWED_BACKUP_ROOT` is configured. Its bootstrap-only defaults are
|
||||
`:8081`, `/var/run/docker.sock` and
|
||||
`:8081`, `/var/run/docker.sock`, the fixed `dogama-games` Docker network and
|
||||
`/var/lib/dogama-agent/registry.json`. The configured roots must already exist
|
||||
and are canonicalized with symlinks resolved. For normal deployment, use the
|
||||
secret file and private control network defined in `compose.yaml`; never publish
|
||||
the agent port on the host.
|
||||
|
||||
The agent loads the same embedded validated catalog as the main application.
|
||||
Before Docker access it independently matches image, entrypoint, arguments,
|
||||
container ports, mount destinations, resource minimums and stop timeout against
|
||||
the pinned template snapshot. Mount sources are created one directory at a time
|
||||
below configured roots with symlinks refused. Docker containers always use the
|
||||
fixed restricted baseline; callers cannot provide labels, capabilities, devices,
|
||||
network modes or arbitrary Docker options.
|
||||
|
||||
Lifecycle API operations are authorized in the backend against the authenticated
|
||||
identity and the target instance. Global administrators retain implicit access;
|
||||
assigned users can inspect, view metrics, start and stop, while managers also
|
||||
receive the documented operational baseline. Explicit deny overrides take
|
||||
precedence over role baselines and allows. Install and container-only delete
|
||||
remain administrator operations. Operations are serialized per instance and
|
||||
recorded in `instance_operations`; desired and observed states are reconciled at
|
||||
startup and every minute. Container-only delete removes neither the SQLite
|
||||
intent nor host paths, so player data and backups remain untouched.
|
||||
|
||||
The authorization foundation exposes JSON APIs for local user creation,
|
||||
memberships, per-user overrides and installation requests. Mutations require the
|
||||
session CSRF token. User creation, membership changes, override changes and
|
||||
request review additionally require an administrator session authenticated in
|
||||
the previous ten minutes. Approving a request only records the decision and the
|
||||
requested values; it never creates a draft or contacts the restricted agent.
|
||||
|
||||
Game-data backups are written below `DOGAMA_BACKUPS_ROOT` (default
|
||||
`/srv/game-backups`) and may only read instance mounts below
|
||||
`DOGAMA_SERVERS_ROOT` (default `/srv/game-servers`). Untrusted uploads are
|
||||
isolated below `DOGAMA_IMPORTS_ROOT` (default
|
||||
`/var/lib/dogama/imports/staging`). These are bootstrap path boundaries, not
|
||||
ordinary product settings. The same canonical server and backup roots are
|
||||
mounted into the main application and restricted agent by `compose.yaml`.
|
||||
|
||||
The current backup engine conservatively stops a running instance before
|
||||
archiving. Once the WebAssembly runtime is active, an enabled `online_save`
|
||||
module can provide the documented flush-before-archive optimization without
|
||||
moving traversal or archive ownership out of the main application. Archives
|
||||
are finalized before SQLite marks them available; a metadata failure removes
|
||||
the orphaned file. Scheduled retention considers only successful `scheduled`
|
||||
backups. Restore verifies size, checksum, manifest and pinned template version,
|
||||
creates a `pre_restore` backup, extracts into sibling staging and keeps the
|
||||
instance stopped with `intervention_required` if readiness cannot be restored.
|
||||
Validated imports are pinned to the selected template version. Import-backed
|
||||
drafts require that opaque import ID, and installation atomically places the
|
||||
normalized staged tree at the template-declared mount-relative destination
|
||||
before the restricted agent creates the first container. Repeated installation
|
||||
submission recognizes an already attached import instead of copying it twice.
|
||||
|
||||
At main-application startup, every embedded `catalog/*/template.yaml` is
|
||||
validated against `specs/template.schema.json`, checked for cross-reference and
|
||||
asset integrity, canonicalized deterministically and synchronized into SQLite.
|
||||
An existing template ID/version is immutable: changing its digest fails startup
|
||||
instead of silently replacing the snapshot. Deployment previews pin that digest
|
||||
and redact secret defaults before a draft instance can enter the registry.
|
||||
|
||||
## Contract changes
|
||||
|
||||
Template schema, manifest schema, normalized module API and agent deployment plan are versioned contracts.
|
||||
|
||||
@@ -86,7 +86,25 @@ Admin-only system permissions are not delegated per instance in V1.
|
||||
|
||||
Client-provided instance IDs, roles and permission lists are never trusted. Object lookup and permission evaluation occur in one application-layer call to prevent confused-deputy errors.
|
||||
|
||||
## Persistence and request workflow
|
||||
|
||||
Instance memberships and overrides are stored in SQLite with foreign keys to
|
||||
the registered instance and local user. Removing a membership also removes its
|
||||
overrides. A user can have at most one pending installation request for a given
|
||||
template version; users only list their own requests, while administrators list
|
||||
all requests.
|
||||
|
||||
Approving or refusing a request is an atomic state transition from `pending`.
|
||||
Refusal requires a reason. Approval retains the requested template, optional
|
||||
name, player estimate, schedule, mods flag and message for a later creation
|
||||
workflow, but deliberately performs no deployment and creates no instance.
|
||||
|
||||
All API mutations validate the session-bound CSRF token. User creation,
|
||||
membership and override changes, and request review require an administrator
|
||||
authentication no older than ten minutes. Authorization failures return a
|
||||
generic denial so an unassigned user cannot use object identifiers to discover
|
||||
instances.
|
||||
|
||||
## Sensitive-action safeguards
|
||||
|
||||
Restore, destructive delete, membership changes, secret rotation and security configuration require recent authentication. Data removal requires separate checkboxes and typed instance-name confirmation. A manager never gains new abilities merely because a module exposes a capability.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
|
||||
| `template_versions` | Immutable validated snapshots | template_id, version, schema_version, canonical_yaml, digest |
|
||||
| `modules` | Module identity and trust | id, source, trust_status, active_version |
|
||||
| `module_versions` | Immutable installed artifacts | module_id, version, manifest, wasm_digest, path, api_range |
|
||||
| `instances` | Desired and observed instance state | id, slug, display_name, template snapshot, revision, lifecycle_state, public_host |
|
||||
| `instances` | Desired and observed instance state | id, derived slug, display_name, template snapshot, revision, lifecycle_state, Docker-user mode/UID/GID, image-tag mode/tag, custom labels, container_config_pending |
|
||||
| `instance_settings` | Typed non-secret template values | instance_id, field_id, value_json |
|
||||
| `instance_secrets` | Encrypted secret values | instance_id, field_id, key_version, nonce, ciphertext |
|
||||
| `instance_ports` | Published and private bindings | instance_id, port_id, host_ip, host_port, container_port, protocol |
|
||||
@@ -36,6 +36,9 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
|
||||
## Invariants
|
||||
|
||||
- IDs are opaque and stable; slugs are unique but mutable only through a controlled rename.
|
||||
- Instance slugs are derived from display names, transliterated to lowercase ASCII and recalculated on rename; they are never canonical identifiers.
|
||||
- The Docker-user selection is immutable after creation. Custom UID/GID values exist only for `custom`; DoGaMa never recursively changes file ownership.
|
||||
- Desired container configuration is stored separately from the applied `plan_digest`; `container_config_pending` covers any replacement-requiring change without feature-specific flags.
|
||||
- Released template and module versions are immutable. Editing creates a new version or an independent local copy.
|
||||
- An instance pins a template snapshot and module version; catalog changes do not mutate it silently.
|
||||
- There is at most one active mutating operation per instance.
|
||||
|
||||
@@ -12,6 +12,14 @@ any state -> error | unknown | intervention_required
|
||||
|
||||
`container_running` is an observation, not the `online` state. Online requires the template health probe or module readiness check to succeed within its startup timeout.
|
||||
|
||||
The milestone-4 foundation persists each mutually exclusive action before
|
||||
dispatch and keeps desired lifecycle state separate from observed Docker state.
|
||||
It reconciles registered instances at application startup and periodically.
|
||||
Until a template's module readiness adapter is available, a running container
|
||||
whose readiness cannot be established is `degraded`, never optimistically
|
||||
`online`. Docker automatic restart is disabled, so later scheduler work cannot
|
||||
create an unbounded crash loop before the circuit-breaker policy is implemented.
|
||||
|
||||
## Creation
|
||||
|
||||
1. Select a validated template version.
|
||||
@@ -41,6 +49,10 @@ The configurable failure policy is `abort_stop`, `stop_without_backup` or `force
|
||||
|
||||
Maintenance mode blocks ordinary user starts and shows an administrator message while preserving manager/admin access. Settings specify `immediate` or `restart_required`; pending restart changes are applied together through a controlled container replacement. Keep a small redacted configuration history.
|
||||
|
||||
Docker label and image-tag changes use the same generic desired-versus-applied mechanism. `immediate` stops and replaces the container, restores its prior running/stopped intent and preserves every bind-mounted data path. `next_start` sets `container_config_pending`; the next explicit start pulls the desired image, replaces the container, clears the flag and starts it. A stopped instance remains stopped during immediate replacement.
|
||||
|
||||
The Docker user is selected at creation (`dogama`, `custom`, or image-defined) and is never editable afterward because changing it could invalidate persistent-file permissions. Administrators must use backup, new-instance creation and restore to change ownership deliberately; DoGaMa never performs automatic recursive `chown`.
|
||||
|
||||
## Crash-loop protection
|
||||
|
||||
Track unexpected exits. Default circuit breaker: five restarts within ten minutes disables automatic restart and moves the instance to error. Manual administrator action after diagnosis resets it. Scheduled jobs do not fight this state.
|
||||
@@ -73,4 +85,3 @@ Scopes 3 and 4 are off by default, require typed-name confirmation and are audit
|
||||
## Reconciliation
|
||||
|
||||
At startup and periodically, compare desired registry state with agent-inspected registered instances. Unknown Docker containers are ignored. Missing or altered managed containers become `unknown` or `intervention_required`; DoGaMa does not silently recreate or adopt them when data safety is uncertain.
|
||||
|
||||
|
||||
@@ -91,3 +91,24 @@ The preview reports detected game/type, file count, expanded size, world/player
|
||||
|
||||
The reference template recognizes dedicated-server world layouts with `Level.sav` and `Players/`. A local hosted-world import may require player identity conversion. V1 must preserve the upload, warn about this possibility and never perform undocumented silent conversion. A future game-specific data converter remains separate from the WebAssembly API adapter because modules have no filesystem access.
|
||||
|
||||
## Implemented foundation
|
||||
|
||||
The V1 backup foundation persists policies, archives and import-validation
|
||||
records in SQLite. Manual backups, listing, integrity-checked export, restore
|
||||
and five-field cron policy APIs are protected by the stable instance permission
|
||||
identifiers. Restore and backup-policy mutation use the existing recent-session
|
||||
safeguard.
|
||||
|
||||
Until an integration module is activated, the generic engine uses the safe
|
||||
`stop_then_archive` behavior even when a template advertises `online_save`.
|
||||
The later WebAssembly milestone supplies the capability call; modules will
|
||||
still never traverse or archive files.
|
||||
|
||||
ZIP, tar, tar.gz and tar.zst imports are copied to a unique staging directory
|
||||
before validation. Extraction uses create-new files and rejects absolute or
|
||||
Windows paths, traversal, links, special files, excessive nesting, excessive
|
||||
file counts and expanded-size overflow. A compatible validated import can be
|
||||
selected in an administrator creation preview. Its normalized data is copied
|
||||
through a create-new sibling directory into the template-declared destination
|
||||
immediately before first container creation; validation itself never creates a
|
||||
container or writes into live player data.
|
||||
|
||||
@@ -31,18 +31,44 @@ The template states destination mount, ordering, restart requirement, dependency
|
||||
|
||||
DoGaMa clearly labels unofficial mod support and never assumes a server update is compatible with installed mods.
|
||||
|
||||
The V1 implementation accepts only Steam Workshop numeric item IDs when the pinned template explicitly declares that provider. It persists a normalized declarative list and rejects provider commands, scripts, arbitrary URLs and changes for templates without mod support.
|
||||
|
||||
## Configuration application
|
||||
|
||||
Each template field declares `apply: immediate` or `restart_required`. Secret fields are write-only. Validate types, ranges, patterns and conflicts on both client and server. A preview lists pending changes and whether container replacement or game restart is needed.
|
||||
|
||||
Keep the last 10 redacted configuration revisions by default. Rollback revalidates the old revision against the pinned template/module versions before applying it.
|
||||
|
||||
Every desired container or mod change creates a revision, listed newest first. Rollback never restores secrets, never changes the immutable Docker user and may be immediate or deferred until the next explicit start.
|
||||
|
||||
## Updates
|
||||
|
||||
Image updates are digest-aware. A mutable tag alone is never treated as proof that nothing changed. The UI shows current and candidate references, template release notes if available, mod warnings and whether a backup will run.
|
||||
|
||||
The full update sequence and rollback behavior are normative in `docs/domain/instance-lifecycle.md`. Managers may trigger only updates allowed by global/instance policy; administrators choose channels and may pin a digest. Automatic updates remain disabled by default.
|
||||
|
||||
The V1 API requires an explicit candidate tag and SHA-256 image digest plus confirmation. A required `pre_update` backup must finish before replacement. Readiness is bounded by the template timeout; failed replacement, start or readiness restores the prior image/configuration plan when rollback is enabled, without restoring player data.
|
||||
|
||||
## Game-container labels, users and tags
|
||||
|
||||
Administrators can define global labels for game-server containers and instance-specific overrides, one `key=value` per line. Empty lines are ignored and only the first `=` separates the key. Instance labels override global labels; DoGaMa's technical labels always win. Both `dogama.*` and `io.dogama.*` are reserved.
|
||||
|
||||
Values support only `{{game.name}}`, `{{game.id}}`, `{{game.icon_url}}`, `{{instance.name}}`, `{{instance.id}}`, `{{instance.slug}}` and `{{server.name}}`. Unknown or malformed variables fail validation; this is substitution, not a general template language. For example:
|
||||
|
||||
```text
|
||||
glance.name={{instance.name}}
|
||||
glance.icon={{game.icon_url}}
|
||||
glance.parent=DoGaMa
|
||||
```
|
||||
|
||||
`{{game.icon_url}}` resolves to the unauthenticated, read-only `/public/game-icons/{game-id}` route. The route serves only embedded reviewed raster content with an explicit MIME type and cache policy; it is not a public catalog or administration API.
|
||||
|
||||
At creation, the Docker user is either the DoGaMa process UID/GID (default), an explicitly validated numeric UID/GID, or omitted to use the image-defined user. An image without `USER` may therefore run as root. The selection is immutable after creation.
|
||||
|
||||
The template's declared tag is the `tracked` default. An administrator may instead select a syntactically validated `pinned` tag and later return to tracked mode. Pinned means an explicitly selected mutable tag, not a digest: publishers can republish the same tag. Manual SHA-256 digest management is outside this milestone.
|
||||
|
||||
Label and tag changes can apply immediately or at the next start. Immediate application disconnects players and recreates only the container; bind-mounted persistent data remains. Deferred application uses the generic `container_config_pending` state.
|
||||
|
||||
## Template and module updates
|
||||
|
||||
- Updating a catalog template creates a new immutable version; instances remain pinned.
|
||||
@@ -50,4 +76,3 @@ The full update sequence and rollback behavior are normative in `docs/domain/ins
|
||||
- Local copied templates are independent and are never overwritten by their origin.
|
||||
- Module packages update independently and require compatibility plus connection tests before activation.
|
||||
- Rollback keeps the prior template snapshot, module binary and container plan available until the new combination is verified.
|
||||
|
||||
|
||||
@@ -10,6 +10,14 @@ Administrators configure channels entirely in the UI:
|
||||
|
||||
Channel secrets are encrypted and write-only. A test action sends a clearly marked test message and reports a redacted result.
|
||||
|
||||
The main application reads the 32-byte authenticated-encryption key from
|
||||
`DOGAMA_MASTER_KEY_FILE`. Without that external key, audit remains available
|
||||
but channel configuration and delivery are disabled. Generic and Discord
|
||||
webhooks require HTTPS; resolution, redirects and every resolved address reject
|
||||
loopback, private, link-local, multicast and unspecified networks. Generic
|
||||
webhooks carry `X-DoGaMa-Event-ID`, `X-DoGaMa-Timestamp` and, when a signing
|
||||
secret is configured, an HMAC-SHA256 `X-DoGaMa-Signature`.
|
||||
|
||||
## Events and filtering
|
||||
|
||||
Suggested configurable events:
|
||||
@@ -58,5 +66,8 @@ Entries are compact and use allow-listed structured summaries. Player IDs may be
|
||||
|
||||
Audit retention defaults to 30 days and is globally administrator-configurable. An optional maximum count prevents unbounded growth. Purge runs daily and records one aggregate audit event, not an event per deleted row. Unlimited retention requires an explicit warning and displays database usage. Administrators may manually purge by date with confirmation.
|
||||
|
||||
Technical application logs go to stdout/stderr and use Docker log rotation. Their level and retention are separate from SQLite audit policy.
|
||||
The V1 bounds are 0–3650 retention days and 0–1,000,000 entries; zero means
|
||||
unlimited. The viewer returns at most 200 entries per request and supports time,
|
||||
actor, instance, action and outcome filters through the administration API.
|
||||
|
||||
Technical application logs go to stdout/stderr and use Docker log rotation. Their level and retention are separate from SQLite audit policy.
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
| CSRF/session theft | Secure HttpOnly SameSite cookies, CSRF token, TLS guidance, session rotation/revocation and idle/absolute expiry |
|
||||
| Password attack | Modern password hashing, rate limits, backoff, generic errors, repeated-failure audit/notification |
|
||||
| Supply-chain substitution | Immutable version snapshots, checksums, optional signatures/trust labels, digest-pinned images, controlled activation |
|
||||
| Label/template injection | Structured key/value parsing, reserved namespaces, explicit substitution allowlist, no arbitrary template execution |
|
||||
| Destructive mistake | Preview, recent authentication, typed-name confirmation, pre-restore/update backups and recoverable workflows |
|
||||
| Resource exhaustion | Upload/extraction limits, job concurrency, per-instance locks, Docker limits, disk checks, notification/module bounds |
|
||||
| Replay/race | Signed nonce/timestamp agent calls, idempotency keys, optimistic revisions and durable operation phases |
|
||||
|
||||
@@ -54,5 +54,9 @@ Restore shows overwritten data, safety-backup behavior and server downtime. It r
|
||||
|
||||
Global screens cover users, public address, approved storage roots (displayed, bootstrap-controlled where appropriate), catalog/modules, notification channels, audit retention/default 30 days, upload limits, disk thresholds and safety defaults.
|
||||
|
||||
The game-container label editor is a multiline `key=value` field with one label per line, the complete allowed-variable list, and explicit `apply immediately` versus `apply on next start` choices. Immediate application confirms that affected containers stop and are recreated, connected players disconnect, persistent data remains, and displays affected/running counts when known.
|
||||
|
||||
Instance creation includes Docker-user mode, conditional custom UID/GID, tracked/pinned image tag and optional labels. Existing-instance advanced configuration displays the Docker user read-only with the backup/new-instance/restore migration explanation, permits label and tag changes with the same application choices, and visibly reports `container_config_pending`.
|
||||
|
||||
The audit viewer is compact and filterable by time, actor, instance, action and outcome. It is not a raw log console.
|
||||
|
||||
|
||||
@@ -3,7 +3,15 @@ module git.zaynet.fr/DoGaMa/DoGaMa-serv
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dlclark/regexp2 v1.12.0
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
|
||||
github.com/tetratelabs/wazero v1.11.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/text v0.38.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.56.0
|
||||
)
|
||||
|
||||
@@ -13,7 +21,6 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
@@ -6,12 +8,20 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
|
||||
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
@@ -20,8 +30,14 @@ golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
)
|
||||
|
||||
func (s *service) prepareAssets(plan agentwire.DeploymentPlan) ([]AssetMount, error) {
|
||||
approved, err := s.plans.Assets(plan)
|
||||
if err != nil || len(approved) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
assetRoot := filepath.Join(filepath.Dir(plan.Mounts[0].HostPath), ".dogama", "assets")
|
||||
assetRoot, err = s.paths.Prepare(assetRoot)
|
||||
if err != nil {
|
||||
return nil, errors.New("asset root is not allowed")
|
||||
}
|
||||
result := make([]AssetMount, 0, len(approved))
|
||||
for _, asset := range approved {
|
||||
digest := sha256.Sum256(asset.Content)
|
||||
if hex.EncodeToString(digest[:]) != asset.SHA256 {
|
||||
return nil, errors.New("approved asset integrity check failed")
|
||||
}
|
||||
target := filepath.Join(assetRoot, asset.SHA256)
|
||||
if err := writeImmutableAsset(target, asset.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, AssetMount{HostPath: target, ContainerPath: asset.Destination})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func writeImmutableAsset(path string, content []byte) error {
|
||||
if info, err := os.Lstat(path); err == nil {
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return errors.New("approved asset path is not a regular file")
|
||||
}
|
||||
existing, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return errors.New("read approved asset")
|
||||
}
|
||||
existingDigest, wantedDigest := sha256.Sum256(existing), sha256.Sum256(content)
|
||||
if existingDigest != wantedDigest {
|
||||
return errors.New("approved asset content conflict")
|
||||
}
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.New("inspect approved asset path")
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Dir(path), ".asset-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create approved asset: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if err = temporary.Chmod(0o500); err == nil {
|
||||
_, err = temporary.Write(content)
|
||||
}
|
||||
if err == nil {
|
||||
err = temporary.Sync()
|
||||
}
|
||||
if closeErr := temporary.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("write approved asset")
|
||||
}
|
||||
if err := os.Rename(temporaryPath, path); err != nil {
|
||||
return errors.New("publish approved asset")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Config contains bootstrap-only settings for the restricted agent.
|
||||
@@ -15,6 +16,7 @@ type Config struct {
|
||||
AllowedRoots []string
|
||||
RegistryPath string
|
||||
DockerSocket string
|
||||
DockerNetwork string
|
||||
}
|
||||
|
||||
// LoadConfig reads the agent's bootstrap settings and shared secret file.
|
||||
@@ -42,10 +44,14 @@ func LoadConfig() (Config, error) {
|
||||
AllowedRoots: roots,
|
||||
RegistryPath: environment("DOGAMA_AGENT_REGISTRY_PATH", "/var/lib/dogama-agent/registry.json"),
|
||||
DockerSocket: environment("DOGAMA_DOCKER_SOCKET", "/var/run/docker.sock"),
|
||||
DockerNetwork: environment("DOGAMA_DOCKER_NETWORK", "dogama-games"),
|
||||
}
|
||||
if !filepath.IsAbs(config.RegistryPath) || !filepath.IsAbs(config.DockerSocket) {
|
||||
return Config{}, errors.New("registry and Docker socket paths must be absolute")
|
||||
}
|
||||
if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`).MatchString(config.DockerNetwork) {
|
||||
return Config{}, errors.New("docker network name is invalid")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestLoadConfigReadsSecretFileAndRoots(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(config.Secret, secret) || len(config.AllowedRoots) != 1 || config.ListenAddress != ":8081" {
|
||||
if !bytes.Equal(config.Secret, secret) || len(config.AllowedRoots) != 1 || config.ListenAddress != ":8081" || config.DockerNetwork != "dogama-games" {
|
||||
t.Fatalf("config = %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
+317
-18
@@ -1,32 +1,62 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
)
|
||||
|
||||
// DockerPinger is the only Docker capability needed by the foundation agent.
|
||||
// Container mutation is added only with validated deployment plans.
|
||||
type DockerPinger interface {
|
||||
const dockerAPIVersion = "/v1.41"
|
||||
|
||||
type DockerRuntime interface {
|
||||
Ping(context.Context) error
|
||||
CheckPorts(context.Context, []agentwire.PlanPort) error
|
||||
Create(context.Context, agentwire.DeploymentPlan, []AssetMount) (string, error)
|
||||
Start(context.Context, string) error
|
||||
Stop(context.Context, string, int) error
|
||||
Restart(context.Context, string, int) error
|
||||
Delete(context.Context, string) error
|
||||
Inspect(context.Context, string) (DockerInspection, error)
|
||||
Stats(context.Context, string) (agentwire.InstanceStats, error)
|
||||
}
|
||||
|
||||
type dockerPinger struct {
|
||||
client *http.Client
|
||||
type DockerInspection struct {
|
||||
ContainerID string
|
||||
Running bool
|
||||
Health string
|
||||
ExitCode int
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
// NewDockerPinger constructs a client pinned to one configured Unix socket.
|
||||
func NewDockerPinger(socketPath string) (DockerPinger, error) {
|
||||
type AssetMount struct {
|
||||
HostPath string
|
||||
ContainerPath string
|
||||
}
|
||||
|
||||
type dockerRuntime struct {
|
||||
client *http.Client
|
||||
network string
|
||||
}
|
||||
|
||||
func NewDockerRuntime(socketPath, network string) (DockerRuntime, error) {
|
||||
if !filepath.IsAbs(socketPath) {
|
||||
return nil, errors.New("docker socket path must be absolute")
|
||||
}
|
||||
if strings.TrimSpace(network) == "" {
|
||||
return nil, errors.New("docker network is required")
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 2 * time.Second}
|
||||
transport := &http.Transport{
|
||||
DisableCompression: true,
|
||||
@@ -34,22 +64,291 @@ func NewDockerPinger(socketPath string) (DockerPinger, error) {
|
||||
return dialer.DialContext(ctx, "unix", socketPath)
|
||||
},
|
||||
}
|
||||
return &dockerPinger{client: &http.Client{Transport: transport, Timeout: 3 * time.Second}}, nil
|
||||
return &dockerRuntime{client: &http.Client{Transport: transport, Timeout: 10 * time.Minute}, network: network}, nil
|
||||
}
|
||||
|
||||
func (d *dockerPinger) Ping(ctx context.Context) error {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker/_ping", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := d.client.Do(request)
|
||||
if err != nil {
|
||||
func (d *dockerRuntime) Ping(ctx context.Context) error {
|
||||
response, err := d.call(ctx, http.MethodGet, "/_ping", nil, "", 16)
|
||||
if err != nil || response.status != http.StatusOK || strings.TrimSpace(string(response.body)) != "OK" {
|
||||
return errors.New("docker daemon is unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dockerRuntime) CheckPorts(ctx context.Context, ports []agentwire.PlanPort) error {
|
||||
response, err := d.call(ctx, http.MethodGet, dockerAPIVersion+"/containers/json?all=1", nil, "", 1<<20)
|
||||
if err != nil || response.status != http.StatusOK {
|
||||
return errors.New("host port availability check failed")
|
||||
}
|
||||
var containers []struct {
|
||||
Ports []struct {
|
||||
PublicPort int `json:"PublicPort"`
|
||||
Type string `json:"Type"`
|
||||
} `json:"Ports"`
|
||||
}
|
||||
if json.Unmarshal(response.body, &containers) != nil {
|
||||
return errors.New("host port availability check failed")
|
||||
}
|
||||
used := make(map[string]struct{})
|
||||
for _, container := range containers {
|
||||
for _, port := range container.Ports {
|
||||
if port.PublicPort > 0 {
|
||||
used[fmt.Sprintf("%s/%d", port.Type, port.PublicPort)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, port := range ports {
|
||||
if !port.Publish {
|
||||
continue
|
||||
}
|
||||
if _, exists := used[fmt.Sprintf("%s/%d", port.Protocol, port.HostPort)]; exists {
|
||||
return errors.New("requested host port is unavailable")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPlan, assets []AssetMount) (string, error) {
|
||||
pullPath := dockerAPIVersion + "/images/create?fromImage=" + url.QueryEscape(plan.Image)
|
||||
response, err := d.call(ctx, http.MethodPost, pullPath, nil, "", 8<<20)
|
||||
if err != nil || response.status < 200 || response.status >= 300 || !validPullResponse(response.body) {
|
||||
return "", errors.New("docker image pull failed")
|
||||
}
|
||||
type portBinding struct {
|
||||
HostIP string `json:"HostIp"`
|
||||
HostPort string `json:"HostPort"`
|
||||
}
|
||||
exposed := make(map[string]struct{}, len(plan.Ports))
|
||||
bindings := make(map[string][]portBinding)
|
||||
for _, port := range plan.Ports {
|
||||
key := fmt.Sprintf("%d/%s", port.ContainerPort, port.Protocol)
|
||||
exposed[key] = struct{}{}
|
||||
if port.Publish {
|
||||
bindings[key] = []portBinding{{HostIP: "0.0.0.0", HostPort: strconv.Itoa(port.HostPort)}}
|
||||
}
|
||||
}
|
||||
binds := make([]string, 0, len(plan.Mounts))
|
||||
for _, mount := range plan.Mounts {
|
||||
mode := "rw"
|
||||
if mount.ReadOnly {
|
||||
mode = "ro"
|
||||
}
|
||||
binds = append(binds, mount.HostPath+":"+mount.ContainerPath+":"+mode)
|
||||
}
|
||||
for _, asset := range assets {
|
||||
binds = append(binds, asset.HostPath+":"+asset.ContainerPath+":ro")
|
||||
}
|
||||
pidsLimit := int64(512)
|
||||
payload := struct {
|
||||
Image string `json:"Image"`
|
||||
User string `json:"User,omitempty"`
|
||||
Entrypoint []string `json:"Entrypoint,omitempty"`
|
||||
Cmd []string `json:"Cmd,omitempty"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
ExposedPorts map[string]struct{} `json:"ExposedPorts"`
|
||||
HostConfig struct {
|
||||
Binds []string `json:"Binds"`
|
||||
PortBindings map[string][]portBinding `json:"PortBindings"`
|
||||
Memory int64 `json:"Memory"`
|
||||
NanoCPUs int64 `json:"NanoCpus"`
|
||||
PidsLimit *int64 `json:"PidsLimit"`
|
||||
CapDrop []string `json:"CapDrop"`
|
||||
SecurityOpt []string `json:"SecurityOpt"`
|
||||
NetworkMode string `json:"NetworkMode"`
|
||||
RestartPolicy map[string]string `json:"RestartPolicy"`
|
||||
} `json:"HostConfig"`
|
||||
}{
|
||||
Image: plan.Image, User: plan.User, Entrypoint: plan.Entrypoint, Cmd: plan.Arguments,
|
||||
Labels: mergeDockerLabels(plan.Labels, map[string]string{
|
||||
"io.dogama.managed": "true", "io.dogama.instance-id": plan.InstanceID,
|
||||
"io.dogama.template-id": plan.TemplateID, "io.dogama.template-version": plan.TemplateVersion,
|
||||
"io.dogama.plan-digest": plan.PlanDigest,
|
||||
}),
|
||||
ExposedPorts: exposed,
|
||||
}
|
||||
payload.HostConfig.Binds = binds
|
||||
payload.HostConfig.PortBindings = bindings
|
||||
payload.HostConfig.Memory = int64(plan.Resources.MemoryMB) * 1024 * 1024
|
||||
payload.HostConfig.NanoCPUs = int64(plan.Resources.CPUCores * 1_000_000_000)
|
||||
payload.HostConfig.PidsLimit = &pidsLimit
|
||||
payload.HostConfig.CapDrop = []string{"ALL"}
|
||||
payload.HostConfig.SecurityOpt = []string{"no-new-privileges:true"}
|
||||
payload.HostConfig.NetworkMode = d.network
|
||||
payload.HostConfig.RestartPolicy = map[string]string{"Name": "no"}
|
||||
name := "dogama-" + strings.ToLower(plan.InstanceID)
|
||||
response, err = d.call(ctx, http.MethodPost, dockerAPIVersion+"/containers/create?name="+url.QueryEscape(name), payload, "application/json", 64<<10)
|
||||
if err != nil || response.status < 200 || response.status >= 300 {
|
||||
d.cleanupPartialCreate(ctx, name, plan)
|
||||
return "", errors.New("docker container creation failed")
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"Id"`
|
||||
}
|
||||
if json.Unmarshal(response.body, &created) != nil || created.ID == "" {
|
||||
d.cleanupPartialCreate(ctx, name, plan)
|
||||
return "", errors.New("docker returned an invalid container identity")
|
||||
}
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
func mergeDockerLabels(custom, technical map[string]string) map[string]string {
|
||||
result := make(map[string]string, len(custom)+len(technical))
|
||||
for key, value := range custom {
|
||||
result[key] = value
|
||||
}
|
||||
for key, value := range technical {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (d *dockerRuntime) cleanupPartialCreate(ctx context.Context, name string, plan agentwire.DeploymentPlan) {
|
||||
inspection, err := d.Inspect(ctx, name)
|
||||
if err != nil || inspection.Labels["io.dogama.managed"] != "true" || inspection.Labels["io.dogama.instance-id"] != plan.InstanceID || inspection.Labels["io.dogama.plan-digest"] != plan.PlanDigest {
|
||||
return
|
||||
}
|
||||
_ = d.Delete(ctx, inspection.ContainerID)
|
||||
}
|
||||
|
||||
func validPullResponse(body []byte) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
seen := false
|
||||
for {
|
||||
var event struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDetail *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errorDetail"`
|
||||
}
|
||||
err := decoder.Decode(&event)
|
||||
if errors.Is(err, io.EOF) {
|
||||
return seen
|
||||
}
|
||||
if err != nil || event.Error != "" || event.ErrorDetail != nil {
|
||||
return false
|
||||
}
|
||||
seen = true
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dockerRuntime) Start(ctx context.Context, id string) error {
|
||||
return d.expectNoContent(ctx, http.MethodPost, dockerAPIVersion+"/containers/"+url.PathEscape(id)+"/start")
|
||||
}
|
||||
|
||||
func (d *dockerRuntime) Stop(ctx context.Context, id string, timeout int) error {
|
||||
return d.expectNoContent(ctx, http.MethodPost, dockerAPIVersion+"/containers/"+url.PathEscape(id)+"/stop?t="+strconv.Itoa(timeout))
|
||||
}
|
||||
|
||||
func (d *dockerRuntime) Restart(ctx context.Context, id string, timeout int) error {
|
||||
return d.expectNoContent(ctx, http.MethodPost, dockerAPIVersion+"/containers/"+url.PathEscape(id)+"/restart?t="+strconv.Itoa(timeout))
|
||||
}
|
||||
|
||||
func (d *dockerRuntime) Delete(ctx context.Context, id string) error {
|
||||
return d.expectNoContent(ctx, http.MethodDelete, dockerAPIVersion+"/containers/"+url.PathEscape(id))
|
||||
}
|
||||
|
||||
func (d *dockerRuntime) Inspect(ctx context.Context, id string) (DockerInspection, error) {
|
||||
response, err := d.call(ctx, http.MethodGet, dockerAPIVersion+"/containers/"+url.PathEscape(id)+"/json", nil, "", 256<<10)
|
||||
if err != nil || response.status != http.StatusOK {
|
||||
return DockerInspection{}, errors.New("registered container inspection failed")
|
||||
}
|
||||
var payload struct {
|
||||
ID string `json:"Id"`
|
||||
Config struct {
|
||||
Labels map[string]string `json:"Labels"`
|
||||
} `json:"Config"`
|
||||
State struct {
|
||||
Running bool `json:"Running"`
|
||||
ExitCode int `json:"ExitCode"`
|
||||
Health *struct {
|
||||
Status string `json:"Status"`
|
||||
} `json:"Health"`
|
||||
} `json:"State"`
|
||||
}
|
||||
if json.Unmarshal(response.body, &payload) != nil || payload.ID == "" {
|
||||
return DockerInspection{}, errors.New("docker returned invalid inspection data")
|
||||
}
|
||||
health := "none"
|
||||
if payload.State.Health != nil {
|
||||
health = payload.State.Health.Status
|
||||
}
|
||||
return DockerInspection{ContainerID: payload.ID, Running: payload.State.Running, Health: health, ExitCode: payload.State.ExitCode, Labels: payload.Config.Labels}, nil
|
||||
}
|
||||
|
||||
func (d *dockerRuntime) Stats(ctx context.Context, id string) (agentwire.InstanceStats, error) {
|
||||
response, err := d.call(ctx, http.MethodGet, dockerAPIVersion+"/containers/"+url.PathEscape(id)+"/stats?stream=false&one-shot=true", nil, "", 512<<10)
|
||||
if err != nil || response.status != http.StatusOK {
|
||||
return agentwire.InstanceStats{}, errors.New("registered container statistics failed")
|
||||
}
|
||||
var payload struct {
|
||||
CPUStats struct {
|
||||
CPUUsage struct {
|
||||
TotalUsage uint64 `json:"total_usage"`
|
||||
} `json:"cpu_usage"`
|
||||
SystemUsage uint64 `json:"system_cpu_usage"`
|
||||
OnlineCPUs uint32 `json:"online_cpus"`
|
||||
} `json:"cpu_stats"`
|
||||
PreCPUStats struct {
|
||||
CPUUsage struct {
|
||||
TotalUsage uint64 `json:"total_usage"`
|
||||
} `json:"cpu_usage"`
|
||||
SystemUsage uint64 `json:"system_cpu_usage"`
|
||||
} `json:"precpu_stats"`
|
||||
MemoryStats struct{ Usage, Limit uint64 } `json:"memory_stats"`
|
||||
}
|
||||
if json.Unmarshal(response.body, &payload) != nil {
|
||||
return agentwire.InstanceStats{}, errors.New("docker returned invalid statistics")
|
||||
}
|
||||
cpuDelta := payload.CPUStats.CPUUsage.TotalUsage - payload.PreCPUStats.CPUUsage.TotalUsage
|
||||
systemDelta := payload.CPUStats.SystemUsage - payload.PreCPUStats.SystemUsage
|
||||
percentage := 0.0
|
||||
if systemDelta > 0 {
|
||||
cpus := payload.CPUStats.OnlineCPUs
|
||||
if cpus == 0 {
|
||||
cpus = 1
|
||||
}
|
||||
percentage = float64(cpuDelta) / float64(systemDelta) * float64(cpus) * 100
|
||||
}
|
||||
return agentwire.InstanceStats{CPUPercentage: percentage, MemoryBytes: payload.MemoryStats.Usage, MemoryLimit: payload.MemoryStats.Limit}, nil
|
||||
}
|
||||
|
||||
type dockerResponse struct {
|
||||
status int
|
||||
body []byte
|
||||
}
|
||||
|
||||
func (d *dockerRuntime) call(ctx context.Context, method, path string, input any, contentType string, limit int64) (dockerResponse, error) {
|
||||
var body io.Reader
|
||||
if input != nil {
|
||||
encoded, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return dockerResponse{}, err
|
||||
}
|
||||
body = bytes.NewReader(encoded)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, "http://docker"+path, body)
|
||||
if err != nil {
|
||||
return dockerResponse{}, err
|
||||
}
|
||||
if contentType != "" {
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
response, err := d.client.Do(request)
|
||||
if err != nil {
|
||||
return dockerResponse{}, errors.New("docker daemon is unavailable")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 16))
|
||||
if err != nil || response.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "OK" {
|
||||
return fmt.Errorf("docker daemon ping failed with HTTP %d", response.StatusCode)
|
||||
responseBody, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
|
||||
if err != nil || int64(len(responseBody)) > limit {
|
||||
return dockerResponse{}, errors.New("docker response is invalid")
|
||||
}
|
||||
return dockerResponse{status: response.StatusCode, body: responseBody}, nil
|
||||
}
|
||||
|
||||
func (d *dockerRuntime) expectNoContent(ctx context.Context, method, path string) error {
|
||||
response, err := d.call(ctx, method, path, nil, "", 64<<10)
|
||||
if err != nil || (response.status != http.StatusNoContent && response.status != http.StatusNotModified) {
|
||||
return errors.New("docker lifecycle operation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
)
|
||||
|
||||
func TestDockerPingerUsesConfiguredUnixSocket(t *testing.T) {
|
||||
@@ -23,7 +28,7 @@ func TestDockerPingerUsesConfiguredUnixSocket(t *testing.T) {
|
||||
})}
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(func() { _ = server.Shutdown(context.Background()) })
|
||||
pinger, err := NewDockerPinger(socket)
|
||||
pinger, err := NewDockerRuntime(socket, "dogama-games")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -32,8 +37,77 @@ func TestDockerPingerUsesConfiguredUnixSocket(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
|
||||
socket := filepath.Join(t.TempDir(), "docker.sock")
|
||||
listener, err := net.Listen("unix", socket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createdBodies := make(chan []byte, 1)
|
||||
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/v1.41/images/create":
|
||||
_, _ = w.Write([]byte("{}\n"))
|
||||
case r.URL.Path == "/v1.41/containers/json":
|
||||
_, _ = w.Write([]byte("[]"))
|
||||
case r.URL.Path == "/v1.41/containers/create":
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
createdBodies <- body
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"Id":"container-1"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})}
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(func() { _ = server.Shutdown(context.Background()) })
|
||||
runtime, err := NewDockerRuntime(socket, "dogama-games")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan := agentwire.DeploymentPlan{
|
||||
InstanceID: "abcdefghijklmnopqrstuvwx", TemplateID: "palworld-official", TemplateVersion: "1.0.0", PlanDigest: strings.Repeat("a", 64), Image: "example.invalid/game:1",
|
||||
Ports: []agentwire.PlanPort{{ID: "game", Protocol: "udp", ContainerPort: 8211, HostPort: 38211, Publish: true}},
|
||||
Mounts: []agentwire.PlanMount{{ID: "saved", HostPath: "/srv/games/saved", ContainerPath: "/game/saved"}}, Resources: agentwire.PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10}, Labels: map[string]string{"dashboard.name": "Summer"}, User: "1000:1001",
|
||||
}
|
||||
if err := runtime.CheckPorts(context.Background(), plan.Ports); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := runtime.Create(context.Background(), plan, []AssetMount{{HostPath: "/srv/games/.dogama/helper", ContainerPath: "/pal/helper.sh"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "container-1" {
|
||||
t.Fatalf("container ID = %q", id)
|
||||
}
|
||||
var payload struct {
|
||||
User string `json:"User"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
HostConfig struct {
|
||||
NetworkMode string `json:"NetworkMode"`
|
||||
CapDrop []string `json:"CapDrop"`
|
||||
SecurityOpt []string `json:"SecurityOpt"`
|
||||
Memory int64 `json:"Memory"`
|
||||
NanoCPUs int64 `json:"NanoCpus"`
|
||||
} `json:"HostConfig"`
|
||||
}
|
||||
if err := json.Unmarshal(<-createdBodies, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.HostConfig.NetworkMode != "dogama-games" || len(payload.HostConfig.CapDrop) != 1 || payload.HostConfig.CapDrop[0] != "ALL" || len(payload.HostConfig.SecurityOpt) != 1 || payload.HostConfig.Memory <= 0 || payload.HostConfig.NanoCPUs <= 0 {
|
||||
t.Fatalf("insecure Docker host config: %#v", payload.HostConfig)
|
||||
}
|
||||
if payload.Labels["io.dogama.instance-id"] != plan.InstanceID || payload.Labels["io.dogama.plan-digest"] != plan.PlanDigest {
|
||||
t.Fatalf("binding labels = %#v", payload.Labels)
|
||||
}
|
||||
if payload.Labels["dashboard.name"] != "Summer" || payload.User != "1000:1001" {
|
||||
t.Fatalf("custom Docker configuration = %#v user=%q", payload.Labels, payload.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerPingerDoesNotExposeConnectionDetails(t *testing.T) {
|
||||
pinger, err := NewDockerPinger(filepath.Join(t.TempDir(), "missing.sock"))
|
||||
pinger, err := NewDockerRuntime(filepath.Join(t.TempDir(), "missing.sock"), "dogama-games")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -58,17 +58,63 @@ func (p *PathPolicy) Resolve(path string) (string, error) {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
for _, root := range p.roots {
|
||||
relative, err := filepath.Rel(root, resolved)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))) {
|
||||
if withinRoot(root, resolved) {
|
||||
return resolved, nil
|
||||
}
|
||||
}
|
||||
return "", ErrPathOutsideRoots
|
||||
}
|
||||
|
||||
// Prepare creates a deployment mount below an allowed root one directory at a
|
||||
// time and refuses every symlink in the path. Existing data is never replaced.
|
||||
func (p *PathPolicy) Prepare(path string) (string, error) {
|
||||
if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
for _, root := range p.roots {
|
||||
if !withinRoot(root, path) || path == root {
|
||||
continue
|
||||
}
|
||||
relative, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
current := root
|
||||
valid := true
|
||||
for _, component := range strings.Split(relative, string(filepath.Separator)) {
|
||||
if component == "" || component == "." || component == ".." {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
current = filepath.Join(current, component)
|
||||
info, err := os.Lstat(current)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if err := os.Mkdir(current, 0o700); err != nil {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
info, err = os.Lstat(current)
|
||||
}
|
||||
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err == nil && withinRoot(root, resolved) {
|
||||
return resolved, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ErrPathOutsideRoots
|
||||
}
|
||||
|
||||
func withinRoot(root, path string) bool {
|
||||
relative, err := filepath.Rel(root, path)
|
||||
return err == nil && (relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))))
|
||||
}
|
||||
|
||||
// RootCount returns the number of canonical roots without disclosing them.
|
||||
func (p *PathPolicy) RootCount() int {
|
||||
return len(p.roots)
|
||||
|
||||
@@ -70,3 +70,23 @@ func TestPathPolicyRejectsSymlinkEscape(t *testing.T) {
|
||||
t.Fatalf("symlink escape error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathPolicyPreparesOnlyNonSymlinkDirectoriesBelowRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
policy, err := NewPathPolicy([]string{root})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepared := filepath.Join(root, "instance-a", "saved")
|
||||
resolved, err := policy.Prepare(prepared)
|
||||
if err != nil || resolved != prepared {
|
||||
t.Fatalf("Prepare() = %q, %v", resolved, err)
|
||||
}
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
if _, err := policy.Prepare(filepath.Join(root, "escape", "saved")); err == nil {
|
||||
t.Fatal("symlink escape was prepared")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
)
|
||||
|
||||
// PlanPolicy independently binds privileged deployment fields to validated,
|
||||
// embedded template snapshots. The agent never trusts a caller-supplied image,
|
||||
// container port, mount destination or security-sensitive command by itself.
|
||||
type PlanPolicy struct {
|
||||
snapshots map[string]catalog.Snapshot
|
||||
assets fs.FS
|
||||
}
|
||||
|
||||
func NewPlanPolicy(snapshots []catalog.Snapshot, assets fs.FS) (*PlanPolicy, error) {
|
||||
if len(snapshots) == 0 {
|
||||
return nil, errors.New("agent plan policy requires validated templates")
|
||||
}
|
||||
if assets == nil {
|
||||
return nil, errors.New("agent plan policy requires embedded assets")
|
||||
}
|
||||
policy := &PlanPolicy{snapshots: make(map[string]catalog.Snapshot, len(snapshots)), assets: assets}
|
||||
for _, snapshot := range snapshots {
|
||||
key := snapshot.Template.ID + "@" + snapshot.Template.Version
|
||||
if _, exists := policy.snapshots[key]; exists {
|
||||
return nil, errors.New("duplicate agent template snapshot")
|
||||
}
|
||||
policy.snapshots[key] = snapshot
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
type ApprovedAsset struct {
|
||||
Destination string
|
||||
SHA256 string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
func (p *PlanPolicy) Assets(plan agentwire.DeploymentPlan) ([]ApprovedAsset, error) {
|
||||
snapshot, ok := p.snapshots[plan.TemplateID+"@"+plan.TemplateVersion]
|
||||
if !ok || snapshot.Digest != plan.TemplateDigest {
|
||||
return nil, errors.New("unknown template snapshot")
|
||||
}
|
||||
result := make([]ApprovedAsset, 0, len(snapshot.Template.Container.Assets))
|
||||
for _, asset := range snapshot.Template.Container.Assets {
|
||||
if !asset.ReadOnly {
|
||||
return nil, errors.New("writable template asset is not allowed")
|
||||
}
|
||||
content, err := fs.ReadFile(p.assets, path.Join(snapshot.AssetRoot, asset.Source))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read approved template asset: %w", err)
|
||||
}
|
||||
result = append(result, ApprovedAsset{Destination: asset.Destination, SHA256: asset.SHA256, Content: content})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *PlanPolicy) Validate(plan agentwire.DeploymentPlan) error {
|
||||
if p == nil || plan.Validate() != nil {
|
||||
return errors.New("invalid deployment plan")
|
||||
}
|
||||
snapshot, ok := p.snapshots[plan.TemplateID+"@"+plan.TemplateVersion]
|
||||
if !ok || snapshot.Digest != plan.TemplateDigest {
|
||||
return errors.New("unknown template snapshot")
|
||||
}
|
||||
template := snapshot.Template
|
||||
imagePrefix := template.Container.Image + ":"
|
||||
if !strings.HasPrefix(plan.Image, imagePrefix) || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || !reflect.DeepEqual(plan.Arguments, template.Container.Arguments) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds {
|
||||
return errors.New("container plan differs from template")
|
||||
}
|
||||
if plan.Resources.CPUCores < template.Requirements.Minimum.CPUCores || plan.Resources.MemoryMB < template.Requirements.Minimum.MemoryMB || plan.Resources.StorageGB < template.Requirements.Minimum.StorageGB {
|
||||
return errors.New("container resources are below template minimum")
|
||||
}
|
||||
if len(plan.Ports) != len(template.Container.Ports) || len(plan.Mounts) != len(template.Storage.Mounts) {
|
||||
return errors.New("container plan shape differs from template")
|
||||
}
|
||||
ports := make(map[string]agentwire.PlanPort, len(plan.Ports))
|
||||
for _, port := range plan.Ports {
|
||||
ports[port.ID] = port
|
||||
}
|
||||
for _, expected := range template.Container.Ports {
|
||||
actual, ok := ports[expected.ID]
|
||||
if !ok || actual.Protocol != expected.Protocol || actual.ContainerPort != expected.ContainerPort || actual.Publish != expected.Publish {
|
||||
return errors.New("container port differs from template")
|
||||
}
|
||||
}
|
||||
mounts := make(map[string]agentwire.PlanMount, len(plan.Mounts))
|
||||
for _, mount := range plan.Mounts {
|
||||
mounts[mount.ID] = mount
|
||||
}
|
||||
for _, expected := range template.Storage.Mounts {
|
||||
actual, ok := mounts[expected.ID]
|
||||
if !ok || actual.ContainerPath != expected.ContainerPath || actual.ReadOnly != expected.ReadOnly {
|
||||
return errors.New("container mount differs from template")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -68,6 +68,67 @@ func (r *Registry) List() []RegisteredInstance {
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *Registry) Get(instanceID string) (RegisteredInstance, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, entry := range r.instances {
|
||||
if entry.InstanceID == instanceID {
|
||||
return entry, true
|
||||
}
|
||||
}
|
||||
return RegisteredInstance{}, false
|
||||
}
|
||||
|
||||
func (r *Registry) Register(entry RegisteredInstance) error {
|
||||
if entry.InstanceID == "" || entry.ContainerID == "" || entry.PlanDigest == "" {
|
||||
return errors.New("incomplete agent registration")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, existing := range r.instances {
|
||||
if existing.InstanceID == entry.InstanceID || existing.ContainerID == entry.ContainerID {
|
||||
if existing == entry {
|
||||
return nil
|
||||
}
|
||||
return errors.New("instance registration conflict")
|
||||
}
|
||||
}
|
||||
instances := append(append([]RegisteredInstance(nil), r.instances...), entry)
|
||||
return r.saveLocked(instances)
|
||||
}
|
||||
|
||||
func (r *Registry) Remove(instanceID string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
instances := make([]RegisteredInstance, 0, len(r.instances))
|
||||
found := false
|
||||
for _, entry := range r.instances {
|
||||
if entry.InstanceID == instanceID {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
instances = append(instances, entry)
|
||||
}
|
||||
if !found {
|
||||
return errors.New("instance is not registered")
|
||||
}
|
||||
return r.saveLocked(instances)
|
||||
}
|
||||
|
||||
// Replace atomically persists a new binding for an already registered instance.
|
||||
func (r *Registry) Replace(entry RegisteredInstance) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
instances := append([]RegisteredInstance(nil), r.instances...)
|
||||
for index := range instances {
|
||||
if instances[index].InstanceID == entry.InstanceID {
|
||||
instances[index] = entry
|
||||
return r.saveLocked(instances)
|
||||
}
|
||||
}
|
||||
return errors.New("instance registration not found")
|
||||
}
|
||||
|
||||
func (r *Registry) load() error {
|
||||
info, err := os.Lstat(r.path)
|
||||
if err != nil {
|
||||
@@ -92,6 +153,10 @@ func (r *Registry) load() error {
|
||||
func (r *Registry) save(instances []RegisteredInstance) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.saveLocked(instances)
|
||||
}
|
||||
|
||||
func (r *Registry) saveLocked(instances []RegisteredInstance) error {
|
||||
payload := registryPayload{Version: registryVersion, Instances: append([]RegisteredInstance(nil), instances...)}
|
||||
envelope := registryEnvelope{Payload: payload, MAC: r.mac(payload)}
|
||||
body, err := json.Marshal(envelope)
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"net/http"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
)
|
||||
|
||||
const maxDiskPaths = 16
|
||||
@@ -17,17 +19,46 @@ const maxDiskPaths = 16
|
||||
type service struct {
|
||||
paths *PathPolicy
|
||||
registry *Registry
|
||||
docker DockerPinger
|
||||
plans *PlanPolicy
|
||||
docker DockerRuntime
|
||||
logger *slog.Logger
|
||||
disk DiskChecker
|
||||
}
|
||||
|
||||
type DiskChecker interface {
|
||||
AvailableBytes(string) (uint64, uint64, error)
|
||||
}
|
||||
type statfsDiskChecker struct{}
|
||||
|
||||
func (statfsDiskChecker) AvailableBytes(path string) (uint64, uint64, error) {
|
||||
var filesystem syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &filesystem); err != nil || filesystem.Bsize <= 0 {
|
||||
return 0, 0, errors.New("filesystem unavailable")
|
||||
}
|
||||
blockSize := uint64(filesystem.Bsize)
|
||||
return blockSize * filesystem.Bavail, blockSize * filesystem.Blocks, nil
|
||||
}
|
||||
|
||||
// NewHandler constructs the complete authenticated private agent API.
|
||||
func NewHandler(authenticator *Authenticator, paths *PathPolicy, registry *Registry, docker DockerPinger, logger *slog.Logger) http.Handler {
|
||||
server := &service{paths: paths, registry: registry, docker: docker, logger: logger}
|
||||
func NewHandler(authenticator *Authenticator, paths *PathPolicy, plans *PlanPolicy, registry *Registry, docker DockerRuntime, logger *slog.Logger) http.Handler {
|
||||
return NewHandlerWithDiskChecker(authenticator, paths, plans, registry, docker, statfsDiskChecker{}, logger)
|
||||
}
|
||||
|
||||
func NewHandlerWithDiskChecker(authenticator *Authenticator, paths *PathPolicy, plans *PlanPolicy, registry *Registry, docker DockerRuntime, disk DiskChecker, logger *slog.Logger) http.Handler {
|
||||
server := &service{paths: paths, plans: plans, registry: registry, docker: docker, disk: disk, logger: logger}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /v1/health", server.health)
|
||||
mux.HandleFunc("POST /v1/check-disk", server.checkDisk)
|
||||
mux.HandleFunc("GET /v1/instances", server.listInstances)
|
||||
mux.HandleFunc("POST /v1/check-ports", server.checkPorts)
|
||||
mux.HandleFunc("POST /v1/instances", server.createInstance)
|
||||
mux.HandleFunc("PUT /v1/instances/{id}", server.replaceInstance)
|
||||
mux.HandleFunc("GET /v1/instances/{id}", server.inspectInstance)
|
||||
mux.HandleFunc("GET /v1/instances/{id}/stats", server.instanceStats)
|
||||
mux.HandleFunc("POST /v1/instances/{id}/start", server.startInstance)
|
||||
mux.HandleFunc("POST /v1/instances/{id}/stop", server.stopInstance)
|
||||
mux.HandleFunc("POST /v1/instances/{id}/restart", server.restartInstance)
|
||||
mux.HandleFunc("DELETE /v1/instances/{id}", server.deleteInstance)
|
||||
return server.headers(authenticator.Middleware(mux))
|
||||
}
|
||||
|
||||
@@ -69,16 +100,15 @@ func (s *service) checkDisk(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
seen[canonical] = struct{}{}
|
||||
var filesystem syscall.Statfs_t
|
||||
if err := syscall.Statfs(canonical, &filesystem); err != nil || filesystem.Bsize <= 0 {
|
||||
available, total, err := s.disk.AvailableBytes(canonical)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "path_unavailable", "A requested path is unavailable.")
|
||||
return
|
||||
}
|
||||
blockSize := uint64(filesystem.Bsize)
|
||||
response.Paths = append(response.Paths, diskInfo{
|
||||
Path: requested,
|
||||
BytesAvailable: blockSize * filesystem.Bavail,
|
||||
BytesTotal: blockSize * filesystem.Blocks,
|
||||
BytesAvailable: available,
|
||||
BytesTotal: total,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
@@ -90,6 +120,289 @@ func (s *service) listInstances(w http.ResponseWriter, _ *http.Request) {
|
||||
}{Instances: s.registry.List()})
|
||||
}
|
||||
|
||||
func (s *service) checkPorts(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
Ports []agentwire.PlanPort `json:"ports"`
|
||||
}
|
||||
if decodeJSON(r.Body, &request) != nil || len(request.Ports) == 0 || len(request.Ports) > 32 {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
|
||||
return
|
||||
}
|
||||
if err := s.docker.CheckPorts(r.Context(), request.Ports); err != nil {
|
||||
writeProblem(w, http.StatusConflict, "port_unavailable", "A requested host port is unavailable.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"available": true})
|
||||
}
|
||||
|
||||
func (s *service) createInstance(w http.ResponseWriter, r *http.Request) {
|
||||
var plan agentwire.DeploymentPlan
|
||||
if decodeJSON(r.Body, &plan) != nil || s.plans.Validate(plan) != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "invalid_plan", "The deployment plan is invalid.")
|
||||
return
|
||||
}
|
||||
for index := range plan.Mounts {
|
||||
mount := &plan.Mounts[index]
|
||||
canonical, err := s.paths.Prepare(mount.HostPath)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "path_not_allowed", "A deployment path is not allowed.")
|
||||
return
|
||||
}
|
||||
mount.HostPath = canonical
|
||||
available, _, diskErr := s.disk.AvailableBytes(canonical)
|
||||
if diskErr != nil || available < uint64(plan.Resources.StorageGB)*1024*1024*1024 {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "insufficient_disk", "A deployment path has insufficient disk space.")
|
||||
return
|
||||
}
|
||||
}
|
||||
if existing, ok := s.registry.Get(plan.InstanceID); ok {
|
||||
if existing.PlanDigest != plan.PlanDigest {
|
||||
writeProblem(w, http.StatusConflict, "registration_conflict", "The instance registration conflicts with the deployment plan.")
|
||||
return
|
||||
}
|
||||
state, err := s.boundState(r.Context(), existing)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusConflict, "registration_mismatch", "The registered container binding is invalid.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, state)
|
||||
return
|
||||
}
|
||||
if err := s.docker.CheckPorts(r.Context(), plan.Ports); err != nil {
|
||||
writeProblem(w, http.StatusConflict, "port_unavailable", "A requested host port is unavailable.")
|
||||
return
|
||||
}
|
||||
assets, err := s.prepareAssets(plan)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "asset_prepare_failed", "Approved template assets could not be prepared.")
|
||||
return
|
||||
}
|
||||
containerID, err := s.docker.Create(r.Context(), plan, assets)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadGateway, "container_create_failed", "The container could not be created.")
|
||||
return
|
||||
}
|
||||
entry := RegisteredInstance{InstanceID: plan.InstanceID, ContainerID: containerID, PlanDigest: plan.PlanDigest}
|
||||
if err := s.registry.Register(entry); err != nil {
|
||||
_ = s.docker.Delete(r.Context(), containerID)
|
||||
writeProblem(w, http.StatusInternalServerError, "registration_failed", "The container registration could not be persisted.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: containerID, PlanDigest: plan.PlanDigest, Health: "stopped"})
|
||||
}
|
||||
|
||||
func (s *service) replaceInstance(w http.ResponseWriter, r *http.Request) {
|
||||
var plan agentwire.DeploymentPlan
|
||||
id := r.PathValue("id")
|
||||
if decodeJSON(r.Body, &plan) != nil || plan.InstanceID != id || s.plans.Validate(plan) != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "invalid_plan", "The replacement plan is invalid.")
|
||||
return
|
||||
}
|
||||
entry, ok := s.registry.Get(id)
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusNotFound, "registration_not_found", "The instance is not registered.")
|
||||
return
|
||||
}
|
||||
oldState, err := s.boundState(r.Context(), entry)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusConflict, "registration_mismatch", "The registered container binding is invalid.")
|
||||
return
|
||||
}
|
||||
for index := range plan.Mounts {
|
||||
canonical, pathErr := s.paths.Resolve(plan.Mounts[index].HostPath)
|
||||
if pathErr != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "path_not_allowed", "A deployment path is not allowed.")
|
||||
return
|
||||
}
|
||||
plan.Mounts[index].HostPath = canonical
|
||||
}
|
||||
assets, err := s.prepareAssets(plan)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "asset_prepare_failed", "Approved template assets could not be prepared.")
|
||||
return
|
||||
}
|
||||
if oldState.Running {
|
||||
if err := s.docker.Stop(r.Context(), entry.ContainerID, plan.StopTimeoutSeconds); err != nil {
|
||||
writeProblem(w, http.StatusBadGateway, "container_stop_failed", "The previous container could not be stopped.")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.docker.Delete(r.Context(), entry.ContainerID); err != nil {
|
||||
writeProblem(w, http.StatusBadGateway, "container_delete_failed", "The previous container could not be removed.")
|
||||
return
|
||||
}
|
||||
containerID, err := s.docker.Create(r.Context(), plan, assets)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadGateway, "container_replace_failed", "The replacement container could not be created; persistent data was preserved.")
|
||||
return
|
||||
}
|
||||
replacement := RegisteredInstance{InstanceID: id, ContainerID: containerID, PlanDigest: plan.PlanDigest}
|
||||
if err := s.registry.Replace(replacement); err != nil {
|
||||
_ = s.docker.Delete(r.Context(), containerID)
|
||||
writeProblem(w, http.StatusInternalServerError, "registration_failed", "The replacement registration could not be persisted.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, agentwire.InstanceState{InstanceID: id, ContainerID: containerID, PlanDigest: plan.PlanDigest, Health: "stopped"})
|
||||
}
|
||||
|
||||
func (s *service) inspectInstance(w http.ResponseWriter, r *http.Request) {
|
||||
entry, ok := s.registration(w, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
state, err := s.boundState(r.Context(), entry)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusConflict, "registration_mismatch", "The registered container binding is invalid.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (s *service) instanceStats(w http.ResponseWriter, r *http.Request) {
|
||||
entry, ok := s.registration(w, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := s.boundState(r.Context(), entry); err != nil {
|
||||
writeProblem(w, http.StatusConflict, "registration_mismatch", "The registered container binding is invalid.")
|
||||
return
|
||||
}
|
||||
stats, err := s.docker.Stats(r.Context(), entry.ContainerID)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadGateway, "stats_unavailable", "Container statistics are unavailable.")
|
||||
return
|
||||
}
|
||||
stats.InstanceID = entry.InstanceID
|
||||
writeJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (s *service) startInstance(w http.ResponseWriter, r *http.Request) {
|
||||
entry, ok := s.registration(w, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
state, err := s.boundState(r.Context(), entry)
|
||||
if err != nil {
|
||||
s.bindingProblem(w)
|
||||
return
|
||||
}
|
||||
if !state.Running {
|
||||
if err := s.docker.Start(r.Context(), entry.ContainerID); err != nil {
|
||||
writeProblem(w, http.StatusBadGateway, "start_failed", "The registered container could not be started.")
|
||||
return
|
||||
}
|
||||
state.Running, state.Health = true, "starting"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (s *service) stopInstance(w http.ResponseWriter, r *http.Request) {
|
||||
entry, ok := s.registration(w, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
state, err := s.boundState(r.Context(), entry)
|
||||
if err != nil {
|
||||
s.bindingProblem(w)
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
}
|
||||
if decodeJSON(r.Body, &request) != nil || request.TimeoutSeconds < 5 || request.TimeoutSeconds > 900 {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid_request", "The stop timeout is invalid.")
|
||||
return
|
||||
}
|
||||
if state.Running {
|
||||
if err := s.docker.Stop(r.Context(), entry.ContainerID, request.TimeoutSeconds); err != nil {
|
||||
writeProblem(w, http.StatusBadGateway, "stop_failed", "The registered container could not be stopped.")
|
||||
return
|
||||
}
|
||||
state.Running, state.Ready, state.Health = false, false, "stopped"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (s *service) restartInstance(w http.ResponseWriter, r *http.Request) {
|
||||
entry, ok := s.registration(w, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
state, err := s.boundState(r.Context(), entry)
|
||||
if err != nil {
|
||||
s.bindingProblem(w)
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
}
|
||||
if decodeJSON(r.Body, &request) != nil || request.TimeoutSeconds < 5 || request.TimeoutSeconds > 900 {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid_request", "The restart timeout is invalid.")
|
||||
return
|
||||
}
|
||||
if state.Running {
|
||||
err = s.docker.Restart(r.Context(), entry.ContainerID, request.TimeoutSeconds)
|
||||
} else {
|
||||
err = s.docker.Start(r.Context(), entry.ContainerID)
|
||||
}
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadGateway, "restart_failed", "The registered container could not be restarted.")
|
||||
return
|
||||
}
|
||||
state.Running, state.Ready, state.Health = true, false, "starting"
|
||||
writeJSON(w, http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (s *service) deleteInstance(w http.ResponseWriter, r *http.Request) {
|
||||
entry, ok := s.registry.Get(r.PathValue("id"))
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
state, err := s.boundState(r.Context(), entry)
|
||||
if err != nil {
|
||||
s.bindingProblem(w)
|
||||
return
|
||||
}
|
||||
if state.Running {
|
||||
writeProblem(w, http.StatusConflict, "instance_running", "The instance must be stopped before container deletion.")
|
||||
return
|
||||
}
|
||||
if err := s.docker.Delete(r.Context(), entry.ContainerID); err != nil {
|
||||
writeProblem(w, http.StatusBadGateway, "delete_failed", "The registered container could not be deleted.")
|
||||
return
|
||||
}
|
||||
if err := s.registry.Remove(entry.InstanceID); err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "registry_update_failed", "The agent registry could not be updated.")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *service) registration(w http.ResponseWriter, id string) (RegisteredInstance, bool) {
|
||||
entry, ok := s.registry.Get(id)
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusNotFound, "instance_not_registered", "The instance is not registered.")
|
||||
}
|
||||
return entry, ok
|
||||
}
|
||||
|
||||
func (s *service) boundState(ctx context.Context, entry RegisteredInstance) (agentwire.InstanceState, error) {
|
||||
inspection, err := s.docker.Inspect(ctx, entry.ContainerID)
|
||||
if err != nil || inspection.ContainerID != entry.ContainerID || inspection.Labels["io.dogama.managed"] != "true" || inspection.Labels["io.dogama.instance-id"] != entry.InstanceID || inspection.Labels["io.dogama.plan-digest"] != entry.PlanDigest {
|
||||
return agentwire.InstanceState{}, errors.New("registration binding mismatch")
|
||||
}
|
||||
health := inspection.Health
|
||||
if !inspection.Running {
|
||||
health = "stopped"
|
||||
}
|
||||
return agentwire.InstanceState{InstanceID: entry.InstanceID, ContainerID: entry.ContainerID, PlanDigest: entry.PlanDigest, Running: inspection.Running, Ready: inspection.Running && inspection.Health == "healthy", Health: health, ExitCode: inspection.ExitCode}, nil
|
||||
}
|
||||
|
||||
func (s *service) bindingProblem(w http.ResponseWriter) {
|
||||
writeProblem(w, http.StatusConflict, "registration_mismatch", "The registered container binding is invalid.")
|
||||
}
|
||||
|
||||
func (s *service) headers(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
@@ -11,15 +11,47 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agent"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentclient"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
)
|
||||
|
||||
type fakeDocker struct {
|
||||
err error
|
||||
err error
|
||||
inspection agent.DockerInspection
|
||||
}
|
||||
|
||||
func (d fakeDocker) Ping(context.Context) error { return d.err }
|
||||
type fakeDisk struct{}
|
||||
|
||||
func (fakeDisk) AvailableBytes(string) (uint64, uint64, error) { return 1 << 50, 1 << 50, nil }
|
||||
|
||||
func (d fakeDocker) Ping(context.Context) error { return d.err }
|
||||
func (d fakeDocker) CheckPorts(context.Context, []agentwire.PlanPort) error { return d.err }
|
||||
func (d fakeDocker) Create(_ context.Context, plan agentwire.DeploymentPlan, _ []agent.AssetMount) (string, error) {
|
||||
if d.err != nil {
|
||||
return "", d.err
|
||||
}
|
||||
return "container-" + plan.InstanceID, nil
|
||||
}
|
||||
func (d fakeDocker) Start(context.Context, string) error { return d.err }
|
||||
func (d fakeDocker) Stop(context.Context, string, int) error { return d.err }
|
||||
func (d fakeDocker) Restart(context.Context, string, int) error { return d.err }
|
||||
func (d fakeDocker) Delete(context.Context, string) error { return d.err }
|
||||
func (d fakeDocker) Inspect(_ context.Context, id string) (agent.DockerInspection, error) {
|
||||
if d.err != nil {
|
||||
return agent.DockerInspection{}, d.err
|
||||
}
|
||||
inspection := d.inspection
|
||||
if inspection.ContainerID == "" {
|
||||
inspection = agent.DockerInspection{ContainerID: id, Labels: map[string]string{}}
|
||||
}
|
||||
return inspection, nil
|
||||
}
|
||||
func (d fakeDocker) Stats(context.Context, string) (agentwire.InstanceStats, error) {
|
||||
return agentwire.InstanceStats{MemoryBytes: 42}, d.err
|
||||
}
|
||||
|
||||
func TestAuthenticatedAgentClientOperations(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
@@ -89,7 +121,98 @@ func TestAgentReportsDockerUnavailableWithoutDetails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func newTestHandler(t *testing.T, root string, secret []byte, docker agent.DockerPinger) http.Handler {
|
||||
func TestAgentCreatesOnlyValidatedBoundInstances(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
secret := bytes.Repeat([]byte{0x31}, 32)
|
||||
instanceID := "abcdefghijklmnopqrstuvwx"
|
||||
plan := testPlan(t, instanceID, root)
|
||||
docker := fakeDocker{inspection: agent.DockerInspection{
|
||||
ContainerID: "container-" + instanceID,
|
||||
Labels: map[string]string{"io.dogama.managed": "true", "io.dogama.instance-id": instanceID, "io.dogama.plan-digest": plan.PlanDigest},
|
||||
}}
|
||||
server := httptest.NewServer(newTestHandler(t, root, secret, docker))
|
||||
t.Cleanup(server.Close)
|
||||
client, err := agentclient.New(server.URL, secret, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := client.CreateInstance(context.Background(), plan)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.InstanceID != instanceID || created.ContainerID == "" {
|
||||
t.Fatalf("created state = %#v", created)
|
||||
}
|
||||
if _, err := client.StartInstance(context.Background(), instanceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.StopInstance(context.Background(), instanceID, 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.DeleteContainer(context.Background(), instanceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.InspectInstance(context.Background(), instanceID); err == nil {
|
||||
t.Fatal("deleted registration remained targetable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRejectsPlanSubstitutionAndEscapingMount(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
secret := bytes.Repeat([]byte{0x31}, 32)
|
||||
server := httptest.NewServer(newTestHandler(t, root, secret, fakeDocker{}))
|
||||
t.Cleanup(server.Close)
|
||||
client, err := agentclient.New(server.URL, secret, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan := testPlan(t, "abcdefghijklmnopqrstuvwx", root)
|
||||
plan.Image = "attacker.example/other:latest"
|
||||
digest, _ := plan.CanonicalDigest()
|
||||
plan.PlanDigest = digest
|
||||
_, err = client.CreateInstance(context.Background(), plan)
|
||||
var problem *agentclient.ProblemError
|
||||
if !errors.As(err, &problem) || problem.Code != "invalid_plan" {
|
||||
t.Fatalf("substitution error = %#v", err)
|
||||
}
|
||||
plan = testPlan(t, "zyxwvutsrqponmlkjihgfedc", root)
|
||||
plan.Mounts[0].HostPath = filepath.Dir(root)
|
||||
digest, _ = plan.CanonicalDigest()
|
||||
plan.PlanDigest = digest
|
||||
_, err = client.CreateInstance(context.Background(), plan)
|
||||
if !errors.As(err, &problem) || problem.Code != "path_not_allowed" {
|
||||
t.Fatalf("escaping path error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testPlan(t *testing.T, instanceID, root string) agentwire.DeploymentPlan {
|
||||
t.Helper()
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := snapshots[0].Template
|
||||
plan := agentwire.DeploymentPlan{
|
||||
SchemaVersion: 1, InstanceID: instanceID, TemplateID: template.ID, TemplateVersion: template.Version,
|
||||
TemplateDigest: snapshots[0].Digest, Image: template.Container.Image + ":" + template.Container.Tag,
|
||||
Entrypoint: template.Container.Entrypoint, Arguments: template.Container.Arguments,
|
||||
Resources: agentwire.PlanResource{CPUCores: template.Requirements.Recommended.CPUCores, MemoryMB: template.Requirements.Recommended.MemoryMB, StorageGB: template.Requirements.Recommended.StorageGB}, StopTimeoutSeconds: template.Container.StopTimeoutSeconds,
|
||||
}
|
||||
for _, port := range template.Container.Ports {
|
||||
plan.Ports = append(plan.Ports, agentwire.PlanPort{ID: port.ID, Protocol: port.Protocol, ContainerPort: port.ContainerPort, HostPort: map[bool]int{true: 38211, false: 0}[port.Publish], Publish: port.Publish})
|
||||
}
|
||||
for _, mount := range template.Storage.Mounts {
|
||||
plan.Mounts = append(plan.Mounts, agentwire.PlanMount{ID: mount.ID, HostPath: filepath.Join(root, "instance", mount.ID), ContainerPath: mount.ContainerPath, ReadOnly: mount.ReadOnly})
|
||||
}
|
||||
digest, err := plan.CanonicalDigest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan.PlanDigest = digest
|
||||
return plan
|
||||
}
|
||||
|
||||
func newTestHandler(t *testing.T, root string, secret []byte, docker agent.DockerRuntime) http.Handler {
|
||||
t.Helper()
|
||||
paths, err := agent.NewPathPolicy([]string{root})
|
||||
if err != nil {
|
||||
@@ -104,5 +227,13 @@ func newTestHandler(t *testing.T, root string, secret []byte, docker agent.Docke
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return agent.NewHandler(authenticator, paths, registry, docker, logger)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plans, err := agent.NewPlanPolicy(snapshots, catalogdata.Files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return agent.NewHandlerWithDiskChecker(authenticator, paths, plans, registry, docker, fakeDisk{}, logger)
|
||||
}
|
||||
|
||||
@@ -109,6 +109,66 @@ func (c *Client) ListRegisteredInstances(ctx context.Context) ([]RegisteredInsta
|
||||
return response.Instances, nil
|
||||
}
|
||||
|
||||
func (c *Client) CheckPorts(ctx context.Context, ports []agentwire.PlanPort) error {
|
||||
return c.do(ctx, http.MethodPost, "/v1/check-ports", struct {
|
||||
Ports []agentwire.PlanPort `json:"ports"`
|
||||
}{Ports: ports}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) CreateInstance(ctx context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
|
||||
var state agentwire.InstanceState
|
||||
err := c.do(ctx, http.MethodPost, "/v1/instances", plan, &state)
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (c *Client) ReplaceInstance(ctx context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
|
||||
var state agentwire.InstanceState
|
||||
err := c.do(ctx, http.MethodPut, instancePath(plan.InstanceID), plan, &state)
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (c *Client) InspectInstance(ctx context.Context, instanceID string) (agentwire.InstanceState, error) {
|
||||
var state agentwire.InstanceState
|
||||
err := c.do(ctx, http.MethodGet, instancePath(instanceID), nil, &state)
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (c *Client) StartInstance(ctx context.Context, instanceID string) (agentwire.InstanceState, error) {
|
||||
var state agentwire.InstanceState
|
||||
err := c.do(ctx, http.MethodPost, instancePath(instanceID)+"/start", nil, &state)
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (c *Client) StopInstance(ctx context.Context, instanceID string, timeoutSeconds int) (agentwire.InstanceState, error) {
|
||||
var state agentwire.InstanceState
|
||||
err := c.do(ctx, http.MethodPost, instancePath(instanceID)+"/stop", struct {
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
}{TimeoutSeconds: timeoutSeconds}, &state)
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (c *Client) RestartInstance(ctx context.Context, instanceID string, timeoutSeconds int) (agentwire.InstanceState, error) {
|
||||
var state agentwire.InstanceState
|
||||
err := c.do(ctx, http.MethodPost, instancePath(instanceID)+"/restart", struct {
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
}{TimeoutSeconds: timeoutSeconds}, &state)
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (c *Client) DeleteContainer(ctx context.Context, instanceID string) error {
|
||||
return c.do(ctx, http.MethodDelete, instancePath(instanceID), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) GetInstanceStats(ctx context.Context, instanceID string) (agentwire.InstanceStats, error) {
|
||||
var stats agentwire.InstanceStats
|
||||
err := c.do(ctx, http.MethodGet, instancePath(instanceID)+"/stats", nil, &stats)
|
||||
return stats, err
|
||||
}
|
||||
|
||||
func instancePath(instanceID string) string {
|
||||
return "/v1/instances/" + url.PathEscape(instanceID)
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, input, output any) error {
|
||||
var body []byte
|
||||
var err error
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package agentwire
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const DeploymentPlanVersion = 1
|
||||
|
||||
var (
|
||||
instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{20,128}$`)
|
||||
templateIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
componentIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||
digestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
)
|
||||
|
||||
type DeploymentPlan struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
TemplateID string `json:"template_id"`
|
||||
TemplateVersion string `json:"template_version"`
|
||||
TemplateDigest string `json:"template_digest"`
|
||||
Image string `json:"image"`
|
||||
Entrypoint []string `json:"entrypoint,omitempty"`
|
||||
Arguments []string `json:"arguments,omitempty"`
|
||||
Ports []PlanPort `json:"ports"`
|
||||
Mounts []PlanMount `json:"mounts"`
|
||||
Resources PlanResource `json:"resources"`
|
||||
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
PlanDigest string `json:"plan_digest"`
|
||||
}
|
||||
|
||||
type PlanPort struct {
|
||||
ID string `json:"id"`
|
||||
Protocol string `json:"protocol"`
|
||||
ContainerPort int `json:"container_port"`
|
||||
HostPort int `json:"host_port"`
|
||||
Publish bool `json:"publish"`
|
||||
}
|
||||
|
||||
type PlanMount struct {
|
||||
ID string `json:"id"`
|
||||
HostPath string `json:"host_path"`
|
||||
ContainerPath string `json:"container_path"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
type PlanResource struct {
|
||||
CPUCores float64 `json:"cpu_cores"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
StorageGB int `json:"storage_gb"`
|
||||
}
|
||||
|
||||
func (p DeploymentPlan) CanonicalDigest() (string, error) {
|
||||
copyPlan := p
|
||||
copyPlan.PlanDigest = ""
|
||||
copyPlan.Entrypoint = append([]string(nil), p.Entrypoint...)
|
||||
copyPlan.Arguments = append([]string(nil), p.Arguments...)
|
||||
copyPlan.Ports = append([]PlanPort(nil), p.Ports...)
|
||||
copyPlan.Mounts = append([]PlanMount(nil), p.Mounts...)
|
||||
sort.Slice(copyPlan.Ports, func(i, j int) bool { return copyPlan.Ports[i].ID < copyPlan.Ports[j].ID })
|
||||
sort.Slice(copyPlan.Mounts, func(i, j int) bool { return copyPlan.Mounts[i].ID < copyPlan.Mounts[j].ID })
|
||||
body, err := json.Marshal(copyPlan)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode deployment plan: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
return hex.EncodeToString(digest[:]), nil
|
||||
}
|
||||
|
||||
func (p DeploymentPlan) Validate() error {
|
||||
if p.SchemaVersion != DeploymentPlanVersion || !instanceIDPattern.MatchString(p.InstanceID) || !templateIDPattern.MatchString(p.TemplateID) {
|
||||
return errors.New("invalid deployment plan identity")
|
||||
}
|
||||
if strings.TrimSpace(p.TemplateVersion) == "" || !digestPattern.MatchString(p.TemplateDigest) || strings.TrimSpace(p.Image) == "" {
|
||||
return errors.New("invalid deployment plan template")
|
||||
}
|
||||
if p.Resources.CPUCores <= 0 || p.Resources.CPUCores > 256 || p.Resources.MemoryMB < 128 || p.Resources.MemoryMB > 4*1024*1024 || p.Resources.StorageGB < 1 || p.Resources.StorageGB > 100000 {
|
||||
return errors.New("invalid deployment plan resources")
|
||||
}
|
||||
if p.StopTimeoutSeconds < 5 || p.StopTimeoutSeconds > 900 || len(p.Ports) > 32 || len(p.Mounts) == 0 || len(p.Mounts) > 16 {
|
||||
return errors.New("invalid deployment plan limits")
|
||||
}
|
||||
if len(p.Labels) > 64 || len(p.User) > 32 {
|
||||
return errors.New("invalid deployment plan container configuration")
|
||||
}
|
||||
for key, value := range p.Labels {
|
||||
lower := strings.ToLower(key)
|
||||
if key == "" || len(key) > 255 || len(value) > 4096 || strings.HasPrefix(lower, "dogama.") || strings.HasPrefix(lower, "io.dogama.") {
|
||||
return errors.New("invalid deployment plan label")
|
||||
}
|
||||
}
|
||||
if p.User != "" {
|
||||
parts := strings.Split(p.User, ":")
|
||||
if len(parts) != 2 {
|
||||
return errors.New("invalid deployment plan user")
|
||||
}
|
||||
for _, part := range parts {
|
||||
if _, err := strconv.ParseUint(part, 10, 32); err != nil {
|
||||
return errors.New("invalid deployment plan user")
|
||||
}
|
||||
}
|
||||
}
|
||||
portIDs := make(map[string]struct{}, len(p.Ports))
|
||||
hostPorts := make(map[string]struct{})
|
||||
for _, port := range p.Ports {
|
||||
if !componentIDPattern.MatchString(port.ID) || (port.Protocol != "tcp" && port.Protocol != "udp") || port.ContainerPort < 1 || port.ContainerPort > 65535 {
|
||||
return errors.New("invalid deployment plan port")
|
||||
}
|
||||
if _, exists := portIDs[port.ID]; exists {
|
||||
return errors.New("duplicate deployment plan port")
|
||||
}
|
||||
portIDs[port.ID] = struct{}{}
|
||||
if port.Publish {
|
||||
if port.HostPort < 1 || port.HostPort > 65535 {
|
||||
return errors.New("invalid published host port")
|
||||
}
|
||||
key := fmt.Sprintf("%s/%d", port.Protocol, port.HostPort)
|
||||
if _, exists := hostPorts[key]; exists {
|
||||
return errors.New("duplicate published host port")
|
||||
}
|
||||
hostPorts[key] = struct{}{}
|
||||
} else if port.HostPort != 0 {
|
||||
return errors.New("private port cannot have a host port")
|
||||
}
|
||||
}
|
||||
mountIDs := make(map[string]struct{}, len(p.Mounts))
|
||||
destinations := make(map[string]struct{}, len(p.Mounts))
|
||||
for _, mount := range p.Mounts {
|
||||
if !componentIDPattern.MatchString(mount.ID) || !filepath.IsAbs(mount.HostPath) || filepath.Clean(mount.HostPath) != mount.HostPath || !strings.HasPrefix(mount.ContainerPath, "/") || filepath.Clean(mount.ContainerPath) != mount.ContainerPath || mount.ContainerPath == "/" || strings.ContainsRune(mount.ContainerPath, '\x00') {
|
||||
return errors.New("invalid deployment plan mount")
|
||||
}
|
||||
if _, exists := mountIDs[mount.ID]; exists {
|
||||
return errors.New("duplicate deployment plan mount")
|
||||
}
|
||||
if _, exists := destinations[mount.ContainerPath]; exists {
|
||||
return errors.New("duplicate deployment plan mount destination")
|
||||
}
|
||||
mountIDs[mount.ID] = struct{}{}
|
||||
destinations[mount.ContainerPath] = struct{}{}
|
||||
}
|
||||
expected, err := p.CanonicalDigest()
|
||||
if err != nil || !digestPattern.MatchString(p.PlanDigest) || p.PlanDigest != expected {
|
||||
return errors.New("deployment plan digest mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type InstanceState struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
ContainerID string `json:"container_id"`
|
||||
PlanDigest string `json:"plan_digest"`
|
||||
Running bool `json:"running"`
|
||||
Ready bool `json:"ready"`
|
||||
Health string `json:"health"`
|
||||
ExitCode int `json:"exit_code,omitempty"`
|
||||
}
|
||||
|
||||
type InstanceStats struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
CPUPercentage float64 `json:"cpu_percentage"`
|
||||
MemoryBytes uint64 `json:"memory_bytes"`
|
||||
MemoryLimit uint64 `json:"memory_limit"`
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package agentwire
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeploymentPlanDigestRejectsPrivilegedFieldSubstitution(t *testing.T) {
|
||||
plan := DeploymentPlan{
|
||||
SchemaVersion: DeploymentPlanVersion, InstanceID: "abcdefghijklmnopqrstuvwx", TemplateID: "palworld-official", TemplateVersion: "1.0.0",
|
||||
TemplateDigest: strings.Repeat("a", 64), Image: "example.invalid/game:1",
|
||||
Ports: []PlanPort{{ID: "game", Protocol: "udp", ContainerPort: 8211, HostPort: 38211, Publish: true}},
|
||||
Mounts: []PlanMount{{ID: "saved", HostPath: filepath.Join(string(filepath.Separator), "srv", "games", "saved"), ContainerPath: "/game/saved"}},
|
||||
Resources: PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10}, StopTimeoutSeconds: 30,
|
||||
}
|
||||
digest, err := plan.CanonicalDigest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan.PlanDigest = digest
|
||||
if err := plan.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan.Image = "attacker.invalid/game:latest"
|
||||
if err := plan.Validate(); err == nil {
|
||||
t.Fatal("image substitution preserved a valid binding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploymentPlanRejectsReservedLabelsAndInvalidUser(t *testing.T) {
|
||||
plan := DeploymentPlan{SchemaVersion: DeploymentPlanVersion, InstanceID: "abcdefghijklmnopqrstuvwx", TemplateID: "palworld-official", TemplateVersion: "1.0.0", TemplateDigest: strings.Repeat("a", 64), Image: "example.invalid/game:1", Ports: []PlanPort{{ID: "game", Protocol: "udp", ContainerPort: 8211, HostPort: 38211, Publish: true}}, Mounts: []PlanMount{{ID: "saved", HostPath: filepath.Join(string(filepath.Separator), "srv", "games", "saved"), ContainerPath: "/game/saved"}}, Resources: PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10}, StopTimeoutSeconds: 30, Labels: map[string]string{"dogama.managed": "false"}, User: "1000:1000"}
|
||||
digest, _ := plan.CanonicalDigest()
|
||||
plan.PlanDigest = digest
|
||||
if err := plan.Validate(); err == nil {
|
||||
t.Fatal("reserved label accepted")
|
||||
}
|
||||
plan.Labels = map[string]string{"dashboard.name": "server"}
|
||||
plan.User = "root"
|
||||
digest, _ = plan.CanonicalDigest()
|
||||
plan.PlanDigest = digest
|
||||
if err := plan.Validate(); err == nil {
|
||||
t.Fatal("non-numeric Docker user accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Package audit stores the deliberately small, redacted security audit trail.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorLabel string `json:"actor_label"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Action string `json:"action"`
|
||||
Outcome string `json:"outcome"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Summary map[string]string `json:"summary"`
|
||||
}
|
||||
|
||||
type Filter struct {
|
||||
ActorID, InstanceID, Action, Outcome string
|
||||
Since, Until time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
RetentionDays int `json:"retention_days"`
|
||||
MaximumCount int `json:"maximum_count"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(db *sql.DB) *Service { return &Service{db: db, now: time.Now} }
|
||||
|
||||
var allowedSummaryKeys = map[string]bool{"target_id": true, "target_name": true, "channel_type": true, "event_type": true, "reason_code": true, "deleted_count": true, "before": true, "after": true}
|
||||
|
||||
func (s *Service) Record(ctx context.Context, event Event) error {
|
||||
if event.Action == "" || (event.Outcome != "allowed" && event.Outcome != "denied" && event.Outcome != "failed") {
|
||||
return errors.New("invalid audit event")
|
||||
}
|
||||
clean := map[string]string{}
|
||||
for key, value := range event.Summary {
|
||||
if allowedSummaryKeys[key] && len(value) <= 200 {
|
||||
clean[key] = value
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(clean)
|
||||
when := event.OccurredAt
|
||||
if when.IsZero() {
|
||||
when = s.now().UTC()
|
||||
}
|
||||
var actor, instance any
|
||||
if event.ActorID != "" {
|
||||
actor = event.ActorID
|
||||
}
|
||||
if event.InstanceID != "" {
|
||||
instance = event.InstanceID
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(id,occurred_at,actor_id,actor_label,instance_id,action,outcome,summary_json) VALUES(?,?,?,?,?,?,?,?)`, randomID(), when.Format(time.RFC3339Nano), actor, event.ActorLabel, instance, event.Action, event.Outcome, string(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("record audit event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, f Filter) ([]Event, error) {
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
clauses, args := []string{"1=1"}, []any{}
|
||||
for _, item := range []struct{ column, value string }{{"actor_id", f.ActorID}, {"instance_id", f.InstanceID}, {"action", f.Action}, {"outcome", f.Outcome}} {
|
||||
if item.value != "" {
|
||||
clauses = append(clauses, item.column+"=?")
|
||||
args = append(args, item.value)
|
||||
}
|
||||
}
|
||||
if !f.Since.IsZero() {
|
||||
clauses = append(clauses, "occurred_at>=?")
|
||||
args = append(args, f.Since.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
if !f.Until.IsZero() {
|
||||
clauses = append(clauses, "occurred_at<?")
|
||||
args = append(args, f.Until.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
args = append(args, limit)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,occurred_at,COALESCE(actor_id,''),actor_label,COALESCE(instance_id,''),action,outcome,summary_json FROM audit_events WHERE `+strings.Join(clauses, " AND ")+` ORDER BY occurred_at DESC,id DESC LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list audit events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var events []Event
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
var occurred, body string
|
||||
if err := rows.Scan(&e.ID, &occurred, &e.ActorID, &e.ActorLabel, &e.InstanceID, &e.Action, &e.Outcome, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.OccurredAt, _ = time.Parse(time.RFC3339Nano, occurred)
|
||||
_ = json.Unmarshal([]byte(body), &e.Summary)
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) Policy(ctx context.Context) (Policy, error) {
|
||||
var body string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value_json FROM system_settings WHERE key='audit_policy'`).Scan(&body)
|
||||
if err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
var p Policy
|
||||
var raw struct {
|
||||
RetentionDays int `json:"retention_days"`
|
||||
MaximumCount int `json:"maximum_count"`
|
||||
}
|
||||
if err = json.Unmarshal([]byte(body), &raw); err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.RetentionDays, p.MaximumCount = raw.RetentionDays, raw.MaximumCount
|
||||
return p, nil
|
||||
}
|
||||
func (s *Service) SetPolicy(ctx context.Context, p Policy) error {
|
||||
if p.RetentionDays < 0 || p.RetentionDays > 3650 || p.MaximumCount < 0 || p.MaximumCount > 1000000 {
|
||||
return errors.New("audit policy is out of bounds")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]int{"retention_days": p.RetentionDays, "maximum_count": p.MaximumCount})
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE system_settings SET value_json=?,revision=revision+1,updated_at=? WHERE key='audit_policy'`, body, s.now().UTC().Format(time.RFC3339Nano))
|
||||
return err
|
||||
}
|
||||
func (s *Service) Purge(ctx context.Context, before time.Time) (int64, error) {
|
||||
if before.IsZero() || before.After(s.now().UTC()) {
|
||||
return 0, errors.New("invalid audit purge boundary")
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE occurred_at < ?`, before.UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
func (s *Service) RunRetention(ctx context.Context) (int64, error) {
|
||||
p, err := s.Policy(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
if p.RetentionDays > 0 {
|
||||
n, e := s.Purge(ctx, s.now().UTC().AddDate(0, 0, -p.RetentionDays))
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
total += n
|
||||
}
|
||||
if p.MaximumCount > 0 {
|
||||
result, e := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE id IN (SELECT id FROM audit_events ORDER BY occurred_at DESC,id DESC LIMIT -1 OFFSET ?)`, p.MaximumCount)
|
||||
if e != nil {
|
||||
return total, e
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
func randomID() string {
|
||||
b := make([]byte, 18)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package audit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
func TestRecordFiltersSummaryAndRetention(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service := audit.New(db)
|
||||
old := time.Now().UTC().AddDate(0, 0, -40)
|
||||
if err := service.Record(ctx, audit.Event{OccurredAt: old, ActorLabel: "admin", Action: "instance.update", Outcome: "allowed", Summary: map[string]string{"target_name": "Palworld", "secret": "must-not-persist"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, err := service.List(ctx, audit.Filter{Action: "instance.update"})
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("events=%#v err=%v", events, err)
|
||||
}
|
||||
if events[0].Summary["target_name"] != "Palworld" || events[0].Summary["secret"] != "" {
|
||||
t.Fatalf("summary was not allow-listed: %#v", events[0].Summary)
|
||||
}
|
||||
if err := service.SetPolicy(ctx, audit.Policy{RetentionDays: 30, MaximumCount: 100}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := service.RunRetention(ctx)
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyAndPurgeBounds(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service := audit.New(db)
|
||||
if err := service.SetPolicy(ctx, audit.Policy{RetentionDays: -1}); err == nil {
|
||||
t.Fatal("negative retention accepted")
|
||||
}
|
||||
if _, err := service.Purge(ctx, time.Now().Add(time.Hour)); err == nil {
|
||||
t.Fatal("future purge accepted")
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,11 @@ const (
|
||||
|
||||
// User is the authenticated principal exposed to application handlers.
|
||||
type User struct {
|
||||
ID string
|
||||
Username string
|
||||
Role string
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
AuthenticatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// Session contains a new opaque browser credential and CSRF token.
|
||||
@@ -150,10 +152,10 @@ func (s *Service) Authenticate(ctx context.Context, token string) (User, error)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
var user User
|
||||
var expiresAt, lastSeenAt string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.global_role, s.expires_at, s.last_seen_at
|
||||
var expiresAt, lastSeenAt, createdAt string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.global_role, s.expires_at, s.last_seen_at, s.created_at
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.id_hash = ? AND u.disabled_at IS NULL`, digest(token)).Scan(&user.ID, &user.Username, &user.Role, &expiresAt, &lastSeenAt)
|
||||
WHERE s.id_hash = ? AND u.disabled_at IS NULL`, digest(token)).Scan(&user.ID, &user.Username, &user.Role, &expiresAt, &lastSeenAt, &createdAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, ErrInvalidSession
|
||||
}
|
||||
@@ -162,16 +164,57 @@ func (s *Service) Authenticate(ctx context.Context, token string) (User, error)
|
||||
}
|
||||
expires, err1 := time.Parse(time.RFC3339Nano, expiresAt)
|
||||
lastSeen, err2 := time.Parse(time.RFC3339Nano, lastSeenAt)
|
||||
if err1 != nil || err2 != nil || !now.Before(expires) || now.Sub(lastSeen) > idleLifetime {
|
||||
authenticatedAt, err3 := time.Parse(time.RFC3339Nano, createdAt)
|
||||
if err1 != nil || err2 != nil || err3 != nil || !now.Before(expires) || now.Sub(lastSeen) > idleLifetime {
|
||||
_ = s.Revoke(ctx, token)
|
||||
return User{}, ErrInvalidSession
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, "UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?", now.Format(time.RFC3339Nano), digest(token)); err != nil {
|
||||
return User{}, fmt.Errorf("refresh session: %w", err)
|
||||
}
|
||||
user.AuthenticatedAt = authenticatedAt
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// CreateUser adds a local identity after the caller has enforced administrator
|
||||
// authorization and recent authentication.
|
||||
func (s *Service) CreateUser(ctx context.Context, username, password, role string) (User, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if role != "user" && role != "admin" {
|
||||
return User{}, errors.New("invalid global role")
|
||||
}
|
||||
if err := validateCredentials(username, password); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
user := User{ID: randomToken(18), Username: username, Role: role}
|
||||
_, err = s.db.ExecContext(ctx, "INSERT INTO users(id, username, password_hash, global_role, created_at) VALUES (?, ?, ?, ?, ?)", user.ID, username, hash, role, s.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("create local user: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT id, username, global_role, disabled_at IS NOT NULL FROM users ORDER BY username COLLATE NOCASE")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var users []User
|
||||
for rows.Next() {
|
||||
var user User
|
||||
if err := rows.Scan(&user.ID, &user.Username, &user.Role, &user.Disabled); err != nil {
|
||||
return nil, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// ValidateCSRF checks that a token belongs to the current session.
|
||||
func (s *Service) ValidateCSRF(ctx context.Context, sessionToken, csrfToken string) bool {
|
||||
if sessionToken == "" || csrfToken == "" {
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -9,7 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/migrations"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestBootstrapLoginSessionAndRevocation(t *testing.T) {
|
||||
@@ -178,12 +180,59 @@ func TestVerifyPasswordRejectsMalformedArgon2idEncodings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func testService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
db, err := sqlite.Open(context.Background(), filepath.Join(t.TempDir(), "dogama.db"))
|
||||
func TestCreateAndListUserTracksAuthenticationTime(t *testing.T) {
|
||||
service := testService(t)
|
||||
ctx := context.Background()
|
||||
if err := service.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := service.CreateUser(ctx, "player", "another correct battery staple", "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.ID == "" || created.Username != "player" || created.Role != "user" {
|
||||
t.Fatalf("created user = %#v", created)
|
||||
}
|
||||
users, err := service.ListUsers(ctx)
|
||||
if err != nil || len(users) != 2 {
|
||||
t.Fatalf("users = %#v, error = %v", users, err)
|
||||
}
|
||||
session, err := service.Login(ctx, "player", "another correct battery staple", "192.0.2.10:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authenticated, err := service.Authenticate(ctx, session.Token)
|
||||
if err != nil || authenticated.AuthenticatedAt.IsZero() {
|
||||
t.Fatalf("authenticated user = %#v, error = %v", authenticated, err)
|
||||
}
|
||||
}
|
||||
|
||||
func testService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if _, err := db.Exec("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := migrations.Files.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
body, err := migrations.Files.ReadFile(entry.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(string(body)); err != nil {
|
||||
t.Fatalf("apply %s: %v", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return New(db)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
// Package authorization enforces global and per-instance permissions.
|
||||
package authorization
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDenied = errors.New("permission denied")
|
||||
ErrInvalidInput = errors.New("invalid authorization input")
|
||||
ErrNotFound = errors.New("authorization object not found")
|
||||
ErrConflict = errors.New("authorization conflict")
|
||||
ErrRecentAuth = errors.New("recent authentication required")
|
||||
)
|
||||
|
||||
const RecentAuthenticationWindow = 10 * time.Minute
|
||||
|
||||
const (
|
||||
PermissionInstanceView = "instance.view"
|
||||
PermissionInstanceStart = "instance.start"
|
||||
PermissionInstanceStop = "instance.stop"
|
||||
PermissionInstanceRestart = "instance.restart"
|
||||
PermissionInstanceUpdate = "instance.update"
|
||||
PermissionInstanceConfigure = "instance.configure"
|
||||
PermissionInstanceDelete = "instance.delete"
|
||||
PermissionInstanceWelcomeEdit = "instance.welcome.edit"
|
||||
PermissionMetricsView = "metrics.view"
|
||||
PermissionPlayersView = "players.view"
|
||||
PermissionPlayersKick = "players.kick"
|
||||
PermissionPlayersBan = "players.ban"
|
||||
PermissionPlayersUnban = "players.unban"
|
||||
PermissionAnnouncementsSend = "announcements.send"
|
||||
PermissionLogsView = "logs.view"
|
||||
PermissionModsManage = "mods.manage"
|
||||
PermissionBackupCreate = "backup.create"
|
||||
PermissionBackupList = "backup.list"
|
||||
PermissionBackupExport = "backup.export"
|
||||
PermissionBackupRestore = "backup.restore"
|
||||
PermissionBackupDelete = "backup.delete"
|
||||
PermissionRequestCreate = "request.create"
|
||||
)
|
||||
|
||||
var allPermissions = map[string]struct{}{
|
||||
PermissionInstanceView: {}, PermissionInstanceStart: {}, PermissionInstanceStop: {}, PermissionInstanceRestart: {},
|
||||
PermissionInstanceUpdate: {}, PermissionInstanceConfigure: {}, PermissionInstanceDelete: {}, PermissionInstanceWelcomeEdit: {},
|
||||
PermissionMetricsView: {}, PermissionPlayersView: {}, PermissionPlayersKick: {}, PermissionPlayersBan: {}, PermissionPlayersUnban: {},
|
||||
PermissionAnnouncementsSend: {}, PermissionLogsView: {}, PermissionModsManage: {}, PermissionBackupCreate: {}, PermissionBackupList: {},
|
||||
PermissionBackupExport: {}, PermissionBackupRestore: {}, PermissionBackupDelete: {}, PermissionRequestCreate: {},
|
||||
}
|
||||
|
||||
var userBaseline = permissionSet(
|
||||
PermissionInstanceView, PermissionInstanceStart, PermissionInstanceStop,
|
||||
PermissionMetricsView, PermissionPlayersView, PermissionRequestCreate,
|
||||
)
|
||||
|
||||
var managerBaseline = permissionSet(
|
||||
PermissionInstanceView, PermissionInstanceStart, PermissionInstanceStop, PermissionInstanceRestart,
|
||||
PermissionInstanceUpdate, PermissionInstanceWelcomeEdit, PermissionMetricsView, PermissionPlayersView,
|
||||
PermissionPlayersKick, PermissionPlayersBan, PermissionPlayersUnban, PermissionAnnouncementsSend,
|
||||
PermissionLogsView, PermissionModsManage, PermissionBackupCreate, PermissionBackupList, PermissionRequestCreate,
|
||||
)
|
||||
|
||||
type Access struct {
|
||||
InstanceExists bool
|
||||
MembershipRole string
|
||||
Overrides map[string]string
|
||||
}
|
||||
|
||||
type Membership struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Overrides map[string]string `json:"overrides"`
|
||||
}
|
||||
|
||||
type InstallationRequest struct {
|
||||
ID string `json:"id"`
|
||||
RequestedBy string `json:"requested_by"`
|
||||
RequesterName string `json:"requester_name"`
|
||||
TemplateID string `json:"template_id"`
|
||||
TemplateVersion string `json:"template_version"`
|
||||
SuggestedName string `json:"suggested_name,omitempty"`
|
||||
PlayerEstimate int `json:"player_estimate,omitempty"`
|
||||
DesiredSchedule string `json:"desired_schedule,omitempty"`
|
||||
ModsRequested bool `json:"mods_requested"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||
ReviewerName string `json:"reviewer_name,omitempty"`
|
||||
ReviewReason string `json:"review_reason,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ReviewedAt string `json:"reviewed_at,omitempty"`
|
||||
}
|
||||
|
||||
type RequestInput struct {
|
||||
ID string
|
||||
TemplateID string
|
||||
TemplateVersion string
|
||||
SuggestedName string
|
||||
PlayerEstimate int
|
||||
DesiredSchedule string
|
||||
ModsRequested bool
|
||||
Message string
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
ResolveAccess(context.Context, string, string) (Access, error)
|
||||
SetMembership(context.Context, string, string, string, string) error
|
||||
DeleteMembership(context.Context, string, string) error
|
||||
SetPermissionOverride(context.Context, string, string, string, string, string) error
|
||||
DeletePermissionOverride(context.Context, string, string, string) error
|
||||
ListMemberships(context.Context, string) ([]Membership, error)
|
||||
CreateInstallationRequest(context.Context, string, RequestInput) (InstallationRequest, error)
|
||||
ListInstallationRequests(context.Context, string, bool) ([]InstallationRequest, error)
|
||||
ReviewInstallationRequest(context.Context, string, string, string, string) (InstallationRequest, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(repository Repository) *Service { return &Service{repository: repository, now: time.Now} }
|
||||
|
||||
func Permissions() []string {
|
||||
result := make([]string, 0, len(allPermissions))
|
||||
for permission := range allPermissions {
|
||||
result = append(result, permission)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) Require(ctx context.Context, principal auth.User, instanceID, permission string) error {
|
||||
if principal.ID == "" || principal.Disabled || !knownPermission(permission) {
|
||||
return ErrDenied
|
||||
}
|
||||
if principal.Role == "admin" {
|
||||
return s.requireRecentForPermission(principal, permission)
|
||||
}
|
||||
if principal.Role != "user" || instanceID == "" {
|
||||
return ErrDenied
|
||||
}
|
||||
access, err := s.repository.ResolveAccess(ctx, principal.ID, instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !access.InstanceExists || (access.MembershipRole != "user" && access.MembershipRole != "manager") {
|
||||
return ErrDenied
|
||||
}
|
||||
if access.Overrides[permission] == "deny" {
|
||||
return ErrDenied
|
||||
}
|
||||
baseline := userBaseline
|
||||
if access.MembershipRole == "manager" {
|
||||
baseline = managerBaseline
|
||||
}
|
||||
if baseline[permission] || access.Overrides[permission] == "allow" {
|
||||
return s.requireRecentForPermission(principal, permission)
|
||||
}
|
||||
return ErrDenied
|
||||
}
|
||||
|
||||
func (s *Service) SetMembership(ctx context.Context, actor auth.User, instanceID, userID, role string) error {
|
||||
if err := s.requireRecentAdmin(actor); err != nil {
|
||||
return err
|
||||
}
|
||||
if instanceID == "" || userID == "" || (role != "user" && role != "manager") {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
return s.repository.SetMembership(ctx, actor.ID, instanceID, userID, role)
|
||||
}
|
||||
|
||||
func (s *Service) RequireRecentAdmin(actor auth.User) error { return s.requireRecentAdmin(actor) }
|
||||
|
||||
func (s *Service) DeleteMembership(ctx context.Context, actor auth.User, instanceID, userID string) error {
|
||||
if err := s.requireRecentAdmin(actor); err != nil {
|
||||
return err
|
||||
}
|
||||
if instanceID == "" || userID == "" {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
return s.repository.DeleteMembership(ctx, instanceID, userID)
|
||||
}
|
||||
|
||||
func (s *Service) SetOverride(ctx context.Context, actor auth.User, instanceID, userID, permission, effect string) error {
|
||||
if err := s.requireRecentAdmin(actor); err != nil {
|
||||
return err
|
||||
}
|
||||
if instanceID == "" || userID == "" || !knownPermission(permission) || (effect != "allow" && effect != "deny") {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
return s.repository.SetPermissionOverride(ctx, actor.ID, instanceID, userID, permission, effect)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteOverride(ctx context.Context, actor auth.User, instanceID, userID, permission string) error {
|
||||
if err := s.requireRecentAdmin(actor); err != nil {
|
||||
return err
|
||||
}
|
||||
if instanceID == "" || userID == "" || !knownPermission(permission) {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
return s.repository.DeletePermissionOverride(ctx, instanceID, userID, permission)
|
||||
}
|
||||
|
||||
func (s *Service) ListMemberships(ctx context.Context, actor auth.User, instanceID string) ([]Membership, error) {
|
||||
if actor.Role != "admin" || actor.Disabled {
|
||||
return nil, ErrDenied
|
||||
}
|
||||
return s.repository.ListMemberships(ctx, instanceID)
|
||||
}
|
||||
|
||||
func (s *Service) CreateInstallationRequest(ctx context.Context, actor auth.User, input RequestInput) (InstallationRequest, error) {
|
||||
if actor.ID == "" || actor.Disabled || (actor.Role != "user" && actor.Role != "admin") {
|
||||
return InstallationRequest{}, ErrDenied
|
||||
}
|
||||
input.SuggestedName, input.DesiredSchedule, input.Message = strings.TrimSpace(input.SuggestedName), strings.TrimSpace(input.DesiredSchedule), strings.TrimSpace(input.Message)
|
||||
if input.ID == "" || input.TemplateID == "" || input.TemplateVersion == "" || len(input.SuggestedName) > 100 || strings.ContainsAny(input.SuggestedName, `/\\`) || input.PlayerEstimate < 0 || input.PlayerEstimate > 10000 || len(input.DesiredSchedule) > 200 || len(input.Message) > 1000 {
|
||||
return InstallationRequest{}, ErrInvalidInput
|
||||
}
|
||||
return s.repository.CreateInstallationRequest(ctx, actor.ID, input)
|
||||
}
|
||||
|
||||
func (s *Service) ListInstallationRequests(ctx context.Context, actor auth.User) ([]InstallationRequest, error) {
|
||||
if actor.ID == "" || actor.Disabled {
|
||||
return nil, ErrDenied
|
||||
}
|
||||
return s.repository.ListInstallationRequests(ctx, actor.ID, actor.Role == "admin")
|
||||
}
|
||||
|
||||
func (s *Service) ReviewInstallationRequest(ctx context.Context, actor auth.User, requestID, decision, reason string) (InstallationRequest, error) {
|
||||
if err := s.requireRecentAdmin(actor); err != nil {
|
||||
return InstallationRequest{}, err
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if requestID == "" || (decision != "approved" && decision != "refused") || len(reason) > 1000 || (decision == "refused" && reason == "") {
|
||||
return InstallationRequest{}, ErrInvalidInput
|
||||
}
|
||||
return s.repository.ReviewInstallationRequest(ctx, actor.ID, requestID, decision, reason)
|
||||
}
|
||||
|
||||
func (s *Service) requireRecentAdmin(actor auth.User) error {
|
||||
if actor.ID == "" || actor.Disabled || actor.Role != "admin" {
|
||||
return ErrDenied
|
||||
}
|
||||
if actor.AuthenticatedAt.IsZero() || s.now().Sub(actor.AuthenticatedAt) > RecentAuthenticationWindow {
|
||||
return ErrRecentAuth
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) requireRecentForPermission(actor auth.User, permission string) error {
|
||||
if permission != PermissionInstanceConfigure && permission != PermissionInstanceDelete && permission != PermissionBackupRestore && permission != PermissionBackupDelete {
|
||||
return nil
|
||||
}
|
||||
age := s.now().Sub(actor.AuthenticatedAt)
|
||||
if actor.AuthenticatedAt.IsZero() || age < 0 || age > RecentAuthenticationWindow {
|
||||
return ErrRecentAuth
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func knownPermission(permission string) bool { _, ok := allPermissions[permission]; return ok }
|
||||
func permissionSet(values ...string) map[string]bool {
|
||||
result := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
result[value] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package authorization_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
func TestInstanceBaselinesOverridesAndIdentifierSubstitution(t *testing.T) {
|
||||
ctx, _, repository, _, admin, user, instanceID, _ := authorizationFixture(t)
|
||||
service := authorization.New(repository)
|
||||
if err := service.Require(ctx, user, instanceID, authorization.PermissionInstanceView); !errors.Is(err, authorization.ErrDenied) {
|
||||
t.Fatalf("unassigned access error = %v", err)
|
||||
}
|
||||
if err := service.SetMembership(ctx, admin, instanceID, user.ID, "user"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, permission := range []string{authorization.PermissionInstanceView, authorization.PermissionInstanceStart, authorization.PermissionInstanceStop} {
|
||||
if err := service.Require(ctx, user, instanceID, permission); err != nil {
|
||||
t.Fatalf("user permission %s: %v", permission, err)
|
||||
}
|
||||
}
|
||||
if err := service.Require(ctx, user, instanceID, authorization.PermissionInstanceRestart); !errors.Is(err, authorization.ErrDenied) {
|
||||
t.Fatalf("user restart error = %v", err)
|
||||
}
|
||||
if err := service.SetMembership(ctx, admin, instanceID, user.ID, "manager"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.Require(ctx, user, instanceID, authorization.PermissionInstanceRestart); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.SetOverride(ctx, admin, instanceID, user.ID, authorization.PermissionInstanceRestart, "deny"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.Require(ctx, user, instanceID, authorization.PermissionInstanceRestart); !errors.Is(err, authorization.ErrDenied) {
|
||||
t.Fatalf("explicit deny error = %v", err)
|
||||
}
|
||||
if err := service.SetOverride(ctx, admin, instanceID, user.ID, authorization.PermissionBackupRestore, "allow"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.Require(ctx, user, instanceID, authorization.PermissionBackupRestore); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
staleUser := user
|
||||
staleUser.AuthenticatedAt = time.Now().Add(-authorization.RecentAuthenticationWindow - time.Minute)
|
||||
if err := service.Require(ctx, staleUser, instanceID, authorization.PermissionBackupRestore); !errors.Is(err, authorization.ErrRecentAuth) {
|
||||
t.Fatalf("stale user restore error = %v", err)
|
||||
}
|
||||
if err := service.Require(ctx, user, "substituted-instance-id", authorization.PermissionInstanceView); !errors.Is(err, authorization.ErrDenied) {
|
||||
t.Fatalf("substituted ID error = %v", err)
|
||||
}
|
||||
if err := service.Require(ctx, admin, instanceID, authorization.PermissionInstanceDelete); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := admin
|
||||
stale.AuthenticatedAt = time.Now().Add(-authorization.RecentAuthenticationWindow - time.Minute)
|
||||
if err := service.Require(ctx, stale, instanceID, authorization.PermissionInstanceDelete); !errors.Is(err, authorization.ErrRecentAuth) {
|
||||
t.Fatalf("stale admin delete error = %v", err)
|
||||
}
|
||||
if err := service.SetMembership(ctx, stale, instanceID, user.ID, "user"); !errors.Is(err, authorization.ErrRecentAuth) {
|
||||
t.Fatalf("stale admin error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallationRequestReviewNeverDeploys(t *testing.T) {
|
||||
ctx, db, repository, _, admin, user, _, snapshot := authorizationFixture(t)
|
||||
service := authorization.New(repository)
|
||||
request, err := service.CreateInstallationRequest(ctx, user, authorization.RequestInput{
|
||||
ID: "request-one", TemplateID: snapshot.Template.ID, TemplateVersion: snapshot.Template.Version,
|
||||
SuggestedName: "Friends server", PlayerEstimate: 8, DesiredSchedule: "Evenings", Message: "Please approve",
|
||||
})
|
||||
if err != nil || request.Status != "pending" {
|
||||
t.Fatalf("request=%#v error=%v", request, err)
|
||||
}
|
||||
if _, err := service.CreateInstallationRequest(ctx, user, authorization.RequestInput{ID: "request-two", TemplateID: snapshot.Template.ID, TemplateVersion: snapshot.Template.Version}); !errors.Is(err, authorization.ErrConflict) {
|
||||
t.Fatalf("duplicate request error = %v", err)
|
||||
}
|
||||
userRequests, err := service.ListInstallationRequests(ctx, user)
|
||||
if err != nil || len(userRequests) != 1 {
|
||||
t.Fatalf("user requests=%#v error=%v", userRequests, err)
|
||||
}
|
||||
reviewed, err := service.ReviewInstallationRequest(ctx, admin, request.ID, "approved", "Capacity available")
|
||||
if err != nil || reviewed.Status != "approved" || reviewed.SuggestedName != "Friends server" {
|
||||
t.Fatalf("reviewed=%#v error=%v", reviewed, err)
|
||||
}
|
||||
var instanceCount int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM instances").Scan(&instanceCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if instanceCount != 1 {
|
||||
t.Fatalf("approval deployed an instance; count=%d", instanceCount)
|
||||
}
|
||||
}
|
||||
|
||||
func authorizationFixture(t *testing.T) (context.Context, *sql.DB, *sqlite.Repository, *auth.Service, auth.User, auth.User, string, catalog.Snapshot) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
repository := sqlite.NewRepository(db)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Sync(ctx, snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authService := auth.New(db)
|
||||
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminSession, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, err := authService.Authenticate(ctx, adminSession.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createdUser, err := authService.CreateUser(ctx, "player", "another correct battery staple", "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userSession, err := authService.Login(ctx, createdUser.Username, "another correct battery staple", "192.0.2.2:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, err := authService.Authenticate(ctx, userSession.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Family Palworld", Slug: "family-palworld", HostPorts: map[string]int{"game": 38211}, MountPaths: map[string]string{"saved": "/srv/game-servers/family-palworld/saved"}, DataOrigin: "new", BackupRetention: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
instanceID := "abcdefghijklmnopqrstuvwx"
|
||||
if err := repository.CreateDraft(ctx, instance.Draft{ID: instanceID, Preview: preview}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ctx, db, repository, authService, admin, user, instanceID, snapshots[0]
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
// Package backup owns game-data archives, retention and recoverable restores.
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/robfig/cron/v3"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("backup not found")
|
||||
ErrInvalidInput = errors.New("invalid backup input")
|
||||
ErrInvalidState = errors.New("invalid backup state")
|
||||
ErrUnsafePath = errors.New("unsafe backup path")
|
||||
ErrIntegrity = errors.New("backup integrity check failed")
|
||||
ErrIncompatible = errors.New("backup is incompatible")
|
||||
)
|
||||
|
||||
const manifestSchemaVersion = 1
|
||||
|
||||
type Backup struct {
|
||||
ID string `json:"id"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Origin string `json:"origin"`
|
||||
Status string `json:"status"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
RelativePath string `json:"-"`
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CronExpression string `json:"cron_expression,omitempty"`
|
||||
Timezone string `json:"timezone"`
|
||||
RetentionCount int `json:"retention_count"`
|
||||
NextRunAt string `json:"next_run_at,omitempty"`
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
BackupID string `json:"backup_id"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
TemplateID string `json:"template_id"`
|
||||
TemplateVersion string `json:"template_version"`
|
||||
Origin string `json:"origin"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
MountIDs []string `json:"mount_ids"`
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
instance.LifecycleRepository
|
||||
BeginBackup(context.Context, Backup, string, string) error
|
||||
CompleteBackup(context.Context, string, string, int64, string, Manifest) error
|
||||
FailBackup(context.Context, string, string) error
|
||||
ListBackups(context.Context, string) ([]Backup, error)
|
||||
GetBackup(context.Context, string, string) (Backup, Manifest, error)
|
||||
RetentionCandidates(context.Context, string, int) ([]Backup, error)
|
||||
MarkBackupDeleted(context.Context, string) error
|
||||
GetBackupPolicy(context.Context, string) (Policy, error)
|
||||
SetBackupPolicy(context.Context, Policy) error
|
||||
ListDueBackupPolicies(context.Context, string) ([]Policy, error)
|
||||
}
|
||||
|
||||
type Agent interface {
|
||||
StartInstance(context.Context, string) (agentwire.InstanceState, error)
|
||||
StopInstance(context.Context, string, int) (agentwire.InstanceState, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
agent Agent
|
||||
serversRoot string
|
||||
backupsRoot string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(repository Repository, agent Agent, serversRoot, backupsRoot string) (*Service, error) {
|
||||
serversRoot, err := canonicalRoot(serversRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server root: %w", err)
|
||||
}
|
||||
backupsRoot, err = canonicalRoot(backupsRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("backup root: %w", err)
|
||||
}
|
||||
return &Service{repository: repository, agent: agent, serversRoot: serversRoot, backupsRoot: backupsRoot, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, actorID, instanceID, origin string) (Backup, error) {
|
||||
if !validOrigin(origin) || instanceID == "" {
|
||||
return Backup{}, ErrInvalidInput
|
||||
}
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return Backup{}, err
|
||||
}
|
||||
if current.ContainerID == "" {
|
||||
return Backup{}, ErrInvalidState
|
||||
}
|
||||
operationID, backupID := token(), token()
|
||||
if operationID == "" || backupID == "" {
|
||||
return Backup{}, errors.New("generate backup identifiers")
|
||||
}
|
||||
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "backup", "backup")
|
||||
if err != nil {
|
||||
return Backup{}, err
|
||||
}
|
||||
backup := Backup{ID: backupID, InstanceID: instanceID, Origin: origin, Status: "creating", CreatedAt: s.now().UTC().Format(time.RFC3339Nano)}
|
||||
if err := s.repository.BeginBackup(ctx, backup, operationID, actorID); err != nil {
|
||||
_ = s.repository.FailOperation(ctx, operationID, "error", "backup_metadata_failed")
|
||||
return Backup{}, err
|
||||
}
|
||||
wasRunning := current.DesiredRunning
|
||||
if wasRunning {
|
||||
if _, err := s.agent.StopInstance(ctx, instanceID, current.Preview.StopTimeoutSeconds); err != nil {
|
||||
return Backup{}, s.fail(ctx, operationID, backupID, "backup_stop_failed", err)
|
||||
}
|
||||
}
|
||||
completed, manifest, err := s.writeArchive(ctx, current, backup)
|
||||
if err != nil {
|
||||
if wasRunning {
|
||||
if _, restartErr := s.agent.StartInstance(ctx, instanceID); restartErr != nil {
|
||||
_ = s.repository.FailBackup(ctx, backupID, "backup_restart_failed")
|
||||
_ = s.repository.FailOperation(ctx, operationID, "intervention_required", "backup_restart_failed")
|
||||
return Backup{}, fmt.Errorf("backup failed and restart failed: %v: %w", err, restartErr)
|
||||
}
|
||||
}
|
||||
return Backup{}, s.fail(ctx, operationID, backupID, "backup_archive_failed", err)
|
||||
}
|
||||
if err := s.repository.CompleteBackup(ctx, backupID, completed.RelativePath, completed.SizeBytes, completed.SHA256, manifest); err != nil {
|
||||
if full, pathErr := s.backupPath(completed.RelativePath); pathErr == nil {
|
||||
_ = os.Remove(full)
|
||||
}
|
||||
if wasRunning {
|
||||
_, _ = s.agent.StartInstance(ctx, instanceID)
|
||||
}
|
||||
return Backup{}, s.fail(ctx, operationID, backupID, "backup_persist_failed", err)
|
||||
}
|
||||
state, lifecycle, observed := agentwire.InstanceState{InstanceID: instanceID, ContainerID: current.ContainerID, Health: "stopped"}, "stopped", "stopped"
|
||||
if wasRunning {
|
||||
state, err = s.agent.StartInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
_ = s.repository.FailOperation(ctx, operationID, "intervention_required", "backup_restart_failed")
|
||||
return Backup{}, fmt.Errorf("restart after backup: %w", err)
|
||||
}
|
||||
lifecycle, observed = lifecycleFromAgent(state)
|
||||
}
|
||||
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, current.PlanDigest, wasRunning, ""); err != nil {
|
||||
return Backup{}, err
|
||||
}
|
||||
completed.Status, completed.CompletedAt = "available", s.now().UTC().Format(time.RFC3339Nano)
|
||||
if err := s.applyRetention(ctx, instanceID, current.Preview.Backup.RetentionCount); err != nil {
|
||||
return Backup{}, fmt.Errorf("apply retention: %w", err)
|
||||
}
|
||||
return completed, nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, instanceID string) ([]Backup, error) {
|
||||
return s.repository.ListBackups(ctx, instanceID)
|
||||
}
|
||||
|
||||
func (s *Service) GetPolicy(ctx context.Context, instanceID string) (Policy, error) {
|
||||
return s.repository.GetBackupPolicy(ctx, instanceID)
|
||||
}
|
||||
|
||||
func (s *Service) SetPolicy(ctx context.Context, value Policy) (Policy, error) {
|
||||
if value.InstanceID == "" || value.RetentionCount < 1 || value.RetentionCount > 1000 {
|
||||
return Policy{}, ErrInvalidInput
|
||||
}
|
||||
location, err := time.LoadLocation(value.Timezone)
|
||||
if err != nil {
|
||||
return Policy{}, ErrInvalidInput
|
||||
}
|
||||
if !value.Enabled {
|
||||
value.CronExpression, value.NextRunAt = "", ""
|
||||
} else {
|
||||
schedule, err := cronParser().Parse(value.CronExpression)
|
||||
if err != nil {
|
||||
return Policy{}, ErrInvalidInput
|
||||
}
|
||||
value.NextRunAt = schedule.Next(s.now().In(location)).UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if err := s.repository.SetBackupPolicy(ctx, value); err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Service) RunDue(ctx context.Context) error {
|
||||
now := s.now().UTC()
|
||||
policies, err := s.repository.ListDueBackupPolicies(ctx, now.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policy := range policies {
|
||||
location, locationErr := time.LoadLocation(policy.Timezone)
|
||||
schedule, parseErr := cronParser().Parse(policy.CronExpression)
|
||||
if locationErr != nil || parseErr != nil {
|
||||
continue
|
||||
}
|
||||
policy.NextRunAt = schedule.Next(now.In(location)).UTC().Format(time.RFC3339Nano)
|
||||
if err := s.repository.SetBackupPolicy(ctx, policy); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.Create(ctx, "", policy.InstanceID, "scheduled"); err != nil && !errors.Is(err, instance.ErrOperationConflict) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Export(ctx context.Context, instanceID, backupID string) (*os.File, Backup, error) {
|
||||
backup, _, err := s.repository.GetBackup(ctx, instanceID, backupID)
|
||||
if err != nil {
|
||||
return nil, Backup{}, err
|
||||
}
|
||||
if backup.Status != "available" {
|
||||
return nil, Backup{}, ErrInvalidState
|
||||
}
|
||||
full, err := s.backupPath(backup.RelativePath)
|
||||
if err != nil {
|
||||
return nil, Backup{}, err
|
||||
}
|
||||
if err := verifyFile(full, backup.SizeBytes, backup.SHA256); err != nil {
|
||||
return nil, Backup{}, err
|
||||
}
|
||||
file, err := os.Open(full)
|
||||
return file, backup, err
|
||||
}
|
||||
|
||||
func (s *Service) Restore(ctx context.Context, actorID, instanceID, backupID string) error {
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backup, manifest, err := s.repository.GetBackup(ctx, instanceID, backupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if backup.Status != "available" {
|
||||
return ErrInvalidState
|
||||
}
|
||||
if manifest.InstanceID != instanceID || manifest.TemplateID != current.Preview.Template.ID || manifest.TemplateVersion != current.Preview.Template.Version {
|
||||
return ErrIncompatible
|
||||
}
|
||||
full, err := s.backupPath(backup.RelativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := verifyFile(full, backup.SizeBytes, backup.SHA256); err != nil {
|
||||
return err
|
||||
}
|
||||
operationID := token()
|
||||
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "restore", "restore")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wasRunning := current.DesiredRunning
|
||||
if wasRunning {
|
||||
if _, err := s.agent.StopInstance(ctx, instanceID, current.Preview.StopTimeoutSeconds); err != nil {
|
||||
return s.restoreFail(ctx, operationID, "restore_stop_failed", err)
|
||||
}
|
||||
}
|
||||
safety := Backup{ID: token(), InstanceID: instanceID, Origin: "pre_restore", Status: "creating", CreatedAt: s.now().UTC().Format(time.RFC3339Nano)}
|
||||
if err := s.repository.BeginBackup(ctx, safety, operationID, actorID); err != nil {
|
||||
return s.restoreFail(ctx, operationID, "restore_safety_metadata_failed", err)
|
||||
}
|
||||
safety, safetyManifest, err := s.writeArchive(ctx, current, safety)
|
||||
if err != nil {
|
||||
return s.restoreFail(ctx, operationID, "restore_safety_backup_failed", err)
|
||||
}
|
||||
if err := s.repository.CompleteBackup(ctx, safety.ID, safety.RelativePath, safety.SizeBytes, safety.SHA256, safetyManifest); err != nil {
|
||||
return s.restoreFail(ctx, operationID, "restore_safety_persist_failed", err)
|
||||
}
|
||||
if err := s.restoreArchive(full, current, manifest); err != nil {
|
||||
return s.restoreFail(ctx, operationID, "restore_extract_failed", err)
|
||||
}
|
||||
state, lifecycle, observed := agentwire.InstanceState{InstanceID: instanceID, ContainerID: current.ContainerID, Health: "stopped"}, "stopped", "stopped"
|
||||
if wasRunning {
|
||||
state, err = s.agent.StartInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return s.restoreFail(ctx, operationID, "restore_restart_failed", err)
|
||||
}
|
||||
lifecycle, observed = lifecycleFromAgent(state)
|
||||
}
|
||||
return s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, current.PlanDigest, wasRunning, "")
|
||||
}
|
||||
|
||||
func (s *Service) writeArchive(ctx context.Context, current instance.StoredInstance, backup Backup) (Backup, Manifest, error) {
|
||||
mounts := make(map[string]string)
|
||||
for _, mount := range current.Preview.Mounts {
|
||||
mounts[mount.ID] = mount.HostPath
|
||||
}
|
||||
manifest := Manifest{SchemaVersion: manifestSchemaVersion, BackupID: backup.ID, InstanceID: backup.InstanceID, TemplateID: current.Preview.Template.ID, TemplateVersion: current.Preview.Template.Version, Origin: backup.Origin, CreatedAt: backup.CreatedAt, MountIDs: append([]string(nil), current.Preview.Backup.SourceMounts...)}
|
||||
sort.Strings(manifest.MountIDs)
|
||||
directory, err := s.instanceBackupDirectory(backup.InstanceID)
|
||||
if err != nil {
|
||||
return Backup{}, Manifest{}, err
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o750); err != nil {
|
||||
return Backup{}, Manifest{}, err
|
||||
}
|
||||
var estimated int64
|
||||
for _, mountID := range manifest.MountIDs {
|
||||
source := mounts[mountID]
|
||||
if source == "" {
|
||||
return Backup{}, Manifest{}, ErrUnsafePath
|
||||
}
|
||||
if _, err := s.serverPath(source); err != nil {
|
||||
return Backup{}, Manifest{}, err
|
||||
}
|
||||
size, err := estimateTree(ctx, source)
|
||||
if err != nil {
|
||||
return Backup{}, Manifest{}, err
|
||||
}
|
||||
estimated += size
|
||||
}
|
||||
if err := ensureFreeSpace(directory, estimated+(64<<20)); err != nil {
|
||||
return Backup{}, Manifest{}, err
|
||||
}
|
||||
temporary, err := os.CreateTemp(directory, ".creating-*.tar.zst")
|
||||
if err != nil {
|
||||
return Backup{}, Manifest{}, err
|
||||
}
|
||||
temporaryName := temporary.Name()
|
||||
defer func() { _ = temporary.Close(); _ = os.Remove(temporaryName) }()
|
||||
hasher := sha256.New()
|
||||
zstdWriter, err := zstd.NewWriter(io.MultiWriter(temporary, hasher), zstd.WithEncoderConcurrency(1))
|
||||
if err != nil {
|
||||
return Backup{}, Manifest{}, err
|
||||
}
|
||||
tarWriter := tar.NewWriter(zstdWriter)
|
||||
manifestJSON, _ := json.Marshal(manifest)
|
||||
err = tarWriter.WriteHeader(&tar.Header{Name: "manifest.json", Mode: 0o600, Size: int64(len(manifestJSON)), ModTime: s.now().UTC(), Typeflag: tar.TypeReg})
|
||||
if err == nil {
|
||||
_, err = tarWriter.Write(manifestJSON)
|
||||
}
|
||||
if err == nil {
|
||||
for _, mountID := range manifest.MountIDs {
|
||||
source := mounts[mountID]
|
||||
if source == "" {
|
||||
err = ErrUnsafePath
|
||||
break
|
||||
}
|
||||
if _, pathErr := s.serverPath(source); pathErr != nil {
|
||||
err = pathErr
|
||||
break
|
||||
}
|
||||
if walkErr := addTree(ctx, tarWriter, source, path.Join("data", mountID)); walkErr != nil {
|
||||
err = walkErr
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if closeErr := tarWriter.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if closeErr := zstdWriter.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if syncErr := temporary.Sync(); err == nil {
|
||||
err = syncErr
|
||||
}
|
||||
if closeErr := temporary.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return Backup{}, Manifest{}, err
|
||||
}
|
||||
info, err := os.Stat(temporaryName)
|
||||
if err != nil {
|
||||
return Backup{}, Manifest{}, err
|
||||
}
|
||||
finalName := backup.ID + ".tar.zst"
|
||||
finalPath := filepath.Join(directory, finalName)
|
||||
if err := os.Rename(temporaryName, finalPath); err != nil {
|
||||
return Backup{}, Manifest{}, err
|
||||
}
|
||||
backup.RelativePath = filepath.ToSlash(filepath.Join(backup.InstanceID, finalName))
|
||||
backup.SizeBytes, backup.SHA256 = info.Size(), hex.EncodeToString(hasher.Sum(nil))
|
||||
return backup, manifest, nil
|
||||
}
|
||||
|
||||
func addTree(ctx context.Context, writer *tar.Writer, source, prefix string) error {
|
||||
return filepath.WalkDir(source, func(current string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) {
|
||||
return ErrUnsafePath
|
||||
}
|
||||
relative, err := filepath.Rel(source, current)
|
||||
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return ErrUnsafePath
|
||||
}
|
||||
name := prefix
|
||||
if relative != "." {
|
||||
name = path.Join(prefix, filepath.ToSlash(relative))
|
||||
}
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name, header.Uid, header.Gid, header.Uname, header.Gname = name, 0, 0, "", ""
|
||||
if err := writer.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
file, err := os.Open(current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(writer, file)
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
return closeErr
|
||||
})
|
||||
}
|
||||
|
||||
func estimateTree(ctx context.Context, source string) (int64, error) {
|
||||
var total int64
|
||||
err := filepath.WalkDir(source, func(_ string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) {
|
||||
return ErrUnsafePath
|
||||
}
|
||||
if info.Mode().IsRegular() {
|
||||
total += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return total, err
|
||||
}
|
||||
|
||||
func ensureFreeSpace(directory string, required int64) error {
|
||||
var stats unix.Statfs_t
|
||||
if err := unix.Statfs(directory, &stats); err != nil {
|
||||
return err
|
||||
}
|
||||
available := int64(stats.Bavail) * int64(stats.Bsize)
|
||||
if available < required {
|
||||
return ErrInvalidState
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) restoreArchive(archive string, current instance.StoredInstance, manifest Manifest) error {
|
||||
stage, err := os.MkdirTemp(s.serversRoot, ".dogama-restore-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
file, err := os.Open(archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
decoder, err := zstd.NewReader(file, zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxMemory(2<<30))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer decoder.Close()
|
||||
reader := tar.NewReader(decoder)
|
||||
var total int64
|
||||
sawManifest := false
|
||||
for {
|
||||
header, err := reader.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if header.Name == "manifest.json" {
|
||||
if sawManifest || header.Size < 1 || header.Size > 64<<10 {
|
||||
return ErrIntegrity
|
||||
}
|
||||
var archived Manifest
|
||||
if err := json.NewDecoder(io.LimitReader(reader, header.Size)).Decode(&archived); err != nil {
|
||||
return ErrIntegrity
|
||||
}
|
||||
if archived.SchemaVersion != manifestSchemaVersion || archived.BackupID != manifest.BackupID || archived.InstanceID != manifest.InstanceID || archived.TemplateID != manifest.TemplateID || archived.TemplateVersion != manifest.TemplateVersion || strings.Join(archived.MountIDs, "\x00") != strings.Join(manifest.MountIDs, "\x00") {
|
||||
return ErrIntegrity
|
||||
}
|
||||
sawManifest = true
|
||||
continue
|
||||
}
|
||||
if !fs.ValidPath(header.Name) || !strings.HasPrefix(header.Name, "data/") || header.Linkname != "" {
|
||||
return ErrUnsafePath
|
||||
}
|
||||
if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeDir {
|
||||
return ErrUnsafePath
|
||||
}
|
||||
total += header.Size
|
||||
if total > 1<<40 {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
target := filepath.Join(stage, filepath.FromSlash(header.Name))
|
||||
if !within(stage, target) {
|
||||
return ErrUnsafePath
|
||||
}
|
||||
if header.Typeflag == tar.TypeDir {
|
||||
if err := os.MkdirAll(target, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.CopyN(out, reader, header.Size)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
}
|
||||
if !sawManifest {
|
||||
return ErrIntegrity
|
||||
}
|
||||
mountPaths := make(map[string]string)
|
||||
for _, mount := range current.Preview.Mounts {
|
||||
mountPaths[mount.ID] = mount.HostPath
|
||||
}
|
||||
var swapped []struct{ live, previous string }
|
||||
rollback := func() {
|
||||
for index := len(swapped) - 1; index >= 0; index-- {
|
||||
_ = os.RemoveAll(swapped[index].live)
|
||||
_ = os.Rename(swapped[index].previous, swapped[index].live)
|
||||
}
|
||||
}
|
||||
for _, mountID := range manifest.MountIDs {
|
||||
live := mountPaths[mountID]
|
||||
if _, err := s.serverPath(live); err != nil {
|
||||
rollback()
|
||||
return err
|
||||
}
|
||||
staged := filepath.Join(stage, "data", mountID)
|
||||
if _, err := os.Stat(staged); err != nil {
|
||||
rollback()
|
||||
return ErrIntegrity
|
||||
}
|
||||
previous := live + ".dogama-previous-" + manifest.BackupID
|
||||
if err := os.Rename(live, previous); err != nil {
|
||||
rollback()
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(staged, live); err != nil {
|
||||
_ = os.Rename(previous, live)
|
||||
rollback()
|
||||
return err
|
||||
}
|
||||
swapped = append(swapped, struct{ live, previous string }{live, previous})
|
||||
}
|
||||
for _, item := range swapped {
|
||||
if err := os.RemoveAll(item.previous); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) applyRetention(ctx context.Context, instanceID string, count int) error {
|
||||
candidates, err := s.repository.RetentionCandidates(ctx, instanceID, count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
full, err := s.backupPath(candidate.RelativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(full); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := s.repository.MarkBackupDeleted(ctx, candidate.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) fail(ctx context.Context, operationID, backupID, code string, cause error) error {
|
||||
_ = s.repository.FailBackup(ctx, backupID, code)
|
||||
_ = s.repository.FailOperation(ctx, operationID, "error", code)
|
||||
return fmt.Errorf("%s: %w", code, cause)
|
||||
}
|
||||
|
||||
func (s *Service) restoreFail(ctx context.Context, operationID, code string, cause error) error {
|
||||
_ = s.repository.FailOperation(ctx, operationID, "intervention_required", code)
|
||||
return fmt.Errorf("%s: %w", code, cause)
|
||||
}
|
||||
|
||||
func (s *Service) instanceBackupDirectory(instanceID string) (string, error) {
|
||||
if instanceID == "" || strings.ContainsAny(instanceID, `/\\`) {
|
||||
return "", ErrUnsafePath
|
||||
}
|
||||
result := filepath.Join(s.backupsRoot, instanceID)
|
||||
if !within(s.backupsRoot, result) {
|
||||
return "", ErrUnsafePath
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) backupPath(relative string) (string, error) {
|
||||
if !fs.ValidPath(relative) {
|
||||
return "", ErrUnsafePath
|
||||
}
|
||||
result := filepath.Join(s.backupsRoot, filepath.FromSlash(relative))
|
||||
if !within(s.backupsRoot, result) {
|
||||
return "", ErrUnsafePath
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) serverPath(value string) (string, error) {
|
||||
clean := filepath.Clean(value)
|
||||
if !filepath.IsAbs(clean) || !within(s.serversRoot, clean) {
|
||||
return "", ErrUnsafePath
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func canonicalRoot(value string) (string, error) {
|
||||
if value == "" {
|
||||
return "", ErrUnsafePath
|
||||
}
|
||||
absolute, err := filepath.Abs(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(absolute, 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.EvalSymlinks(absolute)
|
||||
}
|
||||
|
||||
func within(root, candidate string) bool {
|
||||
relative, err := filepath.Rel(root, candidate)
|
||||
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func verifyFile(name string, expectedSize int64, expectedSHA string) error {
|
||||
file, err := os.Open(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
hasher := sha256.New()
|
||||
size, err := io.Copy(hasher, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if size != expectedSize || !strings.EqualFold(hex.EncodeToString(hasher.Sum(nil)), expectedSHA) {
|
||||
return ErrIntegrity
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validOrigin(value string) bool {
|
||||
switch value {
|
||||
case "manual", "scheduled", "pre_update", "pre_restore", "idle_shutdown", "imported", "system":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func token() string {
|
||||
buffer := make([]byte, 24)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buffer)
|
||||
}
|
||||
|
||||
func lifecycleFromAgent(state agentwire.InstanceState) (string, string) {
|
||||
if !state.Running {
|
||||
return "stopped", "stopped"
|
||||
}
|
||||
if state.Ready {
|
||||
return "online", "ready"
|
||||
}
|
||||
if state.Health == "unhealthy" || state.Health == "none" {
|
||||
return "degraded", "degraded"
|
||||
}
|
||||
return "starting", "running"
|
||||
}
|
||||
|
||||
func cronParser() cron.Parser {
|
||||
return cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package backup_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
"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/instance"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
type backupAgent struct{ running bool }
|
||||
|
||||
func (a *backupAgent) StartInstance(_ context.Context, id string) (agentwire.InstanceState, error) {
|
||||
a.running = true
|
||||
return agentwire.InstanceState{InstanceID: id, ContainerID: "container", Running: true, Ready: true, Health: "healthy"}, nil
|
||||
}
|
||||
|
||||
func (a *backupAgent) StopInstance(_ context.Context, id string, _ int) (agentwire.InstanceState, error) {
|
||||
a.running = false
|
||||
return agentwire.InstanceState{InstanceID: id, ContainerID: "container", Health: "stopped"}, nil
|
||||
}
|
||||
|
||||
func TestCreateRestoreExportAndRetention(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
serversRoot, backupsRoot := filepath.Join(root, "servers"), filepath.Join(root, "backups")
|
||||
mount := filepath.Join(serversRoot, "instance", "saved")
|
||||
if err := os.MkdirAll(mount, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
world := filepath.Join(mount, "Level.sav")
|
||||
if err := os.WriteFile(world, []byte("world-v1"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := sqlite.Open(ctx, filepath.Join(root, "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository := sqlite.NewRepository(db)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Sync(ctx, snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authService := auth.New(db)
|
||||
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminSession, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, err := authService.Authenticate(ctx, adminSession.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{
|
||||
DisplayName: "Backup Test", Slug: "backup-test", HostPorts: map[string]int{"game": 38211},
|
||||
MountPaths: map[string]string{"saved": mount}, DataOrigin: "new", BackupRetention: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const instanceID = "backup-instance"
|
||||
if err := repository.CreateDraft(ctx, instance.Draft{ID: instanceID, Preview: preview}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE instances SET lifecycle_state='online', observed_state='ready', container_id='container', desired_running=1 WHERE id=?`, instanceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agent := &backupAgent{running: true}
|
||||
service, err := backup.New(repository, agent, serversRoot, backupsRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manual, err := service.Create(ctx, admin.ID, instanceID, "manual")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manual.Status != "available" || manual.SHA256 == "" || manual.SizeBytes == 0 || !agent.running {
|
||||
t.Fatalf("backup = %#v, running=%v", manual, agent.running)
|
||||
}
|
||||
exported, metadata, err := service.Export(ctx, instanceID, manual.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.ID != manual.ID {
|
||||
t.Fatalf("export metadata = %#v", metadata)
|
||||
}
|
||||
if err := exported.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(world, []byte("world-v2"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.Restore(ctx, admin.ID, instanceID, manual.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := os.ReadFile(world)
|
||||
if err != nil || string(restored) != "world-v1" {
|
||||
t.Fatalf("restored world = %q, error = %v", restored, err)
|
||||
}
|
||||
for index := 0; index < 3; index++ {
|
||||
if _, err := service.Create(ctx, admin.ID, instanceID, "scheduled"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backups, err := service.List(ctx, instanceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manualCount, scheduledCount, safetyCount int
|
||||
for _, item := range backups {
|
||||
switch item.Origin {
|
||||
case "manual":
|
||||
manualCount++
|
||||
case "scheduled":
|
||||
scheduledCount++
|
||||
case "pre_restore":
|
||||
safetyCount++
|
||||
}
|
||||
}
|
||||
if manualCount != 1 || scheduledCount != 2 || safetyCount != 1 {
|
||||
t.Fatalf("retained backups: manual=%d scheduled=%d safety=%d", manualCount, scheduledCount, safetyCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupRejectsSymlinks(t *testing.T) {
|
||||
ctx, service, actorID, instanceID, mount := backupFixture(t)
|
||||
outside := filepath.Join(t.TempDir(), "outside")
|
||||
if err := os.WriteFile(outside, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(mount, "escape")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := service.Create(ctx, actorID, instanceID, "manual")
|
||||
if !errors.Is(err, backup.ErrUnsafePath) {
|
||||
t.Fatalf("symlink backup error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func backupFixture(t *testing.T) (context.Context, *backup.Service, string, string, string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
serversRoot := filepath.Join(root, "servers")
|
||||
backupsRoot := filepath.Join(root, "backups")
|
||||
mount := filepath.Join(serversRoot, "instance", "saved")
|
||||
if err := os.MkdirAll(mount, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := sqlite.Open(ctx, filepath.Join(root, "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
repository := sqlite.NewRepository(db)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Sync(ctx, snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authService := auth.New(db)
|
||||
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actor, err := authService.Authenticate(ctx, session.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Backup Test", Slug: "backup-test", HostPorts: map[string]int{"game": 38211}, MountPaths: map[string]string{"saved": mount}, DataOrigin: "new", BackupRetention: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
instanceID := "backup-instance"
|
||||
if err := repository.CreateDraft(ctx, instance.Draft{ID: instanceID, Preview: preview}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE instances SET lifecycle_state='stopped', observed_state='stopped', container_id='container' WHERE id=?`, instanceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := backup.New(repository, &backupAgent{}, serversRoot, backupsRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ctx, service, actor.ID, instanceID, mount
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTemplateNotFound = errors.New("template not found")
|
||||
ErrImmutableSnapshot = errors.New("template version is immutable")
|
||||
)
|
||||
|
||||
type Summary struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
Description string `json:"description"`
|
||||
TrustStatus string `json:"trust_status"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// Repository persists immutable catalog snapshots.
|
||||
type Repository interface {
|
||||
Sync(context.Context, []Snapshot) error
|
||||
List(context.Context) ([]Summary, error)
|
||||
Get(context.Context, string, string) (Snapshot, error)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
// Package catalog validates and loads immutable game-template snapshots.
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/specs"
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const templateSchemaURL = "https://dogama.dev/schemas/template-v1.json"
|
||||
|
||||
// ValidationIssue points to one invalid field without exposing input secrets.
|
||||
type ValidationIssue struct {
|
||||
Path string `json:"path"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ValidationErrors groups field-specific template errors.
|
||||
type ValidationErrors struct{ Issues []ValidationIssue }
|
||||
|
||||
func (e *ValidationErrors) Error() string {
|
||||
return fmt.Sprintf("template validation failed with %d issue(s)", len(e.Issues))
|
||||
}
|
||||
|
||||
// Template is the validated subset needed to build a deployment preview.
|
||||
type Template struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Source struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"source"`
|
||||
Game struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
} `json:"game"`
|
||||
Requirements struct {
|
||||
Minimum Resources `json:"minimum"`
|
||||
Recommended Resources `json:"recommended"`
|
||||
} `json:"requirements"`
|
||||
Container struct {
|
||||
Image string `json:"image"`
|
||||
Tag string `json:"tag"`
|
||||
Entrypoint []string `json:"entrypoint,omitempty"`
|
||||
Arguments []string `json:"arguments,omitempty"`
|
||||
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
|
||||
Ports []Port `json:"ports"`
|
||||
Assets []struct {
|
||||
Source string `json:"source"`
|
||||
Destination string `json:"destination"`
|
||||
SHA256 string `json:"sha256"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
} `json:"assets"`
|
||||
} `json:"container"`
|
||||
Storage struct {
|
||||
Mounts []Mount `json:"mounts"`
|
||||
} `json:"storage"`
|
||||
Configuration struct {
|
||||
Fields []ConfigField `json:"fields"`
|
||||
} `json:"configuration"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Integration *struct {
|
||||
ModuleID string `json:"module_id"`
|
||||
PortID string `json:"port_id"`
|
||||
} `json:"integration,omitempty"`
|
||||
Backup struct {
|
||||
Strategy string `json:"strategy"`
|
||||
SourceMounts []string `json:"source_mounts"`
|
||||
} `json:"backup"`
|
||||
Healthcheck struct {
|
||||
Type string `json:"type"`
|
||||
PortID string `json:"port_id,omitempty"`
|
||||
StartupTimeoutSeconds int `json:"startup_timeout_seconds"`
|
||||
IntervalSeconds int `json:"interval_seconds"`
|
||||
} `json:"healthcheck"`
|
||||
Imports struct {
|
||||
Supported bool `json:"supported"`
|
||||
AcceptedFormats []string `json:"accepted_formats"`
|
||||
MaxExtractedSizeGB int `json:"max_extracted_size_gb"`
|
||||
RequiredPaths []string `json:"required_paths"`
|
||||
DestinationMount string `json:"destination_mount"`
|
||||
DestinationRelativePath string `json:"destination_relative_path"`
|
||||
RequiresStoppedServer bool `json:"requires_stopped_server"`
|
||||
} `json:"imports"`
|
||||
Mods struct {
|
||||
Supported bool `json:"supported"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
DestinationMount string `json:"destination_mount,omitempty"`
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
} `json:"mods"`
|
||||
Updates struct {
|
||||
BackupBeforeUpdate bool `json:"backup_before_update"`
|
||||
AutomaticDefault bool `json:"automatic_default"`
|
||||
RollbackOnFailure bool `json:"rollback_on_failure"`
|
||||
HealthTimeoutSeconds int `json:"health_timeout_seconds"`
|
||||
} `json:"updates"`
|
||||
}
|
||||
|
||||
type Resources struct {
|
||||
CPUCores float64 `json:"cpu_cores"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
StorageGB int `json:"storage_gb"`
|
||||
}
|
||||
|
||||
type Port struct {
|
||||
ID string `json:"id"`
|
||||
ContainerPort int `json:"container_port"`
|
||||
Protocol string `json:"protocol"`
|
||||
Purpose string `json:"purpose"`
|
||||
Publish bool `json:"publish"`
|
||||
}
|
||||
|
||||
type Mount struct {
|
||||
ID string `json:"id"`
|
||||
ContainerPath string `json:"container_path"`
|
||||
Category string `json:"category"`
|
||||
Backup bool `json:"backup"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
type ConfigField struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Visibility string `json:"visibility"`
|
||||
Required bool `json:"required"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// Snapshot is an immutable validated template version.
|
||||
type Snapshot struct {
|
||||
Template Template
|
||||
CanonicalYAML string
|
||||
Digest string
|
||||
Origin string
|
||||
AssetRoot string
|
||||
}
|
||||
|
||||
// LoadFS validates every template.yaml below root and returns stable snapshots.
|
||||
func LoadFS(source fs.FS, root string) ([]Snapshot, error) {
|
||||
pattern := path.Join(root, "*", "template.yaml")
|
||||
names, err := fs.Glob(source, pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list catalog templates: %w", err)
|
||||
}
|
||||
sort.Strings(names)
|
||||
if len(names) == 0 {
|
||||
return nil, errors.New("catalog contains no templates")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
result := make([]Snapshot, 0, len(names))
|
||||
for _, name := range names {
|
||||
body, err := fs.ReadFile(source, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read template %s: %w", name, err)
|
||||
}
|
||||
snapshot, err := Validate(body, path.Dir(name), source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validate %s: %w", name, err)
|
||||
}
|
||||
key := snapshot.Template.ID + "@" + snapshot.Template.Version
|
||||
if _, exists := seen[key]; exists {
|
||||
return nil, fmt.Errorf("duplicate template version %s", key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, snapshot)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Validate applies YAML safety, JSON Schema and cross-field validation.
|
||||
func Validate(body []byte, assetRoot string, source fs.FS) (Snapshot, error) {
|
||||
var document yaml.Node
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(body))
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return Snapshot{}, &ValidationErrors{Issues: []ValidationIssue{{Path: "/", Message: "invalid YAML"}}}
|
||||
}
|
||||
if hasAlias(&document) {
|
||||
return Snapshot{}, &ValidationErrors{Issues: []ValidationIssue{{Path: "/", Line: document.Line, Message: "YAML aliases are not allowed"}}}
|
||||
}
|
||||
var raw any
|
||||
if err := document.Decode(&raw); err != nil {
|
||||
return Snapshot{}, &ValidationErrors{Issues: []ValidationIssue{{Path: "/", Message: "invalid YAML value"}}}
|
||||
}
|
||||
canonical, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("canonicalize template: %w", err)
|
||||
}
|
||||
schema, err := compileSchema()
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if err := schema.Validate(raw); err != nil {
|
||||
return Snapshot{}, validationErrors(err, &document)
|
||||
}
|
||||
var template Template
|
||||
if err := json.Unmarshal(canonical, &template); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("decode validated template: %w", err)
|
||||
}
|
||||
issues := crossValidate(template, assetRoot, source)
|
||||
if len(issues) != 0 {
|
||||
return Snapshot{}, &ValidationErrors{Issues: issues}
|
||||
}
|
||||
pretty, _ := json.MarshalIndent(raw, "", " ")
|
||||
digest := sha256.Sum256(canonical)
|
||||
return Snapshot{
|
||||
Template: template,
|
||||
CanonicalYAML: string(pretty) + "\n",
|
||||
Digest: hex.EncodeToString(digest[:]),
|
||||
Origin: template.Source.Type,
|
||||
AssetRoot: assetRoot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func compileSchema() (*jsonschema.Schema, error) {
|
||||
body, err := specs.Files.ReadFile("template.schema.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read template schema: %w", err)
|
||||
}
|
||||
compiler := jsonschema.NewCompiler()
|
||||
compiler.AssertFormat()
|
||||
compiler.UseRegexpEngine(compileECMAScript)
|
||||
var document any
|
||||
if err := json.Unmarshal(body, &document); err != nil {
|
||||
return nil, fmt.Errorf("decode template schema: %w", err)
|
||||
}
|
||||
if err := compiler.AddResource(templateSchemaURL, document); err != nil {
|
||||
return nil, fmt.Errorf("load template schema: %w", err)
|
||||
}
|
||||
schema, err := compiler.Compile(templateSchemaURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compile template schema: %w", err)
|
||||
}
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
type ecmaRegexp regexp2.Regexp
|
||||
|
||||
func (expression *ecmaRegexp) MatchString(value string) bool {
|
||||
matched, err := (*regexp2.Regexp)(expression).MatchString(value)
|
||||
return err == nil && matched
|
||||
}
|
||||
|
||||
func (expression *ecmaRegexp) String() string {
|
||||
return (*regexp2.Regexp)(expression).String()
|
||||
}
|
||||
|
||||
func compileECMAScript(pattern string) (jsonschema.Regexp, error) {
|
||||
expression, err := regexp2.Compile(pattern, regexp2.ECMAScript)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return (*ecmaRegexp)(expression), nil
|
||||
}
|
||||
|
||||
func validationErrors(err error, document *yaml.Node) error {
|
||||
var validation *jsonschema.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
return err
|
||||
}
|
||||
leaves := make([]*jsonschema.ValidationError, 0)
|
||||
var walk func(*jsonschema.ValidationError)
|
||||
walk = func(current *jsonschema.ValidationError) {
|
||||
if len(current.Causes) == 0 {
|
||||
leaves = append(leaves, current)
|
||||
return
|
||||
}
|
||||
for _, cause := range current.Causes {
|
||||
walk(cause)
|
||||
}
|
||||
}
|
||||
walk(validation)
|
||||
issues := make([]ValidationIssue, 0, len(leaves))
|
||||
for _, leaf := range leaves {
|
||||
pointer := "/" + strings.Join(leaf.InstanceLocation, "/")
|
||||
issues = append(issues, ValidationIssue{Path: pointer, Line: lineFor(document, leaf.InstanceLocation), Message: "value does not satisfy the template schema"})
|
||||
}
|
||||
return &ValidationErrors{Issues: issues}
|
||||
}
|
||||
|
||||
func crossValidate(template Template, assetRoot string, source fs.FS) []ValidationIssue {
|
||||
var issues []ValidationIssue
|
||||
ports := make(map[string]Port)
|
||||
for _, port := range template.Container.Ports {
|
||||
if _, exists := ports[port.ID]; exists {
|
||||
issues = append(issues, ValidationIssue{Path: "/container/ports", Message: "port IDs must be unique"})
|
||||
}
|
||||
ports[port.ID] = port
|
||||
}
|
||||
mounts := make(map[string]Mount)
|
||||
for _, mount := range template.Storage.Mounts {
|
||||
if _, exists := mounts[mount.ID]; exists {
|
||||
issues = append(issues, ValidationIssue{Path: "/storage/mounts", Message: "mount IDs must be unique"})
|
||||
}
|
||||
mounts[mount.ID] = mount
|
||||
}
|
||||
fields := make(map[string]struct{})
|
||||
for _, field := range template.Configuration.Fields {
|
||||
if _, exists := fields[field.ID]; exists {
|
||||
issues = append(issues, ValidationIssue{Path: "/configuration/fields", Message: "field IDs must be unique"})
|
||||
}
|
||||
fields[field.ID] = struct{}{}
|
||||
if field.Type == "secret" && (field.Visibility != "secret" || field.Default != nil) {
|
||||
issues = append(issues, ValidationIssue{Path: "/configuration/fields/" + field.ID, Message: "secret fields require secret visibility and no default"})
|
||||
}
|
||||
}
|
||||
for _, mountID := range template.Backup.SourceMounts {
|
||||
mount, exists := mounts[mountID]
|
||||
if !exists || !mount.Backup {
|
||||
issues = append(issues, ValidationIssue{Path: "/backup/source_mounts", Message: "backup sources must reference backup-enabled mounts"})
|
||||
}
|
||||
}
|
||||
if template.Integration != nil {
|
||||
port, exists := ports[template.Integration.PortID]
|
||||
if !exists || port.Purpose != "integration" {
|
||||
issues = append(issues, ValidationIssue{Path: "/integration/port_id", Message: "integration must reference an integration port"})
|
||||
}
|
||||
}
|
||||
if template.Healthcheck.PortID != "" {
|
||||
if _, exists := ports[template.Healthcheck.PortID]; !exists {
|
||||
issues = append(issues, ValidationIssue{Path: "/healthcheck/port_id", Message: "healthcheck port does not exist"})
|
||||
}
|
||||
}
|
||||
if template.Imports.Supported {
|
||||
if _, exists := mounts[template.Imports.DestinationMount]; !exists {
|
||||
issues = append(issues, ValidationIssue{Path: "/imports/destination_mount", Message: "import destination mount does not exist"})
|
||||
}
|
||||
}
|
||||
if template.Requirements.Recommended.CPUCores < template.Requirements.Minimum.CPUCores || template.Requirements.Recommended.MemoryMB < template.Requirements.Minimum.MemoryMB || template.Requirements.Recommended.StorageGB < template.Requirements.Minimum.StorageGB {
|
||||
issues = append(issues, ValidationIssue{Path: "/requirements/recommended", Message: "recommended resources must not be below minimum resources"})
|
||||
}
|
||||
for _, asset := range template.Container.Assets {
|
||||
body, err := fs.ReadFile(source, path.Join(assetRoot, asset.Source))
|
||||
digest := sha256.Sum256(body)
|
||||
if err != nil || hex.EncodeToString(digest[:]) != asset.SHA256 {
|
||||
issues = append(issues, ValidationIssue{Path: "/container/assets/" + asset.Source, Message: "asset is missing or its checksum does not match"})
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func hasAlias(node *yaml.Node) bool {
|
||||
if node.Kind == yaml.AliasNode {
|
||||
return true
|
||||
}
|
||||
for _, child := range node.Content {
|
||||
if hasAlias(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func lineFor(document *yaml.Node, segments []string) int {
|
||||
node := document
|
||||
if node.Kind == yaml.DocumentNode && len(node.Content) != 0 {
|
||||
node = node.Content[0]
|
||||
}
|
||||
for _, segment := range segments {
|
||||
if node.Kind == yaml.MappingNode {
|
||||
found := false
|
||||
for index := 0; index+1 < len(node.Content); index += 2 {
|
||||
if node.Content[index].Value == segment {
|
||||
node = node.Content[index+1]
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return node.Line
|
||||
}
|
||||
continue
|
||||
}
|
||||
if node.Kind == yaml.SequenceNode {
|
||||
var index int
|
||||
if _, err := fmt.Sscanf(segment, "%d", &index); err != nil || index < 0 || index >= len(node.Content) {
|
||||
return node.Line
|
||||
}
|
||||
node = node.Content[index]
|
||||
}
|
||||
}
|
||||
return node.Line
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package catalog_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
)
|
||||
|
||||
func TestBuiltInCatalogValidatesDeterministically(t *testing.T) {
|
||||
first, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first) != 1 || first[0].Template.ID != "palworld-official" || first[0].Digest != second[0].Digest || first[0].CanonicalYAML != second[0].CanonicalYAML {
|
||||
t.Fatalf("catalog snapshots = %#v, %#v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaErrorsContainFieldPathAndLine(t *testing.T) {
|
||||
body, err := catalogdata.Files.ReadFile("palworld/template.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = []byte(strings.Replace(string(body), "schema_version: 1", "schema_version: 2", 1))
|
||||
_, err = catalog.Validate(body, "palworld", catalogdata.Files)
|
||||
var validation *catalog.ValidationErrors
|
||||
if !errors.As(err, &validation) || len(validation.Issues) == 0 {
|
||||
t.Fatalf("validation error = %#v", err)
|
||||
}
|
||||
if validation.Issues[0].Path == "" || validation.Issues[0].Line == 0 {
|
||||
t.Fatalf("validation issue = %#v", validation.Issues[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossValidationRejectsUnknownBackupMount(t *testing.T) {
|
||||
body, err := catalogdata.Files.ReadFile("palworld/template.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = []byte(strings.Replace(string(body), " - saved\n restart_after_backup", " - missing\n restart_after_backup", 1))
|
||||
_, err = catalog.Validate(body, "palworld", catalogdata.Files)
|
||||
var validation *catalog.ValidationErrors
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("validation error = %#v", err)
|
||||
}
|
||||
found := false
|
||||
for _, issue := range validation.Issues {
|
||||
found = found || issue.Path == "/backup/source_mounts"
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("issues = %#v", validation.Issues)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
// Package importexport validates untrusted game-data archives in isolated staging.
|
||||
package importexport
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidInput = errors.New("invalid import input")
|
||||
ErrUnsafeArchive = errors.New("unsafe import archive")
|
||||
ErrLimitExceeded = errors.New("import limit exceeded")
|
||||
ErrNotRecognized = errors.New("import layout not recognized")
|
||||
)
|
||||
|
||||
const (
|
||||
maxFiles = 100000
|
||||
maxDepth = 20
|
||||
)
|
||||
|
||||
type Policy struct {
|
||||
TemplateID string
|
||||
TemplateVersion string
|
||||
AcceptedFormats []string
|
||||
MaxExpandedBytes int64
|
||||
RequiredPaths []string
|
||||
}
|
||||
|
||||
type Import struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Format string `json:"format"`
|
||||
DetectedType string `json:"detected_type,omitempty"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
FileCount int `json:"file_count"`
|
||||
ExpandedSizeBytes int64 `json:"expanded_size_bytes"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
RelativeStagePath string `json:"-"`
|
||||
DataRoot string `json:"-"`
|
||||
TemplateID string `json:"template_id"`
|
||||
TemplateVersion string `json:"template_version"`
|
||||
InstanceID string `json:"instance_id,omitempty"`
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
BeginImport(context.Context, Import, string) error
|
||||
CompleteImport(context.Context, Import) error
|
||||
FailImport(context.Context, string, string) error
|
||||
ExpireImports(context.Context, string) ([]string, error)
|
||||
GetImport(context.Context, string) (Import, error)
|
||||
AttachImport(context.Context, string, string) error
|
||||
}
|
||||
|
||||
func (s *Service) CleanupExpired(ctx context.Context) error {
|
||||
paths, err := s.repository.ExpireImports(ctx, s.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, relative := range paths {
|
||||
if !fs.ValidPath(relative) {
|
||||
return ErrUnsafeArchive
|
||||
}
|
||||
target := filepath.Join(s.root, filepath.FromSlash(relative))
|
||||
if !withinRoot(s.root, target) {
|
||||
return ErrUnsafeArchive
|
||||
}
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
root string
|
||||
serversRoot string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(repository Repository, root, serversRoot string) (*Service, error) {
|
||||
if root == "" || serversRoot == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
absolute, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(absolute, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonical, err := filepath.EvalSymlinks(absolute)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serverAbsolute, err := filepath.Abs(serversRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(serverAbsolute, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serverCanonical, err := filepath.EvalSymlinks(serverAbsolute)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Service{repository: repository, root: canonical, serversRoot: serverCanonical, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Stage(ctx context.Context, actorID, format string, source io.Reader, policy Policy) (Import, error) {
|
||||
if actorID == "" || policy.TemplateID == "" || policy.TemplateVersion == "" || !contains(policy.AcceptedFormats, format) || policy.MaxExpandedBytes < 1 {
|
||||
return Import{}, ErrInvalidInput
|
||||
}
|
||||
id := importToken()
|
||||
if id == "" {
|
||||
return Import{}, errors.New("generate import identifier")
|
||||
}
|
||||
directory := filepath.Join(s.root, id)
|
||||
if !withinRoot(s.root, directory) {
|
||||
return Import{}, ErrUnsafeArchive
|
||||
}
|
||||
if err := os.Mkdir(directory, 0o750); err != nil {
|
||||
return Import{}, err
|
||||
}
|
||||
value := Import{ID: id, Status: "staging", Format: format, RelativeStagePath: id, TemplateID: policy.TemplateID, TemplateVersion: policy.TemplateVersion, ExpiresAt: s.now().Add(24 * time.Hour).UTC().Format(time.RFC3339Nano)}
|
||||
if err := s.repository.BeginImport(ctx, value, actorID); err != nil {
|
||||
_ = os.RemoveAll(directory)
|
||||
return Import{}, err
|
||||
}
|
||||
fail := func(code string, err error) (Import, error) {
|
||||
_ = s.repository.FailImport(ctx, id, code)
|
||||
_ = os.RemoveAll(directory)
|
||||
return Import{}, err
|
||||
}
|
||||
upload := filepath.Join(directory, "upload")
|
||||
file, err := os.OpenFile(upload, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fail("import_stage_failed", err)
|
||||
}
|
||||
maxUpload := policy.MaxExpandedBytes
|
||||
written, copyErr := io.Copy(file, io.LimitReader(source, maxUpload+1))
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil || closeErr != nil {
|
||||
if copyErr == nil {
|
||||
copyErr = closeErr
|
||||
}
|
||||
return fail("import_upload_failed", copyErr)
|
||||
}
|
||||
if written > maxUpload {
|
||||
return fail("import_upload_limit", ErrLimitExceeded)
|
||||
}
|
||||
extracted := filepath.Join(directory, "data")
|
||||
if err := os.Mkdir(extracted, 0o750); err != nil {
|
||||
return fail("import_stage_failed", err)
|
||||
}
|
||||
files, size, paths, err := extract(ctx, upload, extracted, format, policy.MaxExpandedBytes)
|
||||
if err != nil {
|
||||
return fail("import_validation_failed", err)
|
||||
}
|
||||
if !requiredPresent(paths, policy.RequiredPaths) {
|
||||
return fail("import_layout_unrecognized", ErrNotRecognized)
|
||||
}
|
||||
dataRoot, err := detectedRoot(paths, policy.RequiredPaths)
|
||||
if err != nil {
|
||||
return fail("import_layout_ambiguous", err)
|
||||
}
|
||||
if err := os.Remove(upload); err != nil {
|
||||
return fail("import_cleanup_failed", err)
|
||||
}
|
||||
value.Status, value.DetectedType, value.Confidence, value.FileCount, value.ExpandedSizeBytes, value.DataRoot = "validated", "game_save", "confirmed", files, size, dataRoot
|
||||
if err := s.repository.CompleteImport(ctx, value); err != nil {
|
||||
return fail("import_persist_failed", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Service) ValidateSelection(ctx context.Context, id, templateID, templateVersion string) error {
|
||||
value, err := s.repository.GetImport(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value.Status != "validated" || value.TemplateID != templateID || value.TemplateVersion != templateVersion {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ApplyToInstance(ctx context.Context, id, instanceID, templateID, templateVersion, mountPath, relativePath string) error {
|
||||
value, err := s.repository.GetImport(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value.InstanceID == instanceID && value.Status == "attached" {
|
||||
return nil
|
||||
}
|
||||
if value.Status != "validated" || value.TemplateID != templateID || value.TemplateVersion != templateVersion {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
live := filepath.Join(mountPath, filepath.FromSlash(relativePath))
|
||||
if !filepath.IsAbs(live) || !withinRoot(s.serversRoot, live) {
|
||||
return ErrUnsafeArchive
|
||||
}
|
||||
source := filepath.Join(s.root, filepath.FromSlash(value.RelativeStagePath), "data", filepath.FromSlash(value.DataRoot))
|
||||
if !withinRoot(s.root, source) {
|
||||
return ErrUnsafeArchive
|
||||
}
|
||||
if info, err := os.Stat(source); err != nil || !info.IsDir() {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
if entries, err := os.ReadDir(live); err == nil && len(entries) != 0 {
|
||||
return ErrInvalidInput
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(live), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary, err := os.MkdirTemp(filepath.Dir(live), ".dogama-import-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(temporary)
|
||||
if err := copyValidatedTree(source, temporary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(live); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(temporary, live); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repository.AttachImport(ctx, id, instanceID); err != nil {
|
||||
_ = os.RemoveAll(live)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extract(ctx context.Context, archive, destination, format string, limit int64) (int, int64, []string, error) {
|
||||
switch format {
|
||||
case "zip":
|
||||
return extractZIP(ctx, archive, destination, limit)
|
||||
case "tar", "tar.gz", "tar.zst":
|
||||
return extractTar(ctx, archive, destination, format, limit)
|
||||
default:
|
||||
return 0, 0, nil, ErrInvalidInput
|
||||
}
|
||||
}
|
||||
|
||||
func extractZIP(ctx context.Context, archive, destination string, limit int64) (int, int64, []string, error) {
|
||||
reader, err := zip.OpenReader(archive)
|
||||
if err != nil {
|
||||
return 0, 0, nil, ErrUnsafeArchive
|
||||
}
|
||||
defer reader.Close()
|
||||
var count int
|
||||
var total int64
|
||||
var paths []string
|
||||
for _, entry := range reader.File {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0, 0, nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
name, err := safeName(entry.Name)
|
||||
if err != nil || entry.Mode()&os.ModeSymlink != 0 || (!entry.Mode().IsRegular() && !entry.FileInfo().IsDir()) {
|
||||
return 0, 0, nil, ErrUnsafeArchive
|
||||
}
|
||||
if entry.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(filepath.Join(destination, filepath.FromSlash(name)), 0o750); err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
count++
|
||||
total += int64(entry.UncompressedSize64)
|
||||
if count > maxFiles || total > limit {
|
||||
return 0, 0, nil, ErrLimitExceeded
|
||||
}
|
||||
input, err := entry.Open()
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
if err := writeExtracted(destination, name, input, int64(entry.UncompressedSize64)); err != nil {
|
||||
input.Close()
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
if err := input.Close(); err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
paths = append(paths, name)
|
||||
}
|
||||
return count, total, paths, nil
|
||||
}
|
||||
|
||||
func extractTar(ctx context.Context, archive, destination, format string, limit int64) (int, int64, []string, error) {
|
||||
file, err := os.Open(archive)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
var source io.Reader = file
|
||||
var closer io.Closer
|
||||
if format == "tar.gz" {
|
||||
value, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return 0, 0, nil, ErrUnsafeArchive
|
||||
}
|
||||
source, closer = value, value
|
||||
}
|
||||
if format == "tar.zst" {
|
||||
value, err := zstd.NewReader(file, zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxMemory(2<<30))
|
||||
if err != nil {
|
||||
return 0, 0, nil, ErrUnsafeArchive
|
||||
}
|
||||
source, closer = value, value.IOReadCloser()
|
||||
}
|
||||
if closer != nil {
|
||||
defer closer.Close()
|
||||
}
|
||||
reader := tar.NewReader(source)
|
||||
var count int
|
||||
var total int64
|
||||
var paths []string
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0, 0, nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
header, err := reader.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return 0, 0, nil, ErrUnsafeArchive
|
||||
}
|
||||
name, err := safeName(header.Name)
|
||||
if err != nil || header.Linkname != "" {
|
||||
return 0, 0, nil, ErrUnsafeArchive
|
||||
}
|
||||
if header.Typeflag == tar.TypeDir {
|
||||
if err := os.MkdirAll(filepath.Join(destination, filepath.FromSlash(name)), 0o750); err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !header.FileInfo().Mode().IsRegular() {
|
||||
return 0, 0, nil, ErrUnsafeArchive
|
||||
}
|
||||
count++
|
||||
total += header.Size
|
||||
if count > maxFiles || total > limit {
|
||||
return 0, 0, nil, ErrLimitExceeded
|
||||
}
|
||||
if err := writeExtracted(destination, name, reader, header.Size); err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
paths = append(paths, name)
|
||||
}
|
||||
return count, total, paths, nil
|
||||
}
|
||||
|
||||
func writeExtracted(root, name string, source io.Reader, size int64) error {
|
||||
target := filepath.Join(root, filepath.FromSlash(name))
|
||||
if !withinRoot(root, target) {
|
||||
return ErrUnsafeArchive
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, copyErr := io.CopyN(file, source, size)
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil || written != size {
|
||||
return ErrUnsafeArchive
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func safeName(value string) (string, error) {
|
||||
if strings.Contains(value, "\\") || strings.ContainsRune(value, 0) || strings.HasPrefix(value, "/") {
|
||||
return "", ErrUnsafeArchive
|
||||
}
|
||||
clean := path.Clean(value)
|
||||
if clean == "." || !fs.ValidPath(clean) || strings.Contains(strings.Split(clean, "/")[0], ":") || len(strings.Split(clean, "/")) > maxDepth {
|
||||
return "", ErrUnsafeArchive
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func requiredPresent(paths, required []string) bool {
|
||||
for _, expected := range required {
|
||||
found := false
|
||||
for _, candidate := range paths {
|
||||
if candidate == expected || strings.HasSuffix(candidate, "/"+expected) || strings.HasPrefix(candidate, expected+"/") || strings.Contains(candidate, "/"+expected+"/") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func detectedRoot(paths, required []string) (string, error) {
|
||||
root := ""
|
||||
for _, expected := range required {
|
||||
found := ""
|
||||
for _, candidate := range paths {
|
||||
marker := "/" + expected
|
||||
if candidate == expected || strings.HasPrefix(candidate, expected+"/") {
|
||||
found = "."
|
||||
break
|
||||
}
|
||||
if index := strings.Index(candidate, marker); index >= 0 && (len(candidate) == index+len(marker) || candidate[index+len(marker)] == '/') {
|
||||
found = candidate[:index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == "" {
|
||||
return "", ErrNotRecognized
|
||||
}
|
||||
if root == "" {
|
||||
root = found
|
||||
} else if root != found {
|
||||
return "", ErrNotRecognized
|
||||
}
|
||||
}
|
||||
if root == "" {
|
||||
root = "."
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func copyValidatedTree(source, destination string) error {
|
||||
return filepath.WalkDir(source, func(current string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) {
|
||||
return ErrUnsafeArchive
|
||||
}
|
||||
relative, err := filepath.Rel(source, current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if relative == "." {
|
||||
return nil
|
||||
}
|
||||
target := filepath.Join(destination, relative)
|
||||
if !withinRoot(destination, target) {
|
||||
return ErrUnsafeArchive
|
||||
}
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, 0o750)
|
||||
}
|
||||
input, err := os.Open(current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
_ = input.Close()
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(output, input)
|
||||
inputCloseErr := input.Close()
|
||||
closeErr := output.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if inputCloseErr != nil {
|
||||
return inputCloseErr
|
||||
}
|
||||
return closeErr
|
||||
})
|
||||
}
|
||||
|
||||
func contains(values []string, expected string) bool {
|
||||
for _, value := range values {
|
||||
if value == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func withinRoot(root, candidate string) bool {
|
||||
relative, err := filepath.Rel(root, candidate)
|
||||
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
|
||||
}
|
||||
func importToken() string {
|
||||
value := make([]byte, 24)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package importexport_test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"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/persistence/sqlite"
|
||||
)
|
||||
|
||||
func TestStageValidZIPAndRejectTraversal(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(root, "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository := sqlite.NewRepository(db)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Sync(ctx, snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authService := auth.New(db)
|
||||
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actor, err := authService.Authenticate(ctx, session.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serversRoot := filepath.Join(root, "servers")
|
||||
service, err := importexport.New(repository, filepath.Join(root, "imports"), serversRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
policy := importexport.Policy{TemplateID: "palworld-official", TemplateVersion: "1.0.0", AcceptedFormats: []string{"zip"}, MaxExpandedBytes: 1 << 20, RequiredPaths: []string{"Level.sav", "Players"}}
|
||||
valid := zipBytes(t, map[string]string{"Save/Level.sav": "world", "Save/Players/player.sav": "player"})
|
||||
result, err := service.Stage(ctx, actor.ID, "zip", bytes.NewReader(valid), policy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "validated" || result.Confidence != "confirmed" || result.FileCount != 2 {
|
||||
t.Fatalf("import = %#v", result)
|
||||
}
|
||||
destination := filepath.Join(serversRoot, "instance", "saved")
|
||||
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Import Test", Slug: "import-test", HostPorts: map[string]int{"game": 38211}, MountPaths: map[string]string{"saved": destination}, DataOrigin: "new", BackupRetention: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.CreateDraft(ctx, instance.Draft{ID: "instance-id", Preview: preview}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.ApplyToInstance(ctx, result.ID, "instance-id", policy.TemplateID, policy.TemplateVersion, destination, "SaveGames/0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body, err := os.ReadFile(filepath.Join(destination, "SaveGames", "0", "Level.sav")); err != nil || string(body) != "world" {
|
||||
t.Fatalf("applied import=%q error=%v", body, err)
|
||||
}
|
||||
if err := service.ApplyToInstance(ctx, result.ID, "instance-id", policy.TemplateID, policy.TemplateVersion, destination, "SaveGames/0"); err != nil {
|
||||
t.Fatalf("idempotent apply: %v", err)
|
||||
}
|
||||
unsafe := zipBytes(t, map[string]string{"../escape": "bad", "Level.sav": "world", "Players/player": "player"})
|
||||
if _, err := service.Stage(ctx, actor.ID, "zip", bytes.NewReader(unsafe), policy); err == nil {
|
||||
t.Fatal("traversal archive was accepted")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "escape")); !os.IsNotExist(err) {
|
||||
t.Fatalf("escape path exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func zipBytes(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
writer := zip.NewWriter(&buffer)
|
||||
for name, body := range files {
|
||||
entry, err := writer.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(entry, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ConfigurationRepository interface {
|
||||
GetGlobalLabels(context.Context) (map[string]string, error)
|
||||
SetGlobalLabels(context.Context, map[string]string, bool) (affected int, running int, err error)
|
||||
SaveInstanceConfiguration(context.Context, string, Preview, bool, string, string) error
|
||||
ClearContainerConfigPending(context.Context, string, string) error
|
||||
ListConfigurationRevisions(context.Context, string) ([]ConfigurationRevision, error)
|
||||
GetConfigurationRevision(context.Context, string, int) (ConfigurationRevision, error)
|
||||
}
|
||||
|
||||
type ConfigurationRevision struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Revision int `json:"revision"`
|
||||
Snapshot Preview `json:"snapshot"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ConfigurationStatus struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
ContainerConfigPending bool `json:"container_config_pending"`
|
||||
Preview Preview `json:"configuration"`
|
||||
}
|
||||
|
||||
func (s *LifecycleService) Configure(ctx context.Context, instanceID, labels string, tag ImageTag, immediate bool) (OperationResult, error) {
|
||||
repository, ok := s.repository.(ConfigurationRepository)
|
||||
if !ok {
|
||||
return OperationResult{}, errors.New("container configuration is unavailable")
|
||||
}
|
||||
return s.exclusive(instanceID, func() (OperationResult, error) {
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
parsed, err := ParseLabels(labels)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
defaultTag := current.Preview.TemplateDefaultTag
|
||||
if defaultTag == "" {
|
||||
parts := strings.Split(current.Preview.Image, ":")
|
||||
defaultTag = parts[len(parts)-1]
|
||||
}
|
||||
validated, err := ValidateImageTag(tag, defaultTag)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
preview := current.Preview
|
||||
preview.CustomLabels, preview.ImageTag = parsed, validated
|
||||
base := imageRepository(preview.Image)
|
||||
preview.Image = base + ":" + validated.Tag
|
||||
if err := repository.SaveInstanceConfiguration(ctx, instanceID, preview, !immediate, "container_configuration", ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if !immediate || current.ContainerID == "" {
|
||||
updated, _ := s.repository.GetInstance(ctx, instanceID)
|
||||
return resultFrom(updated, ""), nil
|
||||
}
|
||||
agent, ok := s.agent.(replacementAgent)
|
||||
if !ok {
|
||||
return OperationResult{}, errors.New("container replacement is unavailable")
|
||||
}
|
||||
operationID, err := operationToken()
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "restart", "update")
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
plan, err := preview.DeploymentPlan(instanceID)
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "invalid_plan", err)
|
||||
}
|
||||
state, err := agent.ReplaceInstance(ctx, plan)
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "agent_replace_failed", err)
|
||||
}
|
||||
if current.DesiredRunning {
|
||||
state, err = s.agent.StartInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "agent_start_failed", err)
|
||||
}
|
||||
}
|
||||
lifecycle, observed := stateToLifecycle(state)
|
||||
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, plan.PlanDigest, current.DesiredRunning, ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if err := repository.ClearContainerConfigPending(ctx, instanceID, plan.PlanDigest); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func imageRepository(reference string) string {
|
||||
if at := strings.Index(reference, "@"); at >= 0 {
|
||||
reference = reference[:at]
|
||||
}
|
||||
if colon := strings.LastIndex(reference, ":"); colon > strings.LastIndex(reference, "/") {
|
||||
return reference[:colon]
|
||||
}
|
||||
return reference
|
||||
}
|
||||
|
||||
var workshopIDPattern = regexp.MustCompile(`^[1-9][0-9]{0,19}$`)
|
||||
|
||||
func (s *LifecycleService) ConfigureMods(ctx context.Context, instanceID string, items []string, immediate bool, actorID string) (OperationResult, error) {
|
||||
return s.exclusive(instanceID, func() (OperationResult, error) {
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if !current.Preview.Mods.Supported {
|
||||
return OperationResult{}, errors.New("template does not support mods")
|
||||
}
|
||||
if current.Preview.Mods.Provider != "steam_workshop" {
|
||||
return OperationResult{}, errors.New("mod provider is not implemented safely")
|
||||
}
|
||||
if len(items) > 256 {
|
||||
return OperationResult{}, errors.New("too many mods")
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range items {
|
||||
if !workshopIDPattern.MatchString(id) {
|
||||
return OperationResult{}, errors.New("invalid Steam Workshop item ID")
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return OperationResult{}, errors.New("duplicate mod item ID")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
items = append([]string(nil), items...)
|
||||
preview := current.Preview
|
||||
preview.Mods.Items = items
|
||||
repository := s.repository.(ConfigurationRepository)
|
||||
if err := repository.SaveInstanceConfiguration(ctx, instanceID, preview, !immediate, "mods", actorID); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if !immediate || current.ContainerID == "" {
|
||||
updated, _ := s.repository.GetInstance(ctx, instanceID)
|
||||
return resultFrom(updated, ""), nil
|
||||
}
|
||||
return s.replaceConfigured(ctx, current, preview, "restart")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LifecycleService) RollbackConfiguration(ctx context.Context, instanceID string, revision int, immediate bool, actorID string) (OperationResult, error) {
|
||||
return s.exclusive(instanceID, func() (OperationResult, error) {
|
||||
repository := s.repository.(ConfigurationRepository)
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
old, err := repository.GetConfigurationRevision(ctx, instanceID, revision)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if old.Snapshot.Template != current.Preview.Template {
|
||||
return OperationResult{}, errors.New("revision template no longer matches pinned template")
|
||||
}
|
||||
old.Snapshot.DockerUser = current.Preview.DockerUser
|
||||
old.Snapshot.DockerUserValue = current.Preview.DockerUserValue
|
||||
if _, err := ValidateImageTag(old.Snapshot.ImageTag, old.Snapshot.TemplateDefaultTag); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if _, err := old.Snapshot.DeploymentPlan(instanceID); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if err := repository.SaveInstanceConfiguration(ctx, instanceID, old.Snapshot, !immediate, "rollback", actorID); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if !immediate || current.ContainerID == "" {
|
||||
updated, _ := s.repository.GetInstance(ctx, instanceID)
|
||||
return resultFrom(updated, ""), nil
|
||||
}
|
||||
return s.replaceConfigured(ctx, current, old.Snapshot, "restart")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LifecycleService) replaceConfigured(ctx context.Context, current StoredInstance, preview Preview, kind string) (OperationResult, error) {
|
||||
agent, ok := s.agent.(replacementAgent)
|
||||
if !ok {
|
||||
return OperationResult{}, errors.New("container replacement is unavailable")
|
||||
}
|
||||
operationID, err := operationToken()
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
current, err = s.repository.BeginOperation(ctx, operationID, current.ID, kind, "update")
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
plan, err := preview.DeploymentPlan(current.ID)
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, current.ID, "invalid_plan", err)
|
||||
}
|
||||
state, err := agent.ReplaceInstance(ctx, plan)
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, current.ID, "agent_replace_failed", err)
|
||||
}
|
||||
if current.DesiredRunning {
|
||||
state, err = s.agent.StartInstance(ctx, current.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, current.ID, "agent_start_failed", err)
|
||||
}
|
||||
lifecycle, observed := stateToLifecycle(state)
|
||||
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, plan.PlanDigest, current.DesiredRunning, ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if err := s.repository.(ConfigurationRepository).ClearContainerConfigPending(ctx, current.ID, plan.PlanDigest); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
const (
|
||||
DockerUserDoGaMa = "dogama"
|
||||
DockerUserCustom = "custom"
|
||||
DockerUserImage = "image"
|
||||
ImageTagTracked = "tracked"
|
||||
ImageTagPinned = "pinned"
|
||||
)
|
||||
|
||||
var (
|
||||
labelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*(?:/[A-Za-z0-9][A-Za-z0-9_.-]*)?$`)
|
||||
tagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
|
||||
variablePattern = regexp.MustCompile(`\{\{([^{}]+)\}\}`)
|
||||
)
|
||||
|
||||
var AllowedLabelVariables = []string{
|
||||
"game.name", "game.id", "game.icon_url", "instance.name", "instance.id", "instance.slug", "server.name",
|
||||
}
|
||||
|
||||
type DockerUser struct {
|
||||
Mode string `json:"mode"`
|
||||
UID *uint32 `json:"uid,omitempty"`
|
||||
GID *uint32 `json:"gid,omitempty"`
|
||||
}
|
||||
|
||||
type ImageTag struct {
|
||||
Mode string `json:"mode"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
type ContainerConfiguration struct {
|
||||
Labels map[string]string `json:"labels"`
|
||||
DockerUser DockerUser `json:"docker_user"`
|
||||
ImageTag ImageTag `json:"image_tag"`
|
||||
}
|
||||
|
||||
type LabelContext struct {
|
||||
GameName, GameID, GameIconURL string
|
||||
InstanceName, InstanceID, InstanceSlug string
|
||||
ServerName string
|
||||
}
|
||||
|
||||
func ParseLabels(input string) (map[string]string, error) {
|
||||
labels := make(map[string]string)
|
||||
for index, raw := range strings.Split(strings.ReplaceAll(input, "\r\n", "\n"), "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
separator := strings.IndexByte(line, '=')
|
||||
if separator < 1 {
|
||||
return nil, fmt.Errorf("label line %d must use key=value", index+1)
|
||||
}
|
||||
key := strings.TrimSpace(line[:separator])
|
||||
if !labelKeyPattern.MatchString(key) {
|
||||
return nil, fmt.Errorf("label line %d has an invalid key", index+1)
|
||||
}
|
||||
lower := strings.ToLower(key)
|
||||
if strings.HasPrefix(lower, "dogama.") || strings.HasPrefix(lower, "io.dogama.") {
|
||||
return nil, fmt.Errorf("label line %d uses a reserved DoGaMa key", index+1)
|
||||
}
|
||||
value := line[separator+1:]
|
||||
if err := ValidateLabelTemplate(value); err != nil {
|
||||
return nil, fmt.Errorf("label line %d: %w", index+1, err)
|
||||
}
|
||||
labels[key] = value
|
||||
}
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
func ValidateLabelTemplate(value string) error {
|
||||
allowed := make(map[string]bool, len(AllowedLabelVariables))
|
||||
for _, variable := range AllowedLabelVariables {
|
||||
allowed[variable] = true
|
||||
}
|
||||
for _, match := range variablePattern.FindAllStringSubmatch(value, -1) {
|
||||
if !allowed[match[1]] {
|
||||
return fmt.Errorf("unknown label variable %q", match[1])
|
||||
}
|
||||
}
|
||||
withoutKnown := variablePattern.ReplaceAllString(value, "")
|
||||
if strings.Contains(withoutKnown, "{{") || strings.Contains(withoutKnown, "}}") {
|
||||
return errors.New("invalid label variable syntax")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ResolveLabels(labels map[string]string, context LabelContext) (map[string]string, error) {
|
||||
values := map[string]string{
|
||||
"game.name": context.GameName, "game.id": context.GameID, "game.icon_url": context.GameIconURL,
|
||||
"instance.name": context.InstanceName, "instance.id": context.InstanceID, "instance.slug": context.InstanceSlug,
|
||||
"server.name": context.ServerName,
|
||||
}
|
||||
result := make(map[string]string, len(labels))
|
||||
for key, value := range labels {
|
||||
if err := ValidateLabelTemplate(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[key] = variablePattern.ReplaceAllStringFunc(value, func(token string) string {
|
||||
name := token[2 : len(token)-2]
|
||||
return values[name]
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func MergeLabels(technical, global, local map[string]string) map[string]string {
|
||||
result := make(map[string]string, len(technical)+len(global)+len(local))
|
||||
for key, value := range global {
|
||||
result[key] = value
|
||||
}
|
||||
for key, value := range local {
|
||||
result[key] = value
|
||||
}
|
||||
for key, value := range technical {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func FormatLabels(labels map[string]string) string {
|
||||
keys := make([]string, 0, len(labels))
|
||||
for key := range labels {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
lines = append(lines, key+"="+labels[key])
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func Slugify(value string) string {
|
||||
decomposed := norm.NFD.String(strings.ToLower(strings.TrimSpace(value)))
|
||||
var builder strings.Builder
|
||||
separator := false
|
||||
for _, r := range decomposed {
|
||||
if unicode.Is(unicode.Mn, r) {
|
||||
continue
|
||||
}
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
||||
if separator && builder.Len() > 0 {
|
||||
builder.WriteByte('-')
|
||||
}
|
||||
builder.WriteRune(r)
|
||||
separator = false
|
||||
} else {
|
||||
separator = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(builder.String(), "-")
|
||||
}
|
||||
|
||||
func ValidateDockerUser(user DockerUser) error {
|
||||
switch user.Mode {
|
||||
case DockerUserDoGaMa, DockerUserImage:
|
||||
if user.UID != nil || user.GID != nil {
|
||||
return errors.New("UID and GID are only valid in custom mode")
|
||||
}
|
||||
case DockerUserCustom:
|
||||
if user.UID == nil || user.GID == nil {
|
||||
return errors.New("custom Docker user requires UID and GID")
|
||||
}
|
||||
default:
|
||||
return errors.New("invalid Docker user mode")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DockerUserValue(user DockerUser, processUID, processGID uint32) (string, error) {
|
||||
if err := ValidateDockerUser(user); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch user.Mode {
|
||||
case DockerUserImage:
|
||||
return "", nil
|
||||
case DockerUserDoGaMa:
|
||||
return strconv.FormatUint(uint64(processUID), 10) + ":" + strconv.FormatUint(uint64(processGID), 10), nil
|
||||
default:
|
||||
return strconv.FormatUint(uint64(*user.UID), 10) + ":" + strconv.FormatUint(uint64(*user.GID), 10), nil
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateImageTag(value ImageTag, defaultTag string) (ImageTag, error) {
|
||||
if value.Mode == "" {
|
||||
value.Mode = ImageTagTracked
|
||||
}
|
||||
switch value.Mode {
|
||||
case ImageTagTracked:
|
||||
value.Tag = defaultTag
|
||||
case ImageTagPinned:
|
||||
if !tagPattern.MatchString(value.Tag) {
|
||||
return ImageTag{}, errors.New("invalid pinned Docker image tag")
|
||||
}
|
||||
default:
|
||||
return ImageTag{}, errors.New("invalid Docker image tag mode")
|
||||
}
|
||||
if !tagPattern.MatchString(value.Tag) {
|
||||
return ImageTag{}, errors.New("invalid Docker image tag")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package instance
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLabelsAndVariables(t *testing.T) {
|
||||
labels, err := ParseLabels("\n glance.name={{instance.name}}\nquery=a=b=c\n")
|
||||
if err != nil || labels["query"] != "a=b=c" {
|
||||
t.Fatalf("ParseLabels = %#v, %v", labels, err)
|
||||
}
|
||||
if _, err := ParseLabels("missing"); err == nil {
|
||||
t.Fatal("line without separator accepted")
|
||||
}
|
||||
if _, err := ParseLabels("=empty"); err == nil {
|
||||
t.Fatal("empty key accepted")
|
||||
}
|
||||
if _, err := ParseLabels("dogama.managed=false"); err == nil {
|
||||
t.Fatal("reserved key accepted")
|
||||
}
|
||||
if _, err := ParseLabels("io.dogama.managed=false"); err == nil {
|
||||
t.Fatal("technical key accepted")
|
||||
}
|
||||
if _, err := ParseLabels("x={{unknown}}"); err == nil {
|
||||
t.Fatal("unknown variable accepted")
|
||||
}
|
||||
resolved, err := ResolveLabels(map[string]string{"x": "{{game.name}}/{{game.id}}/{{game.icon_url}}/{{instance.name}}/{{instance.id}}/{{instance.slug}}/{{server.name}}"}, LabelContext{GameName: "Palworld", GameID: "palworld", GameIconURL: "/public/game-icons/palworld", InstanceName: "Summer", InstanceID: "id", InstanceSlug: "summer", ServerName: "Server"})
|
||||
if err != nil || resolved["x"] != "Palworld/palworld//public/game-icons/palworld/Summer/id/summer/Server" {
|
||||
t.Fatalf("ResolveLabels = %#v, %v", resolved, err)
|
||||
}
|
||||
merged := MergeLabels(map[string]string{"io.dogama.managed": "true"}, map[string]string{"x": "global"}, map[string]string{"x": "local", "io.dogama.managed": "false"})
|
||||
if merged["x"] != "local" || merged["io.dogama.managed"] != "true" {
|
||||
t.Fatalf("MergeLabels = %#v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
cases := map[string]string{"Mon Serveur": "mon-serveur", "Été 2026": "ete-2026", "PvE / PvP": "pve-pvp", "Serveur !!! Test": "serveur-test", "---Été///PvE###1---": "ete-pve-1"}
|
||||
for input, expected := range cases {
|
||||
if actual := Slugify(input); actual != expected {
|
||||
t.Errorf("Slugify(%q)=%q, want %q", input, actual, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerUserAndImageTag(t *testing.T) {
|
||||
uid, gid := uint32(1000), uint32(1001)
|
||||
if value, err := DockerUserValue(DockerUser{Mode: DockerUserDoGaMa}, 42, 43); err != nil || value != "42:43" {
|
||||
t.Fatalf("dogama user = %q, %v", value, err)
|
||||
}
|
||||
if value, err := DockerUserValue(DockerUser{Mode: DockerUserCustom, UID: &uid, GID: &gid}, 0, 0); err != nil || value != "1000:1001" {
|
||||
t.Fatalf("custom user = %q, %v", value, err)
|
||||
}
|
||||
if value, err := DockerUserValue(DockerUser{Mode: DockerUserImage}, 0, 0); err != nil || value != "" {
|
||||
t.Fatalf("image user = %q, %v", value, err)
|
||||
}
|
||||
if _, err := ValidateImageTag(ImageTag{Mode: ImageTagPinned, Tag: "bad/tag"}, "stable"); err == nil {
|
||||
t.Fatal("invalid tag accepted")
|
||||
}
|
||||
tracked, err := ValidateImageTag(ImageTag{Mode: ImageTagTracked}, "stable")
|
||||
if err != nil || tracked.Tag != "stable" {
|
||||
t.Fatalf("tracked tag = %#v, %v", tracked, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInstanceNotFound = errors.New("instance not found")
|
||||
ErrOperationConflict = errors.New("instance operation conflict")
|
||||
ErrInvalidState = errors.New("invalid instance lifecycle state")
|
||||
)
|
||||
|
||||
type StoredInstance struct {
|
||||
ID string
|
||||
Preview Preview
|
||||
LifecycleState string
|
||||
ObservedState string
|
||||
ContainerID string
|
||||
PlanDigest string
|
||||
DesiredRunning bool
|
||||
ContainerConfigPending bool
|
||||
}
|
||||
|
||||
type OperationResult struct {
|
||||
OperationID string `json:"operation_id,omitempty"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
State string `json:"state"`
|
||||
Observed string `json:"observed_state"`
|
||||
ContainerID string `json:"container_id,omitempty"`
|
||||
ContainerConfigPending bool `json:"container_config_pending"`
|
||||
AgentState agentwire.InstanceState `json:"agent_state,omitempty"`
|
||||
}
|
||||
|
||||
type LifecycleRepository interface {
|
||||
GetInstance(context.Context, string) (StoredInstance, error)
|
||||
ListLifecycleInstances(context.Context) ([]StoredInstance, error)
|
||||
BeginOperation(context.Context, string, string, string, string) (StoredInstance, error)
|
||||
FinishOperation(context.Context, string, string, string, string, string, bool, string) error
|
||||
FailOperation(context.Context, string, string, string) error
|
||||
UpdateObservation(context.Context, string, string, string, string, bool, string) error
|
||||
RecoverInterruptedOperations(context.Context) error
|
||||
}
|
||||
|
||||
type LifecycleAgent interface {
|
||||
CreateInstance(context.Context, agentwire.DeploymentPlan) (agentwire.InstanceState, error)
|
||||
InspectInstance(context.Context, string) (agentwire.InstanceState, error)
|
||||
StartInstance(context.Context, string) (agentwire.InstanceState, error)
|
||||
StopInstance(context.Context, string, int) (agentwire.InstanceState, error)
|
||||
RestartInstance(context.Context, string, int) (agentwire.InstanceState, error)
|
||||
DeleteContainer(context.Context, string) error
|
||||
GetInstanceStats(context.Context, string) (agentwire.InstanceStats, error)
|
||||
}
|
||||
|
||||
type replacementAgent interface { ReplaceInstance(context.Context, agentwire.DeploymentPlan) (agentwire.InstanceState, error) }
|
||||
|
||||
type LifecycleService struct {
|
||||
repository LifecycleRepository
|
||||
agent LifecycleAgent
|
||||
locks sync.Map
|
||||
}
|
||||
|
||||
func NewLifecycleService(repository LifecycleRepository, agent LifecycleAgent) *LifecycleService {
|
||||
return &LifecycleService{repository: repository, agent: agent}
|
||||
}
|
||||
|
||||
func (s *LifecycleService) Install(ctx context.Context, instanceID string) (OperationResult, error) {
|
||||
return s.exclusive(instanceID, func() (OperationResult, error) {
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if current.LifecycleState != "draft" && current.ContainerID != "" {
|
||||
return resultFrom(current, ""), nil
|
||||
}
|
||||
operationID, err := operationToken()
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "install", "installing")
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
plan, err := current.Preview.DeploymentPlan(instanceID)
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "invalid_plan", err)
|
||||
}
|
||||
state, err := s.agent.CreateInstance(ctx, plan)
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "agent_create_failed", err)
|
||||
}
|
||||
if err := s.repository.FinishOperation(ctx, operationID, "stopped", "stopped", state.ContainerID, plan.PlanDigest, false, ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: "stopped", Observed: "stopped", ContainerID: state.ContainerID, AgentState: state}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LifecycleService) Start(ctx context.Context, instanceID string) (OperationResult, error) {
|
||||
return s.exclusive(instanceID, func() (OperationResult, error) {
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if current.ContainerID == "" {
|
||||
return OperationResult{}, ErrInvalidState
|
||||
}
|
||||
if current.DesiredRunning && (current.LifecycleState == "online" || current.LifecycleState == "starting" || current.LifecycleState == "degraded") {
|
||||
return s.inspectAndPersist(ctx, current, "")
|
||||
}
|
||||
operationID, err := operationToken()
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "start", "starting")
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
var state agentwire.InstanceState
|
||||
if current.ContainerConfigPending {
|
||||
replacement, ok := s.agent.(replacementAgent)
|
||||
if !ok { return s.fail(ctx, operationID, instanceID, "container_replace_unavailable", errors.New("container replacement is unavailable")) }
|
||||
plan, planErr := current.Preview.DeploymentPlan(instanceID)
|
||||
if planErr != nil { return s.fail(ctx, operationID, instanceID, "invalid_plan", planErr) }
|
||||
state, err = replacement.ReplaceInstance(ctx, plan)
|
||||
if err == nil { err = s.repository.(ConfigurationRepository).ClearContainerConfigPending(ctx, instanceID, plan.PlanDigest) }
|
||||
}
|
||||
if err == nil { state, err = s.agent.StartInstance(ctx, instanceID) }
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "agent_start_failed", err)
|
||||
}
|
||||
lifecycle, observed := stateToLifecycle(state)
|
||||
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, current.PlanDigest, true, ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LifecycleService) Stop(ctx context.Context, instanceID string) (OperationResult, error) {
|
||||
return s.exclusive(instanceID, func() (OperationResult, error) {
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if current.ContainerID == "" {
|
||||
return OperationResult{}, ErrInvalidState
|
||||
}
|
||||
if !current.DesiredRunning && current.LifecycleState == "stopped" {
|
||||
return resultFrom(current, ""), nil
|
||||
}
|
||||
operationID, err := operationToken()
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "stop", "stopping")
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
state, err := s.agent.StopInstance(ctx, instanceID, current.Preview.StopTimeoutSeconds)
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "agent_stop_failed", err)
|
||||
}
|
||||
if err := s.repository.FinishOperation(ctx, operationID, "stopped", "stopped", state.ContainerID, current.PlanDigest, false, ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: "stopped", Observed: "stopped", ContainerID: state.ContainerID, AgentState: state}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LifecycleService) Restart(ctx context.Context, instanceID string) (OperationResult, error) {
|
||||
return s.exclusive(instanceID, func() (OperationResult, error) {
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if current.ContainerID == "" {
|
||||
return OperationResult{}, ErrInvalidState
|
||||
}
|
||||
operationID, err := operationToken()
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "restart", "starting")
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
state, err := s.agent.RestartInstance(ctx, instanceID, current.Preview.StopTimeoutSeconds)
|
||||
if err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "agent_restart_failed", err)
|
||||
}
|
||||
lifecycle, observed := stateToLifecycle(state)
|
||||
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, current.PlanDigest, true, ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LifecycleService) DeleteContainer(ctx context.Context, instanceID string) (OperationResult, error) {
|
||||
return s.exclusive(instanceID, func() (OperationResult, error) {
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if current.ContainerID == "" {
|
||||
return resultFrom(current, ""), nil
|
||||
}
|
||||
if current.DesiredRunning || (current.LifecycleState != "stopped" && current.LifecycleState != "deleting" && current.LifecycleState != "intervention_required") {
|
||||
return OperationResult{}, ErrInvalidState
|
||||
}
|
||||
operationID, err := operationToken()
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
_, err = s.repository.BeginOperation(ctx, operationID, instanceID, "delete_container", "deleting")
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if err := s.agent.DeleteContainer(ctx, instanceID); err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "agent_delete_failed", err)
|
||||
}
|
||||
if err := s.repository.FinishOperation(ctx, operationID, "unknown", "missing", "", current.PlanDigest, false, ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: "unknown", Observed: "missing"}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LifecycleService) Inspect(ctx context.Context, instanceID string) (OperationResult, error) {
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if current.ContainerID == "" {
|
||||
return resultFrom(current, ""), nil
|
||||
}
|
||||
return s.inspectAndPersist(ctx, current, "")
|
||||
}
|
||||
|
||||
func (s *LifecycleService) Stats(ctx context.Context, instanceID string) (agentwire.InstanceStats, error) {
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return agentwire.InstanceStats{}, err
|
||||
}
|
||||
if current.ContainerID == "" {
|
||||
return agentwire.InstanceStats{}, ErrInvalidState
|
||||
}
|
||||
return s.agent.GetInstanceStats(ctx, instanceID)
|
||||
}
|
||||
|
||||
func (s *LifecycleService) ReconcileAll(ctx context.Context) error {
|
||||
instances, err := s.repository.ListLifecycleInstances(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, current := range instances {
|
||||
if current.ContainerID == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := s.inspectAndPersist(ctx, current, ""); err != nil {
|
||||
operationID, tokenErr := operationToken()
|
||||
if tokenErr != nil {
|
||||
return tokenErr
|
||||
}
|
||||
if _, beginErr := s.repository.BeginOperation(ctx, operationID, current.ID, "reconcile", "unknown"); beginErr == nil {
|
||||
_ = s.repository.FailOperation(ctx, operationID, "unknown", "agent_reconcile_failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LifecycleService) RecoverInterruptedOperations(ctx context.Context) error {
|
||||
return s.repository.RecoverInterruptedOperations(ctx)
|
||||
}
|
||||
|
||||
func (s *LifecycleService) inspectAndPersist(ctx context.Context, current StoredInstance, operationID string) (OperationResult, error) {
|
||||
state, err := s.agent.InspectInstance(ctx, current.ID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
lifecycle, observed := stateToLifecycle(state)
|
||||
if current.LifecycleState == "intervention_required" {
|
||||
lifecycle = "intervention_required"
|
||||
}
|
||||
if !current.DesiredRunning && !state.Running {
|
||||
lifecycle = "stopped"
|
||||
}
|
||||
if operationID != "" {
|
||||
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, current.PlanDigest, current.DesiredRunning, ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
} else if err := s.repository.UpdateObservation(ctx, current.ID, lifecycle, observed, state.ContainerID, current.DesiredRunning, ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
|
||||
}
|
||||
|
||||
func (s *LifecycleService) fail(ctx context.Context, operationID, instanceID, code string, cause error) (OperationResult, error) {
|
||||
if err := s.repository.FailOperation(ctx, operationID, "error", code); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: "error", Observed: "unknown"}, fmt.Errorf("%s: %w", code, cause)
|
||||
}
|
||||
|
||||
func (s *LifecycleService) exclusive(instanceID string, action func() (OperationResult, error)) (OperationResult, error) {
|
||||
lockValue, _ := s.locks.LoadOrStore(instanceID, &sync.Mutex{})
|
||||
lock := lockValue.(*sync.Mutex)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
return action()
|
||||
}
|
||||
|
||||
func stateToLifecycle(state agentwire.InstanceState) (string, string) {
|
||||
if !state.Running {
|
||||
return "stopped", "stopped"
|
||||
}
|
||||
if state.Ready {
|
||||
return "online", "ready"
|
||||
}
|
||||
if state.Health == "unhealthy" || state.Health == "none" {
|
||||
return "degraded", "degraded"
|
||||
}
|
||||
return "starting", "running"
|
||||
}
|
||||
|
||||
func resultFrom(current StoredInstance, operationID string) OperationResult {
|
||||
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: current.LifecycleState, Observed: current.ObservedState, ContainerID: current.ContainerID, ContainerConfigPending: current.ContainerConfigPending}
|
||||
}
|
||||
|
||||
func operationToken() (string, error) {
|
||||
buffer := make([]byte, 24)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", fmt.Errorf("generate operation ID: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buffer), nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
type lifecycleAgent struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
created int
|
||||
}
|
||||
|
||||
func (a *lifecycleAgent) CreateInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.created++
|
||||
return agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: "container-1", PlanDigest: plan.PlanDigest, Health: "stopped"}, nil
|
||||
}
|
||||
func (a *lifecycleAgent) InspectInstance(_ context.Context, id string) (agentwire.InstanceState, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return agentwire.InstanceState{InstanceID: id, ContainerID: "container-1", Running: a.running, Ready: a.running, Health: map[bool]string{true: "healthy", false: "stopped"}[a.running]}, nil
|
||||
}
|
||||
func (a *lifecycleAgent) StartInstance(ctx context.Context, id string) (agentwire.InstanceState, error) {
|
||||
a.mu.Lock()
|
||||
a.running = true
|
||||
a.mu.Unlock()
|
||||
return a.InspectInstance(ctx, id)
|
||||
}
|
||||
func (a *lifecycleAgent) StopInstance(ctx context.Context, id string, _ int) (agentwire.InstanceState, error) {
|
||||
a.mu.Lock()
|
||||
a.running = false
|
||||
a.mu.Unlock()
|
||||
return a.InspectInstance(ctx, id)
|
||||
}
|
||||
func (a *lifecycleAgent) RestartInstance(ctx context.Context, id string, _ int) (agentwire.InstanceState, error) {
|
||||
return a.StartInstance(ctx, id)
|
||||
}
|
||||
func (a *lifecycleAgent) DeleteContainer(context.Context, string) error { return nil }
|
||||
func (a *lifecycleAgent) ReplaceInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
|
||||
return agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: "container-2", PlanDigest: plan.PlanDigest, Health: "stopped"}, nil
|
||||
}
|
||||
func (a *lifecycleAgent) GetInstanceStats(_ context.Context, id string) (agentwire.InstanceStats, error) {
|
||||
return agentwire.InstanceStats{InstanceID: id, MemoryBytes: 42}, nil
|
||||
}
|
||||
|
||||
func TestUpdatePreviewRequiresDigestAndWarnsAboutMods(t *testing.T) {
|
||||
current := instance.StoredInstance{Preview: instance.Preview{
|
||||
Image: "registry.example/game:old",
|
||||
TemplateDefaultTag: "old",
|
||||
Mods: instance.ModsConfiguration{Items: []string{"123"}},
|
||||
UpdatePolicy: instance.UpdatePolicy{BackupBeforeUpdate: true, RollbackOnFailure: true},
|
||||
}}
|
||||
if _, err := instance.PreviewUpdate(current, instance.UpdateRequest{CandidateTag: "new", CandidateDigest: "latest"}); err == nil {
|
||||
t.Fatal("mutable tag without digest was accepted")
|
||||
}
|
||||
value, err := instance.PreviewUpdate(current, instance.UpdateRequest{CandidateTag: "new", CandidateDigest: "sha256:" + strings.Repeat("a", 64)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !value.BackupRequired || !value.ModWarning || !value.RollbackOnFailure || !strings.Contains(value.CandidateReference, "@sha256:") {
|
||||
t.Fatalf("preview = %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleInstallStartStopAndSafeContainerDeletion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository := sqlite.NewRepository(db)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Sync(ctx, snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{
|
||||
DisplayName: "Family Palworld", Slug: "family-palworld", HostPorts: map[string]int{"game": 38211},
|
||||
MountPaths: map[string]string{"saved": filepath.Join(t.TempDir(), "saved")}, DataOrigin: "new", BackupRetention: 7,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.CreateDraft(ctx, instance.Draft{ID: "abcdefghijklmnopqrstuvwx", Preview: preview}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agent := &lifecycleAgent{}
|
||||
service := instance.NewLifecycleService(repository, agent)
|
||||
installed, err := service.Install(ctx, "abcdefghijklmnopqrstuvwx")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed.State != "stopped" || installed.ContainerID == "" {
|
||||
t.Fatalf("installed = %#v", installed)
|
||||
}
|
||||
if _, err := service.Install(ctx, "abcdefghijklmnopqrstuvwx"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if agent.created != 1 {
|
||||
t.Fatalf("idempotent install created %d containers", agent.created)
|
||||
}
|
||||
started, err := service.Start(ctx, "abcdefghijklmnopqrstuvwx")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if started.State != "online" || started.Observed != "ready" {
|
||||
t.Fatalf("started = %#v", started)
|
||||
}
|
||||
stopped, err := service.Stop(ctx, "abcdefghijklmnopqrstuvwx")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stopped.State != "stopped" {
|
||||
t.Fatalf("stopped = %#v", stopped)
|
||||
}
|
||||
deleted, err := service.DeleteContainer(ctx, "abcdefghijklmnopqrstuvwx")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deleted.State != "unknown" || deleted.Observed != "missing" {
|
||||
t.Fatalf("deleted = %#v", deleted)
|
||||
}
|
||||
var mounts string
|
||||
if err := db.QueryRow("SELECT preview_json FROM instances WHERE id=?", "abcdefghijklmnopqrstuvwx").Scan(&mounts); err != nil || mounts == "" {
|
||||
t.Fatalf("instance intent/player paths were removed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// Package instance builds canonical deployment previews and draft registry entries.
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
)
|
||||
|
||||
var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
type PreviewRequest struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Slug string `json:"slug"`
|
||||
HostPorts map[string]int `json:"host_ports"`
|
||||
MountPaths map[string]string `json:"mount_paths"`
|
||||
Resources catalog.Resources `json:"resources"`
|
||||
DataOrigin string `json:"data_origin"`
|
||||
BackupRetention int `json:"backup_retention"`
|
||||
ImportID string `json:"import_id,omitempty"`
|
||||
CustomLabels string `json:"custom_labels,omitempty"`
|
||||
DockerUser DockerUser `json:"docker_user"`
|
||||
ImageTag ImageTag `json:"image_tag"`
|
||||
PublicBaseURL string `json:"-"`
|
||||
}
|
||||
|
||||
type Preview struct {
|
||||
Template TemplateReference `json:"template"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Slug string `json:"slug"`
|
||||
Image string `json:"image"`
|
||||
Entrypoint []string `json:"entrypoint,omitempty"`
|
||||
Arguments []string `json:"arguments,omitempty"`
|
||||
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
|
||||
StartupTimeoutSeconds int `json:"startup_timeout_seconds"`
|
||||
Ports []PortBinding `json:"ports"`
|
||||
Mounts []MountBinding `json:"mounts"`
|
||||
Resources catalog.Resources `json:"resources"`
|
||||
Settings []SettingPreview `json:"settings"`
|
||||
DataOrigin string `json:"data_origin"`
|
||||
Backup BackupPreview `json:"backup"`
|
||||
Import ImportPreview `json:"import,omitempty"`
|
||||
CanonicalJSON string `json:"canonical_json"`
|
||||
PlanDigest string `json:"plan_digest"`
|
||||
CustomLabels map[string]string `json:"custom_labels"`
|
||||
GlobalLabels map[string]string `json:"global_labels"`
|
||||
DockerUser DockerUser `json:"docker_user"`
|
||||
DockerUserValue string `json:"docker_user_value,omitempty"`
|
||||
ImageTag ImageTag `json:"image_tag"`
|
||||
TemplateDefaultTag string `json:"template_default_tag"`
|
||||
Game GameReference `json:"game"`
|
||||
Mods ModsConfiguration `json:"mods"`
|
||||
UpdatePolicy UpdatePolicy `json:"update_policy"`
|
||||
}
|
||||
|
||||
type ModsConfiguration struct {
|
||||
Supported bool `json:"supported"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
DestinationMount string `json:"destination_mount,omitempty"`
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
Items []string `json:"items"`
|
||||
}
|
||||
|
||||
type UpdatePolicy struct {
|
||||
BackupBeforeUpdate bool `json:"backup_before_update"`
|
||||
RollbackOnFailure bool `json:"rollback_on_failure"`
|
||||
Automatic bool `json:"automatic"`
|
||||
HealthTimeoutSeconds int `json:"health_timeout_seconds"`
|
||||
}
|
||||
|
||||
type GameReference struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IconURL string `json:"icon_url"`
|
||||
}
|
||||
|
||||
type TemplateReference struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type PortBinding struct {
|
||||
ID string `json:"id"`
|
||||
Protocol string `json:"protocol"`
|
||||
Purpose string `json:"purpose"`
|
||||
ContainerPort int `json:"container_port"`
|
||||
HostPort int `json:"host_port,omitempty"`
|
||||
Publish bool `json:"publish"`
|
||||
}
|
||||
|
||||
type MountBinding struct {
|
||||
ID string `json:"id"`
|
||||
HostPath string `json:"host_path"`
|
||||
ContainerPath string `json:"container_path"`
|
||||
Category string `json:"category"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
type SettingPreview struct {
|
||||
ID string `json:"id"`
|
||||
Secret bool `json:"secret"`
|
||||
Configured bool `json:"configured"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
type BackupPreview struct {
|
||||
Strategy string `json:"strategy"`
|
||||
SourceMounts []string `json:"source_mounts"`
|
||||
RetentionCount int `json:"retention_count"`
|
||||
}
|
||||
|
||||
type ImportPreview struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
DestinationMount string `json:"destination_mount,omitempty"`
|
||||
DestinationRelativePath string `json:"destination_relative_path,omitempty"`
|
||||
}
|
||||
|
||||
type Draft struct {
|
||||
ID string
|
||||
Preview Preview
|
||||
}
|
||||
|
||||
// Repository stores draft instances without performing external actions.
|
||||
type Repository interface {
|
||||
CreateDraft(context.Context, Draft) error
|
||||
}
|
||||
|
||||
// BuildPreview validates administrator choices and produces deterministic JSON.
|
||||
func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, error) {
|
||||
request.DisplayName = strings.TrimSpace(request.DisplayName)
|
||||
request.Slug = Slugify(request.DisplayName)
|
||||
if request.DisplayName == "" || len(request.DisplayName) > 100 || !slugPattern.MatchString(request.Slug) {
|
||||
return Preview{}, errors.New("invalid display name or slug")
|
||||
}
|
||||
customLabels, err := ParseLabels(request.CustomLabels)
|
||||
if err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
if request.DockerUser.Mode == "" {
|
||||
request.DockerUser.Mode = DockerUserDoGaMa
|
||||
}
|
||||
if err := ValidateDockerUser(request.DockerUser); err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
tag, err := ValidateImageTag(request.ImageTag, snapshot.Template.Container.Tag)
|
||||
if err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
uid, gid, err := currentUIDGID()
|
||||
if err != nil && request.DockerUser.Mode == DockerUserDoGaMa {
|
||||
return Preview{}, err
|
||||
}
|
||||
userValue, err := DockerUserValue(request.DockerUser, uid, gid)
|
||||
if err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
if request.DataOrigin != "new" && request.DataOrigin != "import" {
|
||||
return Preview{}, errors.New("data origin must be new or import")
|
||||
}
|
||||
if request.DataOrigin == "import" && !snapshot.Template.Imports.Supported {
|
||||
return Preview{}, errors.New("template does not support imports")
|
||||
}
|
||||
if request.DataOrigin == "import" && request.ImportID == "" {
|
||||
return Preview{}, errors.New("validated import is required")
|
||||
}
|
||||
if request.DataOrigin == "new" && request.ImportID != "" {
|
||||
return Preview{}, errors.New("import is only valid for imported data")
|
||||
}
|
||||
resources := request.Resources
|
||||
if resources.CPUCores == 0 {
|
||||
resources = snapshot.Template.Requirements.Recommended
|
||||
}
|
||||
minimum := snapshot.Template.Requirements.Minimum
|
||||
if resources.CPUCores < minimum.CPUCores || resources.MemoryMB < minimum.MemoryMB || resources.StorageGB < minimum.StorageGB {
|
||||
return Preview{}, errors.New("resources are below template minimum")
|
||||
}
|
||||
ports := make([]PortBinding, 0, len(snapshot.Template.Container.Ports))
|
||||
usedPorts := make(map[string]struct{})
|
||||
knownPorts := make(map[string]struct{}, len(snapshot.Template.Container.Ports))
|
||||
for _, port := range snapshot.Template.Container.Ports {
|
||||
knownPorts[port.ID] = struct{}{}
|
||||
hostPort := request.HostPorts[port.ID]
|
||||
if port.Publish && (hostPort < 1 || hostPort > 65535) {
|
||||
return Preview{}, fmt.Errorf("published port %s requires a valid host port", port.ID)
|
||||
}
|
||||
if !port.Publish && hostPort != 0 {
|
||||
return Preview{}, fmt.Errorf("private port %s cannot be published", port.ID)
|
||||
}
|
||||
key := fmt.Sprintf("%s/%d", port.Protocol, hostPort)
|
||||
if port.Publish {
|
||||
if _, exists := usedPorts[key]; exists {
|
||||
return Preview{}, errors.New("host port conflict in preview")
|
||||
}
|
||||
usedPorts[key] = struct{}{}
|
||||
}
|
||||
ports = append(ports, PortBinding{ID: port.ID, Protocol: port.Protocol, Purpose: port.Purpose, ContainerPort: port.ContainerPort, HostPort: hostPort, Publish: port.Publish})
|
||||
}
|
||||
for id := range request.HostPorts {
|
||||
if _, exists := knownPorts[id]; !exists {
|
||||
return Preview{}, fmt.Errorf("unknown port %s", id)
|
||||
}
|
||||
}
|
||||
sort.Slice(ports, func(i, j int) bool { return ports[i].ID < ports[j].ID })
|
||||
mounts := make([]MountBinding, 0, len(snapshot.Template.Storage.Mounts))
|
||||
knownMounts := make(map[string]struct{}, len(snapshot.Template.Storage.Mounts))
|
||||
for _, mount := range snapshot.Template.Storage.Mounts {
|
||||
knownMounts[mount.ID] = struct{}{}
|
||||
hostPath := request.MountPaths[mount.ID]
|
||||
if hostPath == "" || !filepath.IsAbs(hostPath) || filepath.Clean(hostPath) != hostPath {
|
||||
return Preview{}, fmt.Errorf("mount %s requires a canonical absolute path", mount.ID)
|
||||
}
|
||||
mounts = append(mounts, MountBinding{ID: mount.ID, HostPath: hostPath, ContainerPath: mount.ContainerPath, Category: mount.Category, ReadOnly: mount.ReadOnly})
|
||||
}
|
||||
for id := range request.MountPaths {
|
||||
if _, exists := knownMounts[id]; !exists {
|
||||
return Preview{}, fmt.Errorf("unknown mount %s", id)
|
||||
}
|
||||
}
|
||||
sort.Slice(mounts, func(i, j int) bool { return mounts[i].ID < mounts[j].ID })
|
||||
settings := make([]SettingPreview, 0, len(snapshot.Template.Configuration.Fields))
|
||||
for _, field := range snapshot.Template.Configuration.Fields {
|
||||
secret := field.Type == "secret"
|
||||
setting := SettingPreview{ID: field.ID, Secret: secret, Configured: !secret && field.Default != nil}
|
||||
if !secret {
|
||||
setting.Default = field.Default
|
||||
}
|
||||
settings = append(settings, setting)
|
||||
}
|
||||
sort.Slice(settings, func(i, j int) bool { return settings[i].ID < settings[j].ID })
|
||||
preview := Preview{
|
||||
Template: TemplateReference{ID: snapshot.Template.ID, Version: snapshot.Template.Version, Digest: snapshot.Digest},
|
||||
DisplayName: request.DisplayName,
|
||||
Slug: request.Slug,
|
||||
Image: snapshot.Template.Container.Image + ":" + tag.Tag,
|
||||
Entrypoint: append([]string(nil), snapshot.Template.Container.Entrypoint...),
|
||||
Arguments: append([]string(nil), snapshot.Template.Container.Arguments...),
|
||||
StopTimeoutSeconds: snapshot.Template.Container.StopTimeoutSeconds,
|
||||
StartupTimeoutSeconds: snapshot.Template.Healthcheck.StartupTimeoutSeconds,
|
||||
Ports: ports, Mounts: mounts, Resources: resources, Settings: settings,
|
||||
DataOrigin: request.DataOrigin,
|
||||
Backup: BackupPreview{Strategy: snapshot.Template.Backup.Strategy, SourceMounts: append([]string(nil), snapshot.Template.Backup.SourceMounts...), RetentionCount: request.BackupRetention},
|
||||
Import: ImportPreview{ID: request.ImportID, DestinationMount: snapshot.Template.Imports.DestinationMount, DestinationRelativePath: snapshot.Template.Imports.DestinationRelativePath},
|
||||
CustomLabels: customLabels, DockerUser: request.DockerUser, DockerUserValue: userValue, ImageTag: tag, TemplateDefaultTag: snapshot.Template.Container.Tag,
|
||||
Game: GameReference{ID: snapshot.Template.Game.ID, Name: snapshot.Template.Game.Name, IconURL: strings.TrimRight(request.PublicBaseURL, "/") + "/public/game-icons/" + snapshot.Template.Game.ID},
|
||||
Mods: ModsConfiguration{Supported: snapshot.Template.Mods.Supported, Provider: snapshot.Template.Mods.Provider, DestinationMount: snapshot.Template.Mods.DestinationMount, RestartRequired: snapshot.Template.Mods.RestartRequired, Items: []string{}},
|
||||
UpdatePolicy: UpdatePolicy{BackupBeforeUpdate: snapshot.Template.Updates.BackupBeforeUpdate, RollbackOnFailure: snapshot.Template.Updates.RollbackOnFailure, Automatic: false, HealthTimeoutSeconds: snapshot.Template.Updates.HealthTimeoutSeconds},
|
||||
}
|
||||
if preview.Backup.RetentionCount < 1 || preview.Backup.RetentionCount > 1000 {
|
||||
return Preview{}, errors.New("backup retention must be between 1 and 1000")
|
||||
}
|
||||
sort.Strings(preview.Backup.SourceMounts)
|
||||
canonicalValue := preview
|
||||
canonicalValue.CanonicalJSON = ""
|
||||
canonicalValue.PlanDigest = ""
|
||||
canonical, err := json.Marshal(canonicalValue)
|
||||
if err != nil {
|
||||
return Preview{}, fmt.Errorf("encode deployment preview: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(canonical)
|
||||
preview.CanonicalJSON = string(canonical)
|
||||
preview.PlanDigest = hex.EncodeToString(digest[:])
|
||||
return preview, nil
|
||||
}
|
||||
|
||||
// DeploymentPlan converts a persisted preview into the only container plan
|
||||
// accepted by the restricted agent. The digest binds every privileged field.
|
||||
func (p Preview) DeploymentPlan(instanceID string) (agentwire.DeploymentPlan, error) {
|
||||
if p.DockerUser.Mode == "" {
|
||||
p.DockerUser.Mode = DockerUserDoGaMa
|
||||
}
|
||||
if p.DockerUserValue == "" && p.DockerUser.Mode == DockerUserDoGaMa {
|
||||
uid, gid, userErr := currentUIDGID()
|
||||
if userErr != nil {
|
||||
return agentwire.DeploymentPlan{}, userErr
|
||||
}
|
||||
p.DockerUserValue, userErr = DockerUserValue(p.DockerUser, uid, gid)
|
||||
if userErr != nil {
|
||||
return agentwire.DeploymentPlan{}, userErr
|
||||
}
|
||||
}
|
||||
context := LabelContext{GameName: p.Game.Name, GameID: p.Game.ID, GameIconURL: p.Game.IconURL, InstanceName: p.DisplayName, InstanceID: instanceID, InstanceSlug: p.Slug, ServerName: p.DisplayName}
|
||||
global, err := ResolveLabels(p.GlobalLabels, context)
|
||||
if err != nil {
|
||||
return agentwire.DeploymentPlan{}, err
|
||||
}
|
||||
local, err := ResolveLabels(p.CustomLabels, context)
|
||||
if err != nil {
|
||||
return agentwire.DeploymentPlan{}, err
|
||||
}
|
||||
plan := agentwire.DeploymentPlan{
|
||||
SchemaVersion: agentwire.DeploymentPlanVersion,
|
||||
InstanceID: instanceID,
|
||||
TemplateID: p.Template.ID, TemplateVersion: p.Template.Version, TemplateDigest: p.Template.Digest,
|
||||
Image: p.Image, Entrypoint: append([]string(nil), p.Entrypoint...), Arguments: append([]string(nil), p.Arguments...),
|
||||
Labels: MergeLabels(nil, global, local), User: p.DockerUserValue,
|
||||
Resources: agentwire.PlanResource{CPUCores: p.Resources.CPUCores, MemoryMB: p.Resources.MemoryMB, StorageGB: p.Resources.StorageGB},
|
||||
StopTimeoutSeconds: p.StopTimeoutSeconds,
|
||||
}
|
||||
for _, port := range p.Ports {
|
||||
plan.Ports = append(plan.Ports, agentwire.PlanPort{ID: port.ID, Protocol: port.Protocol, ContainerPort: port.ContainerPort, HostPort: port.HostPort, Publish: port.Publish})
|
||||
}
|
||||
for _, mount := range p.Mounts {
|
||||
plan.Mounts = append(plan.Mounts, agentwire.PlanMount{ID: mount.ID, HostPath: mount.HostPath, ContainerPath: mount.ContainerPath, ReadOnly: mount.ReadOnly})
|
||||
}
|
||||
digest, err := plan.CanonicalDigest()
|
||||
if err != nil {
|
||||
return agentwire.DeploymentPlan{}, err
|
||||
}
|
||||
plan.PlanDigest = digest
|
||||
if err := plan.Validate(); err != nil {
|
||||
return agentwire.DeploymentPlan{}, err
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
)
|
||||
|
||||
func TestBuildPreviewIsDeterministicAndRedactsSecrets(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := instance.PreviewRequest{
|
||||
DisplayName: "Family Palworld",
|
||||
Slug: "family-palworld",
|
||||
HostPorts: map[string]int{"game": 8211},
|
||||
MountPaths: map[string]string{"saved": filepath.Join(string(filepath.Separator), "srv", "game-servers", "family-palworld", "saved")},
|
||||
DataOrigin: "new",
|
||||
BackupRetention: 7,
|
||||
}
|
||||
first, err := instance.BuildPreview(snapshots[0], request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := instance.BuildPreview(snapshots[0], request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.PlanDigest != second.PlanDigest || first.CanonicalJSON != second.CanonicalJSON {
|
||||
t.Fatal("preview is not deterministic")
|
||||
}
|
||||
for _, setting := range first.Settings {
|
||||
if setting.Secret && setting.Default != nil {
|
||||
t.Fatalf("secret default leaked: %#v", setting)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewRejectsPrivatePortPublicationAndLowResources(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := instance.PreviewRequest{
|
||||
DisplayName: "Family Palworld", Slug: "family-palworld",
|
||||
HostPorts: map[string]int{"game": 8211, "rest_api": 8212},
|
||||
MountPaths: map[string]string{"saved": "/srv/game-servers/family-palworld/saved"},
|
||||
DataOrigin: "new", BackupRetention: 7,
|
||||
}
|
||||
if _, err := instance.BuildPreview(snapshots[0], base); err == nil {
|
||||
t.Fatal("private integration port unexpectedly published")
|
||||
}
|
||||
base.HostPorts = map[string]int{"game": 8211}
|
||||
base.Resources = catalog.Resources{CPUCores: 1, MemoryMB: 128, StorageGB: 1}
|
||||
if _, err := instance.BuildPreview(snapshots[0], base); err == nil {
|
||||
t.Fatal("below-minimum resources unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewRejectsUnknownPortAndMountIDs(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := instance.PreviewRequest{
|
||||
DisplayName: "Family Palworld", Slug: "family-palworld",
|
||||
HostPorts: map[string]int{"game": 8211, "unknown": 8212},
|
||||
MountPaths: map[string]string{"saved": "/srv/game-servers/family-palworld/saved"},
|
||||
DataOrigin: "new", BackupRetention: 7,
|
||||
}
|
||||
if _, err := instance.BuildPreview(snapshots[0], base); err == nil {
|
||||
t.Fatal("unknown port ID unexpectedly accepted")
|
||||
}
|
||||
base.HostPorts = map[string]int{"game": 8211}
|
||||
base.MountPaths["unknown"] = "/srv/game-servers/family-palworld/unknown"
|
||||
if _, err := instance.BuildPreview(snapshots[0], base); err == nil {
|
||||
t.Fatal("unknown mount ID unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build unix
|
||||
|
||||
package instance
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
func currentUIDGID() (uint32, uint32, error) {
|
||||
return uint32(unix.Getuid()), uint32(unix.Getgid()), nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build windows
|
||||
|
||||
package instance
|
||||
|
||||
// Windows is a development/test host only; Linux deployment resolves the real process identity.
|
||||
func currentUIDGID() (uint32, uint32, error) {
|
||||
return 1000, 1000, nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
)
|
||||
|
||||
var imageDigestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
|
||||
|
||||
type UpdateRequest struct {
|
||||
CandidateTag string `json:"candidate_tag"`
|
||||
CandidateDigest string `json:"candidate_digest"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
}
|
||||
|
||||
type UpdatePreview struct {
|
||||
CurrentReference string `json:"current_reference"`
|
||||
CandidateReference string `json:"candidate_reference"`
|
||||
BackupRequired bool `json:"backup_required"`
|
||||
ModWarning bool `json:"mod_warning"`
|
||||
RollbackOnFailure bool `json:"rollback_on_failure"`
|
||||
}
|
||||
|
||||
func PreviewUpdate(current StoredInstance, request UpdateRequest) (UpdatePreview, error) {
|
||||
tag, err := ValidateImageTag(ImageTag{Mode: ImageTagPinned, Tag: request.CandidateTag}, current.Preview.TemplateDefaultTag)
|
||||
if err != nil {
|
||||
return UpdatePreview{}, err
|
||||
}
|
||||
if !imageDigestPattern.MatchString(request.CandidateDigest) {
|
||||
return UpdatePreview{}, errors.New("candidate image digest must be sha256")
|
||||
}
|
||||
base := imageRepository(current.Preview.Image)
|
||||
return UpdatePreview{CurrentReference: current.Preview.Image, CandidateReference: base + ":" + tag.Tag + "@" + request.CandidateDigest, BackupRequired: current.Preview.UpdatePolicy.BackupBeforeUpdate, ModWarning: len(current.Preview.Mods.Items) != 0, RollbackOnFailure: current.Preview.UpdatePolicy.RollbackOnFailure}, nil
|
||||
}
|
||||
|
||||
func (s *LifecycleService) Update(ctx context.Context, instanceID string, request UpdateRequest, actorID string) (OperationResult, error) {
|
||||
return s.exclusive(instanceID, func() (OperationResult, error) {
|
||||
current, err := s.repository.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
candidate, err := PreviewUpdate(current, request)
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if !request.Confirmed {
|
||||
return OperationResult{}, errors.New("update confirmation is required")
|
||||
}
|
||||
if current.ContainerID == "" {
|
||||
return OperationResult{}, ErrInvalidState
|
||||
}
|
||||
replacement, ok := s.agent.(replacementAgent)
|
||||
if !ok {
|
||||
return OperationResult{}, errors.New("container replacement is unavailable")
|
||||
}
|
||||
previous := current.Preview
|
||||
next := previous
|
||||
next.Image = candidate.CandidateReference
|
||||
next.ImageTag = ImageTag{Mode: ImageTagPinned, Tag: request.CandidateTag}
|
||||
repository := s.repository.(ConfigurationRepository)
|
||||
operationID, err := operationToken()
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
if err := repository.SaveInstanceConfiguration(ctx, instanceID, next, false, "update", actorID); err != nil {
|
||||
return s.fail(ctx, operationID, instanceID, "update_configuration_failed", err)
|
||||
}
|
||||
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "restart", "update")
|
||||
if err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
plan, err := next.DeploymentPlan(instanceID)
|
||||
if err == nil {
|
||||
_, err = replacement.ReplaceInstance(ctx, plan)
|
||||
}
|
||||
var stateErr error
|
||||
if err == nil && current.DesiredRunning {
|
||||
_, stateErr = s.agent.StartInstance(ctx, instanceID)
|
||||
err = stateErr
|
||||
}
|
||||
rollback := func() {
|
||||
if next.UpdatePolicy.RollbackOnFailure {
|
||||
_ = repository.SaveInstanceConfiguration(ctx, instanceID, previous, false, "update_rollback", actorID)
|
||||
if oldPlan, planErr := previous.DeploymentPlan(instanceID); planErr == nil {
|
||||
if _, rollbackErr := replacement.ReplaceInstance(ctx, oldPlan); rollbackErr == nil && current.DesiredRunning {
|
||||
_, _ = s.agent.StartInstance(ctx, instanceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
rollback()
|
||||
return s.fail(ctx, operationID, instanceID, "update_failed", err)
|
||||
}
|
||||
state, err := s.waitForUpdateReadiness(ctx, instanceID, current.DesiredRunning, next.UpdatePolicy.HealthTimeoutSeconds)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return s.fail(ctx, operationID, instanceID, "update_health_failed", err)
|
||||
}
|
||||
lifecycle, observed := stateToLifecycle(state)
|
||||
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, plan.PlanDigest, current.DesiredRunning, ""); err != nil {
|
||||
return OperationResult{}, err
|
||||
}
|
||||
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LifecycleService) waitForUpdateReadiness(ctx context.Context, instanceID string, shouldRun bool, timeoutSeconds int) (agentwire.InstanceState, error) {
|
||||
if timeoutSeconds < 10 {
|
||||
timeoutSeconds = 10
|
||||
}
|
||||
deadline, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
|
||||
defer cancel()
|
||||
for {
|
||||
state, err := s.agent.InspectInstance(deadline, instanceID)
|
||||
if err != nil {
|
||||
return agentwire.InstanceState{}, err
|
||||
}
|
||||
if !shouldRun && !state.Running {
|
||||
return state, nil
|
||||
}
|
||||
if shouldRun && state.Ready {
|
||||
return state, nil
|
||||
}
|
||||
select {
|
||||
case <-deadline.Done():
|
||||
return agentwire.InstanceState{}, errors.New("updated instance did not become ready before timeout")
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// Package module executes game adapters in a capability-limited WebAssembly sandbox.
|
||||
package module
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tetratelabs/wazero"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
|
||||
)
|
||||
|
||||
const (
|
||||
ABI = "dogama:game-module@1.0.0"
|
||||
maxRequestBytes = 64 << 10
|
||||
maxHostCallsPerCall = 32
|
||||
)
|
||||
|
||||
var capabilityExport = map[string]string{
|
||||
"server_info": "get_server_info", "metrics": "get_metrics", "player_list": "list_players",
|
||||
"online_save": "save_world", "graceful_shutdown": "shutdown", "announcement": "send_announcement",
|
||||
"kick": "kick_player", "ban": "ban_player", "unban": "unban_player",
|
||||
}
|
||||
|
||||
type Limits struct {
|
||||
MemoryMB uint32
|
||||
Timeout time.Duration
|
||||
MaxResponseBytes int
|
||||
MaxConcurrentCall int
|
||||
}
|
||||
|
||||
type Binding struct {
|
||||
InstanceID string
|
||||
ContainerPort int
|
||||
AllowedMethods map[string]bool
|
||||
Configuration map[string]string
|
||||
Secrets map[string]string
|
||||
}
|
||||
|
||||
type HTTPRequest struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Header map[string]string `json:"header,omitempty"`
|
||||
Body []byte `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
type HTTPResponse struct {
|
||||
Status int `json:"status"`
|
||||
Header map[string][]string `json:"header,omitempty"`
|
||||
Body []byte `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
type Runtime struct {
|
||||
wasm []byte
|
||||
capabilities []string
|
||||
limits Limits
|
||||
binding Binding
|
||||
client *http.Client
|
||||
sem chan struct{}
|
||||
mu sync.Mutex
|
||||
failures int
|
||||
openUntil time.Time
|
||||
}
|
||||
|
||||
func New(ctx context.Context, wasm []byte, checksum string, capabilities []string, limits Limits, binding Binding) (*Runtime, error) {
|
||||
transport, err := pinnedTransport(ctx, binding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newWithTransport(wasm, checksum, capabilities, limits, binding, transport)
|
||||
}
|
||||
|
||||
func newWithTransport(wasm []byte, checksum string, capabilities []string, limits Limits, binding Binding, transport http.RoundTripper) (*Runtime, error) {
|
||||
if len(wasm) == 0 || limits.MemoryMB == 0 || limits.MemoryMB > 256 || limits.Timeout <= 0 || limits.MaxResponseBytes < 1 || limits.MaxResponseBytes > 8<<20 || limits.MaxConcurrentCall < 1 || limits.MaxConcurrentCall > 16 {
|
||||
return nil, errors.New("invalid module runtime configuration")
|
||||
}
|
||||
digest := sha256.Sum256(wasm)
|
||||
if hex.EncodeToString(digest[:]) != checksum {
|
||||
return nil, errors.New("module checksum mismatch")
|
||||
}
|
||||
if binding.InstanceID == "" || binding.ContainerPort < 1 || binding.ContainerPort > 65535 || transport == nil {
|
||||
return nil, errors.New("invalid instance API binding")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, capability := range capabilities {
|
||||
if _, ok := capabilityExport[capability]; !ok || seen[capability] {
|
||||
return nil, errors.New("invalid module capability")
|
||||
}
|
||||
seen[capability] = true
|
||||
}
|
||||
r := &Runtime{wasm: append([]byte(nil), wasm...), capabilities: append([]string(nil), capabilities...), limits: limits, binding: binding, sem: make(chan struct{}, limits.MaxConcurrentCall)}
|
||||
r.client = &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func pinnedTransport(ctx context.Context, binding Binding) (http.RoundTripper, error) {
|
||||
hostname := "dogama-" + strings.ToLower(binding.InstanceID)
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
addresses, err := net.DefaultResolver.LookupIPAddr(lookupCtx, hostname)
|
||||
if err != nil || len(addresses) == 0 {
|
||||
return nil, errors.New("resolve bound instance API")
|
||||
}
|
||||
ip := addresses[0].IP
|
||||
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() {
|
||||
return nil, errors.New("unsafe instance API address")
|
||||
}
|
||||
pinned := net.JoinHostPort(ip.String(), fmt.Sprint(binding.ContainerPort))
|
||||
dialer := &net.Dialer{Timeout: 2 * time.Second, KeepAlive: 30 * time.Second}
|
||||
return &http.Transport{
|
||||
DisableCompression: true,
|
||||
Proxy: nil,
|
||||
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
if network != "tcp" && network != "tcp4" && network != "tcp6" {
|
||||
return nil, errors.New("unsupported instance API network")
|
||||
}
|
||||
return dialer.DialContext(ctx, "tcp", pinned)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) Call(ctx context.Context, operation string, request any, response any) error {
|
||||
if !r.allowedOperation(operation) {
|
||||
return errors.New("unsupported")
|
||||
}
|
||||
r.mu.Lock()
|
||||
open := time.Now().Before(r.openUntil)
|
||||
r.mu.Unlock()
|
||||
if open {
|
||||
return errors.New("module circuit breaker is open")
|
||||
}
|
||||
select {
|
||||
case r.sem <- struct{}{}:
|
||||
defer func() { <-r.sem }()
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, r.limits.Timeout)
|
||||
defer cancel()
|
||||
input, err := json.Marshal(request)
|
||||
if err != nil || len(input) > maxRequestBytes {
|
||||
return errors.New("invalid module request")
|
||||
}
|
||||
result, err := r.invoke(callCtx, operation, input)
|
||||
if err != nil {
|
||||
r.recordFailure()
|
||||
return err
|
||||
}
|
||||
r.recordSuccess()
|
||||
decoder := json.NewDecoder(bytes.NewReader(result))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(response); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
|
||||
return errors.New("invalid module response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runtime) allowedOperation(operation string) bool {
|
||||
if operation == "initialize" || operation == "test_connection" || operation == "get_server_status" {
|
||||
return true
|
||||
}
|
||||
for _, capability := range r.capabilities {
|
||||
if capabilityExport[capability] == operation {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type callState struct{ hostCalls int }
|
||||
type stateKey struct{}
|
||||
|
||||
func (r *Runtime) invoke(ctx context.Context, operation string, input []byte) ([]byte, error) {
|
||||
ctx = context.WithValue(ctx, stateKey{}, &callState{})
|
||||
pages := (r.limits.MemoryMB*1024*1024 + 65535) / 65536
|
||||
config := wazero.NewRuntimeConfigInterpreter().WithMemoryLimitPages(pages).WithCloseOnContextDone(true).WithDebugInfoEnabled(false)
|
||||
runtime := wazero.NewRuntimeWithConfig(ctx, config)
|
||||
defer runtime.Close(ctx)
|
||||
if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil {
|
||||
return nil, fmt.Errorf("instantiate restricted WASI: %w", err)
|
||||
}
|
||||
host := runtime.NewHostModuleBuilder("dogama_host")
|
||||
host.NewFunctionBuilder().WithFunc(r.httpRequest).Export("http_request")
|
||||
host.NewFunctionBuilder().WithFunc(r.getSecret).Export("get_secret")
|
||||
host.NewFunctionBuilder().WithFunc(r.getConfig).Export("get_config")
|
||||
if _, err := host.Instantiate(ctx); err != nil {
|
||||
return nil, fmt.Errorf("instantiate module host: %w", err)
|
||||
}
|
||||
compiled, err := runtime.CompileModule(ctx, r.wasm)
|
||||
if err != nil {
|
||||
return nil, errors.New("compile module")
|
||||
}
|
||||
if err := validateABI(compiled, r.capabilities); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instance, err := runtime.InstantiateModule(ctx, compiled, wazero.NewModuleConfig().WithName("").WithStartFunctions("_initialize").WithStdin(bytes.NewReader(nil)).WithStdout(io.Discard).WithStderr(io.Discard))
|
||||
if err != nil {
|
||||
return nil, errors.New("instantiate module")
|
||||
}
|
||||
alloc := instance.ExportedFunction("dogama_alloc")
|
||||
fn := instance.ExportedFunction(operation)
|
||||
if alloc == nil || fn == nil {
|
||||
return nil, errors.New("missing module export")
|
||||
}
|
||||
allocated, err := alloc.Call(ctx, uint64(len(input)))
|
||||
if err != nil || len(allocated) != 1 || allocated[0] > 1<<32-1 || !instance.Memory().Write(uint32(allocated[0]), input) {
|
||||
return nil, errors.New("write module request")
|
||||
}
|
||||
out, err := alloc.Call(ctx, uint64(r.limits.MaxResponseBytes))
|
||||
if err != nil || len(out) != 1 || out[0] > 1<<32-1 {
|
||||
return nil, errors.New("allocate module response")
|
||||
}
|
||||
result, err := fn.Call(ctx, allocated[0], uint64(len(input)), out[0], uint64(r.limits.MaxResponseBytes))
|
||||
if err != nil || len(result) != 1 || int32(result[0]) < 0 || result[0] > uint64(r.limits.MaxResponseBytes) {
|
||||
return nil, errors.New("module execution failed")
|
||||
}
|
||||
body, ok := instance.Memory().Read(uint32(out[0]), uint32(result[0]))
|
||||
if !ok {
|
||||
return nil, errors.New("read module response")
|
||||
}
|
||||
return append([]byte(nil), body...), nil
|
||||
}
|
||||
|
||||
func validateABI(compiled wazero.CompiledModule, capabilities []string) error {
|
||||
exports := compiled.ExportedFunctions()
|
||||
for _, name := range []string{"dogama_alloc", "initialize", "test_connection", "get_server_status"} {
|
||||
if exports[name] == nil {
|
||||
return fmt.Errorf("required module export %s is missing", name)
|
||||
}
|
||||
}
|
||||
for _, capability := range capabilities {
|
||||
if exports[capabilityExport[capability]] == nil {
|
||||
return fmt.Errorf("capability export %s is missing", capability)
|
||||
}
|
||||
}
|
||||
for _, imported := range compiled.ImportedFunctions() {
|
||||
moduleName, name, importedFunction := imported.Import()
|
||||
if importedFunction && moduleName != "dogama_host" && moduleName != wasi_snapshot_preview1.ModuleName {
|
||||
return fmt.Errorf("forbidden import %s.%s", moduleName, name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runtime) httpRequest(ctx context.Context, mod api.Module, requestPtr, requestLen, responsePtr, responseCap uint32) int32 {
|
||||
state, _ := ctx.Value(stateKey{}).(*callState)
|
||||
if state == nil || state.hostCalls >= maxHostCallsPerCall || requestLen > maxRequestBytes || responseCap > uint32(r.limits.MaxResponseBytes) {
|
||||
return -1
|
||||
}
|
||||
state.hostCalls++
|
||||
raw, ok := mod.Memory().Read(requestPtr, requestLen)
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
var request HTTPRequest
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&request) != nil || !r.binding.AllowedMethods[request.Method] || len(request.Body) > maxRequestBytes || !validRelativeAPIPath(request.Path) {
|
||||
return -2
|
||||
}
|
||||
endpoint := fmt.Sprintf("http://dogama-%s:%d%s", strings.ToLower(r.binding.InstanceID), r.binding.ContainerPort, request.Path)
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, request.Method, endpoint, bytes.NewReader(request.Body))
|
||||
if err != nil {
|
||||
return -2
|
||||
}
|
||||
for name, value := range request.Header {
|
||||
canonical := http.CanonicalHeaderKey(name)
|
||||
if canonical != "Accept" && canonical != "Content-Type" && canonical != "Authorization" {
|
||||
return -2
|
||||
}
|
||||
httpRequest.Header.Set(canonical, value)
|
||||
}
|
||||
response, err := r.client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return -3
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, int64(r.limits.MaxResponseBytes)+1))
|
||||
if err != nil || len(body) > r.limits.MaxResponseBytes {
|
||||
return -4
|
||||
}
|
||||
encoded, err := json.Marshal(HTTPResponse{Status: response.StatusCode, Header: map[string][]string{"Content-Type": response.Header.Values("Content-Type")}, Body: body})
|
||||
if err != nil || len(encoded) > int(responseCap) || !mod.Memory().Write(responsePtr, encoded) {
|
||||
return -4
|
||||
}
|
||||
return int32(len(encoded))
|
||||
}
|
||||
|
||||
func (r *Runtime) getSecret(ctx context.Context, mod api.Module, keyPtr, keyLen, valuePtr, valueCap uint32) int32 {
|
||||
return r.readBoundValue(ctx, mod, keyPtr, keyLen, valuePtr, valueCap, r.binding.Secrets)
|
||||
}
|
||||
|
||||
func (r *Runtime) getConfig(ctx context.Context, mod api.Module, keyPtr, keyLen, valuePtr, valueCap uint32) int32 {
|
||||
return r.readBoundValue(ctx, mod, keyPtr, keyLen, valuePtr, valueCap, r.binding.Configuration)
|
||||
}
|
||||
|
||||
func (r *Runtime) readBoundValue(ctx context.Context, mod api.Module, keyPtr, keyLen, valuePtr, valueCap uint32, values map[string]string) int32 {
|
||||
state, _ := ctx.Value(stateKey{}).(*callState)
|
||||
if state == nil || state.hostCalls >= maxHostCallsPerCall || keyLen > 128 {
|
||||
return -1
|
||||
}
|
||||
state.hostCalls++
|
||||
raw, ok := mod.Memory().Read(keyPtr, keyLen)
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
value, exists := values[string(raw)]
|
||||
if !exists || len(value) > int(valueCap) || !mod.Memory().Write(valuePtr, []byte(value)) {
|
||||
return -2
|
||||
}
|
||||
return int32(len(value))
|
||||
}
|
||||
|
||||
func validRelativeAPIPath(value string) bool {
|
||||
parsed, err := url.Parse(value)
|
||||
return err == nil && !parsed.IsAbs() && parsed.Host == "" && parsed.RawQuery == "" && parsed.Fragment == "" && strings.HasPrefix(value, "/v1/api/") && path.Clean(value) == value && !strings.Contains(value, "\\")
|
||||
}
|
||||
|
||||
func (r *Runtime) recordFailure() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.failures++
|
||||
if r.failures >= 3 {
|
||||
r.openUntil = time.Now().Add(30 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runtime) recordSuccess() {
|
||||
r.mu.Lock()
|
||||
r.failures, r.openUntil = 0, time.Time{}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
|
||||
|
||||
func TestValidRelativeAPIPath(t *testing.T) {
|
||||
for _, value := range []string{"http://metadata/v1/api/info", "//other/v1/api/info", "/v1/api/../secret", "/v1/api/info?q=1", "/etc/passwd"} {
|
||||
if validRelativeAPIPath(value) {
|
||||
t.Fatalf("unsafe path accepted: %q", value)
|
||||
}
|
||||
}
|
||||
if !validRelativeAPIPath("/v1/api/info") {
|
||||
t.Fatal("documented Palworld endpoint was rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPalworldAdapterReportsBoundedFailures(t *testing.T) {
|
||||
wasm, err := os.ReadFile("../../modules/palworld-rest/module.wasm")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256(wasm)
|
||||
tests := []struct {
|
||||
name string
|
||||
transport roundTripFunc
|
||||
code string
|
||||
}{
|
||||
{name: "unauthorized", code: "unauthorized", transport: func(request *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("unauthorized")), Request: request}, nil
|
||||
}},
|
||||
{name: "malformed", code: "invalid_response", transport: func(request *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not-json")), Request: request}, nil
|
||||
}},
|
||||
{name: "offline", code: "unreachable", transport: func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("offline")
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
runtime, err := newWithTransport(wasm, hex.EncodeToString(digest[:]), []string{"server_info"}, Limits{MemoryMB: 32, Timeout: 10 * time.Second, MaxResponseBytes: 1 << 20, MaxConcurrentCall: 2}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 8212, AllowedMethods: map[string]bool{"GET": true}, Configuration: map[string]string{"username": "admin"}, Secrets: map[string]string{"admin_password": "secret"}}, test.transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var response struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]any `json:"data"`
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := runtime.Call(context.Background(), "get_server_info", struct{}{}, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.OK || response.Error == nil || response.Error.Code != test.code || strings.Contains(response.Error.Message, "secret") {
|
||||
t.Fatalf("unexpected safe failure: %+v", response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPalworldAdapterExecutesInSandbox(t *testing.T) {
|
||||
wasm, err := os.ReadFile("../../modules/palworld-rest/module.wasm")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256(wasm)
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if request.URL.Path != "/v1/api/info" || request.Header.Get("Authorization") == "" {
|
||||
t.Fatalf("unexpected adapter request: %s", request.URL)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"version":"v1","servername":"test","description":"fixture","worldguid":"world"}`)), Request: request}, nil
|
||||
})
|
||||
runtime, err := newWithTransport(wasm, hex.EncodeToString(digest[:]), []string{"server_info"}, Limits{MemoryMB: 32, Timeout: 10 * time.Second, MaxResponseBytes: 1 << 20, MaxConcurrentCall: 2}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 8212, AllowedMethods: map[string]bool{"GET": true, "POST": true}, Configuration: map[string]string{"username": "admin"}, Secrets: map[string]string{"admin_password": "secret"}}, transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var response struct {
|
||||
OK bool `json:"ok"`
|
||||
Error any `json:"error"`
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
GameVersion string `json:"game_version"`
|
||||
Description string `json:"description"`
|
||||
WorldID string `json:"world_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := runtime.Call(context.Background(), "get_server_info", struct{}{}, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !response.OK || response.Data.Name != "test" {
|
||||
t.Fatalf("unexpected normalized response: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsChecksumAndUnknownCapability(t *testing.T) {
|
||||
limits := Limits{MemoryMB: 32, Timeout: time.Second, MaxResponseBytes: 1024, MaxConcurrentCall: 1}
|
||||
binding := Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 8212}
|
||||
if _, err := newWithTransport([]byte("wasm"), strings.Repeat("0", 64), nil, limits, binding, roundTripFunc(nil)); err == nil {
|
||||
t.Fatal("checksum mismatch accepted")
|
||||
}
|
||||
digest := sha256.Sum256([]byte("wasm"))
|
||||
if _, err := newWithTransport([]byte("wasm"), hex.EncodeToString(digest[:]), []string{"shell"}, limits, binding, roundTripFunc(nil)); err == nil {
|
||||
t.Fatal("unknown capability accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPRequestPinsInstanceOriginAndDisablesRedirects(t *testing.T) {
|
||||
instanceID := strings.Repeat("A", 20)
|
||||
seen := false
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
seen = true
|
||||
if request.URL.String() != "http://dogama-aaaaaaaaaaaaaaaaaaaa:8212/v1/api/info" {
|
||||
t.Fatalf("unexpected destination %q", request.URL)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), Request: request}, nil
|
||||
})
|
||||
digest := sha256.Sum256([]byte("wasm"))
|
||||
runtime, err := newWithTransport([]byte("wasm"), hex.EncodeToString(digest[:]), nil, Limits{MemoryMB: 32, Timeout: time.Second, MaxResponseBytes: 1024, MaxConcurrentCall: 1}, Binding{InstanceID: instanceID, ContainerPort: 8212, AllowedMethods: map[string]bool{"GET": true}}, transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://dogama-aaaaaaaaaaaaaaaaaaaa:8212/v1/api/info", nil)
|
||||
response, err := runtime.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if !seen {
|
||||
t.Fatal("bound transport was not used")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// Package notification manages encrypted channels and bounded asynchronous delivery.
|
||||
package notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/smtp"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Channel struct {
|
||||
ID, Name, Type string
|
||||
Enabled bool
|
||||
Events []string
|
||||
Configured bool
|
||||
}
|
||||
type Input struct {
|
||||
Name, Type string
|
||||
Enabled bool
|
||||
Events []string
|
||||
Config map[string]string
|
||||
}
|
||||
type Event struct{ Type, Title, Message, InstanceName, OperationID string }
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
aead cipher.AEAD
|
||||
client *http.Client
|
||||
resolver *net.Resolver
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(db *sql.DB, key []byte) (*Service, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, errors.New("notification encryption key must be exactly 32 bytes")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Service{db: db, aead: aead, resolver: net.DefaultResolver, now: time.Now}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.DialContext = s.dialSafe
|
||||
s.client = &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return s.validateURL(req.Context(), req.URL)
|
||||
}}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Service) Upsert(ctx context.Context, id string, in Input) (Channel, error) {
|
||||
if strings.TrimSpace(in.Name) == "" || !validType(in.Type) {
|
||||
return Channel{}, errors.New("invalid notification channel")
|
||||
}
|
||||
if err := validateConfigShape(in.Type, in.Config); err != nil {
|
||||
return Channel{}, err
|
||||
}
|
||||
encrypted, err := s.seal(in.Config)
|
||||
if err != nil {
|
||||
return Channel{}, err
|
||||
}
|
||||
events, _ := json.Marshal(normalizeEvents(in.Events))
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
if id == "" {
|
||||
id = randomID()
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO notification_channels(id,name,type,enabled,encrypted_config,event_filter_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,type=excluded.type,enabled=excluded.enabled,encrypted_config=excluded.encrypted_config,event_filter_json=excluded.event_filter_json,updated_at=excluded.updated_at`, id, strings.TrimSpace(in.Name), in.Type, in.Enabled, encrypted, string(events), now, now)
|
||||
if err != nil {
|
||||
return Channel{}, fmt.Errorf("save notification channel: %w", err)
|
||||
}
|
||||
return Channel{ID: id, Name: strings.TrimSpace(in.Name), Type: in.Type, Enabled: in.Enabled, Events: normalizeEvents(in.Events), Configured: true}, nil
|
||||
}
|
||||
func (s *Service) List(ctx context.Context) ([]Channel, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,name,type,enabled,event_filter_json,length(encrypted_config)>0 FROM notification_channels ORDER BY name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Channel
|
||||
for rows.Next() {
|
||||
var c Channel
|
||||
var body string
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Enabled, &body, &c.Configured); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(body), &c.Events)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM notification_channels WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
func (s *Service) Queue(ctx context.Context, event Event) error {
|
||||
if !validEvent(event.Type) || len(event.Message) > 1000 {
|
||||
return errors.New("invalid notification event")
|
||||
}
|
||||
payload, _ := json.Marshal(event)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,event_filter_json FROM notification_channels WHERE enabled=1`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var channelIDs []string
|
||||
for rows.Next() {
|
||||
var id, filter string
|
||||
if err := rows.Scan(&id, &filter); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
var events []string
|
||||
_ = json.Unmarshal([]byte(filter), &events)
|
||||
if matches(events, event.Type) {
|
||||
channelIDs = append(channelIDs, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, id := range channelIDs {
|
||||
if _, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) Test(ctx context.Context, id string) error {
|
||||
return s.queueForChannel(ctx, id, Event{Type: "notification.test", Title: "DoGaMa test notification", Message: "This is a test notification from DoGaMa."})
|
||||
}
|
||||
func (s *Service) queueForChannel(ctx context.Context, id string, event Event) error {
|
||||
var enabled bool
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT enabled FROM notification_channels WHERE id=?`, id).Scan(&enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := json.Marshal(event)
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) RunDue(ctx context.Context) error {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT d.id,d.attempt,d.payload_redacted,c.type,c.encrypted_config FROM notification_deliveries d JOIN notification_channels c ON c.id=d.channel_id WHERE d.status IN ('queued','retrying') AND d.next_attempt_at<=? ORDER BY d.next_attempt_at LIMIT 20`, s.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type job struct {
|
||||
id, typ, payload string
|
||||
attempt int
|
||||
encrypted []byte
|
||||
}
|
||||
var jobs []job
|
||||
for rows.Next() {
|
||||
var j job
|
||||
if err := rows.Scan(&j.id, &j.attempt, &j.payload, &j.typ, &j.encrypted); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
rows.Close()
|
||||
for _, j := range jobs {
|
||||
config, e := s.open(j.encrypted)
|
||||
if e == nil {
|
||||
e = s.deliver(ctx, j.id, j.typ, config, []byte(j.payload))
|
||||
}
|
||||
attempt := j.attempt + 1
|
||||
if e == nil {
|
||||
if _, updateErr := s.db.ExecContext(ctx, `UPDATE notification_deliveries SET status='succeeded',attempt=?,completed_at=?,last_error_code='' WHERE id=?`, attempt, s.now().UTC().Format(time.RFC3339Nano), j.id); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
} else {
|
||||
status := "retrying"
|
||||
if attempt >= 5 {
|
||||
status = "failed"
|
||||
}
|
||||
delay := time.Duration(1<<min(attempt, 6)) * time.Minute
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE notification_deliveries SET status=?,attempt=?,next_attempt_at=?,last_error_code=? WHERE id=?`, status, attempt, s.now().UTC().Add(delay).Format(time.RFC3339Nano), errorCode(e), j.id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) deliver(ctx context.Context, id, typ string, c map[string]string, payload []byte) error {
|
||||
if typ == "email" {
|
||||
host := c["host"]
|
||||
port := c["port"]
|
||||
if port == "" {
|
||||
port = "587"
|
||||
}
|
||||
addr := net.JoinHostPort(host, port)
|
||||
var auth smtp.Auth
|
||||
if c["username"] != "" {
|
||||
auth = smtp.PlainAuth("", c["username"], c["password"], host)
|
||||
}
|
||||
msg := []byte("To: " + c["to"] + "\r\nSubject: DoGaMa notification\r\nContent-Type: application/json\r\n\r\n" + string(payload))
|
||||
return sendSMTP(ctx, addr, host, auth, c["from"], strings.Split(c["to"], ","), msg)
|
||||
}
|
||||
u, err := url.Parse(c["url"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.validateURL(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
body := payload
|
||||
if typ == "discord" {
|
||||
var event Event
|
||||
_ = json.Unmarshal(payload, &event)
|
||||
body, _ = json.Marshal(map[string]string{"content": event.Title + "\n" + event.Message})
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-DoGaMa-Event-ID", id)
|
||||
timestamp := strconv.FormatInt(s.now().Unix(), 10)
|
||||
req.Header.Set("X-DoGaMa-Timestamp", timestamp)
|
||||
if secret := c["signing_secret"]; secret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(timestamp + "." + string(body)))
|
||||
req.Header.Set("X-DoGaMa-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("remote_status_%d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) validateURL(ctx context.Context, u *url.URL) error {
|
||||
if u.Scheme != "https" || u.User != nil || u.Hostname() == "" {
|
||||
return errors.New("unsafe_destination")
|
||||
}
|
||||
ips, err := s.resolver.LookupNetIP(ctx, "ip", u.Hostname())
|
||||
if err != nil {
|
||||
return errors.New("destination_resolution_failed")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if unsafeIP(ip) {
|
||||
return errors.New("unsafe_destination")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) dialSafe(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, errors.New("unsafe_destination")
|
||||
}
|
||||
ips, err := s.resolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return nil, errors.New("destination_resolution_failed")
|
||||
}
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
for _, ip := range ips {
|
||||
if unsafeIP(ip) {
|
||||
return nil, errors.New("unsafe_destination")
|
||||
}
|
||||
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if dialErr == nil {
|
||||
return connection, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("delivery_failed")
|
||||
}
|
||||
|
||||
func sendSMTP(ctx context.Context, address, host string, auth smtp.Auth, from string, recipients []string, message []byte) error {
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
connection, err := dialer.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := smtp.NewClient(connection, host)
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||
return errors.New("smtp_tls_required")
|
||||
}
|
||||
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return err
|
||||
}
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recipient := range recipients {
|
||||
if err := client.Rcpt(strings.TrimSpace(recipient)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = w.Write(message); err != nil {
|
||||
_ = w.Close()
|
||||
return err
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
func unsafeIP(ip netip.Addr) bool {
|
||||
return !ip.IsValid() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified()
|
||||
}
|
||||
func (s *Service) seal(config map[string]string) ([]byte, error) {
|
||||
body, _ := json.Marshal(config)
|
||||
nonce := make([]byte, s.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.aead.Seal(nonce, nonce, body, nil), nil
|
||||
}
|
||||
func (s *Service) open(body []byte) (map[string]string, error) {
|
||||
n := s.aead.NonceSize()
|
||||
if len(body) < n {
|
||||
return nil, errors.New("invalid encrypted channel")
|
||||
}
|
||||
plain, err := s.aead.Open(nil, body[:n], body[n:], nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out map[string]string
|
||||
err = json.Unmarshal(plain, &out)
|
||||
return out, err
|
||||
}
|
||||
func validType(v string) bool { return v == "email" || v == "webhook" || v == "discord" }
|
||||
func validEvent(v string) bool {
|
||||
return v == "notification.test" || strings.HasSuffix(v, ".failed") || strings.HasSuffix(v, ".completed") || strings.HasSuffix(v, ".required")
|
||||
}
|
||||
func validateConfigShape(typ string, c map[string]string) error {
|
||||
if typ == "email" {
|
||||
if c["host"] == "" || c["from"] == "" || c["to"] == "" {
|
||||
return errors.New("email host, from and to are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(c["url"])
|
||||
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil {
|
||||
return errors.New("an HTTPS webhook URL is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func normalizeEvents(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, v := range in {
|
||||
v = strings.TrimSpace(v)
|
||||
if validEvent(v) && !seen[v] {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func matches(filter []string, event string) bool {
|
||||
for _, v := range filter {
|
||||
if v == event || v == "*" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func errorCode(err error) string {
|
||||
v := err.Error()
|
||||
if strings.HasPrefix(v, "remote_status_") {
|
||||
return v
|
||||
}
|
||||
switch v {
|
||||
case "unsafe_destination", "destination_resolution_failed":
|
||||
return v
|
||||
}
|
||||
return "delivery_failed"
|
||||
}
|
||||
func randomID() string {
|
||||
b := make([]byte, 18)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package notification_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
func TestChannelSecretsAreEncryptedAndWriteOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service, err := notification.New(db, bytes.Repeat([]byte{7}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channel, err := service.Upsert(ctx, "", notification.Input{Name: "ops", Type: "webhook", Enabled: true, Events: []string{"backup.failed"}, Config: map[string]string{"url": "https://example.com/hook", "signing_secret": "highly-sensitive"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var encrypted []byte
|
||||
if err := db.QueryRowContext(ctx, `SELECT encrypted_config FROM notification_channels WHERE id=?`, channel.ID).Scan(&encrypted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encrypted), "highly-sensitive") {
|
||||
t.Fatal("secret stored in plaintext")
|
||||
}
|
||||
channels, err := service.List(ctx)
|
||||
if err != nil || len(channels) != 1 || !channels[0].Configured {
|
||||
t.Fatalf("channels=%#v err=%v", channels, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryBlocksPrivateWebhookAndRetriesWithRedactedError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service, _ := notification.New(db, bytes.Repeat([]byte{8}, 32))
|
||||
channel, err := service.Upsert(ctx, "", notification.Input{Name: "unsafe", Type: "webhook", Enabled: true, Events: []string{"backup.failed"}, Config: map[string]string{"url": "https://127.0.0.1/hook", "signing_secret": "never-leak"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.Queue(ctx, notification.Event{Type: "backup.failed", Title: "Backup failed", Message: "Operation failed", OperationID: "op-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RunDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status, code string
|
||||
var attempt int
|
||||
if err := db.QueryRowContext(ctx, `SELECT status,attempt,last_error_code FROM notification_deliveries WHERE channel_id=?`, channel.ID).Scan(&status, &attempt, &code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "retrying" || attempt != 1 || code != "unsafe_destination" || strings.Contains(code, "never-leak") {
|
||||
t.Fatalf("status=%s attempt=%d code=%q", status, attempt, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsMissingKeyAndInsecureURL(t *testing.T) {
|
||||
if _, err := notification.New(nil, []byte("short")); err == nil {
|
||||
t.Fatal("short key accepted")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service, _ := notification.New(db, bytes.Repeat([]byte{9}, 32))
|
||||
if _, err := service.Upsert(ctx, "", notification.Input{Name: "bad", Type: "discord", Enabled: true, Config: map[string]string{"url": "http://example.com"}}); err == nil {
|
||||
t.Fatal("insecure URL accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
|
||||
modernsqlite "modernc.org/sqlite"
|
||||
sqlite3 "modernc.org/sqlite/lib"
|
||||
)
|
||||
|
||||
func (r *Repository) ResolveAccess(ctx context.Context, userID, instanceID string) (authorization.Access, error) {
|
||||
access := authorization.Access{Overrides: make(map[string]string)}
|
||||
err := r.db.QueryRowContext(ctx, `SELECT COALESCE(m.role, '') FROM instances i
|
||||
LEFT JOIN instance_memberships m ON m.instance_id=i.id AND m.user_id=?
|
||||
WHERE i.id=? AND i.deleted_at IS NULL`, userID, instanceID).Scan(&access.MembershipRole)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return access, nil
|
||||
}
|
||||
if err != nil {
|
||||
return authorization.Access{}, fmt.Errorf("resolve instance access: %w", err)
|
||||
}
|
||||
access.InstanceExists = true
|
||||
if access.MembershipRole == "" {
|
||||
return access, nil
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT permission, effect FROM permission_overrides WHERE instance_id=? AND user_id=?`, instanceID, userID)
|
||||
if err != nil {
|
||||
return authorization.Access{}, fmt.Errorf("load permission overrides: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var permission, effect string
|
||||
if err := rows.Scan(&permission, &effect); err != nil {
|
||||
return authorization.Access{}, fmt.Errorf("scan permission override: %w", err)
|
||||
}
|
||||
access.Overrides[permission] = effect
|
||||
}
|
||||
return access, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) SetMembership(ctx context.Context, actorID, instanceID, userID, role string) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin membership update: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var globalRole string
|
||||
err = tx.QueryRowContext(ctx, `SELECT global_role FROM users WHERE id=? AND disabled_at IS NULL`, userID).Scan(&globalRole)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return authorization.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("load membership user: %w", err)
|
||||
}
|
||||
if globalRole != "user" {
|
||||
return authorization.ErrInvalidInput
|
||||
}
|
||||
var exists int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT 1 FROM instances WHERE id=? AND deleted_at IS NULL`, instanceID).Scan(&exists); errors.Is(err, sql.ErrNoRows) {
|
||||
return authorization.ErrNotFound
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("load membership instance: %w", err)
|
||||
}
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO instance_memberships(instance_id, user_id, role, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(instance_id, user_id) DO UPDATE SET role=excluded.role, updated_at=excluded.updated_at`, instanceID, userID, role, actorID, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store instance membership: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit membership update: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteMembership(ctx context.Context, instanceID, userID string) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM instance_memberships WHERE instance_id=? AND user_id=?`, instanceID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete membership: %w", err)
|
||||
}
|
||||
changed, _ := result.RowsAffected()
|
||||
if changed != 1 {
|
||||
return authorization.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) SetPermissionOverride(ctx context.Context, actorID, instanceID, userID, permission, effect string) error {
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err := r.db.ExecContext(ctx, `INSERT INTO permission_overrides(instance_id, user_id, permission, effect, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(instance_id, user_id, permission) DO UPDATE SET effect=excluded.effect, created_by=excluded.created_by, updated_at=excluded.updated_at`, instanceID, userID, permission, effect, actorID, now, now)
|
||||
if err != nil {
|
||||
if isConstraintError(err) {
|
||||
return authorization.ErrNotFound
|
||||
}
|
||||
return fmt.Errorf("store permission override: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) DeletePermissionOverride(ctx context.Context, instanceID, userID, permission string) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM permission_overrides WHERE instance_id=? AND user_id=? AND permission=?`, instanceID, userID, permission)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete permission override: %w", err)
|
||||
}
|
||||
changed, _ := result.RowsAffected()
|
||||
if changed != 1 {
|
||||
return authorization.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListMemberships(ctx context.Context, instanceID string) ([]authorization.Membership, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT m.instance_id, m.user_id, u.username, m.role, COALESCE(o.permission, ''), COALESCE(o.effect, '')
|
||||
FROM instance_memberships m JOIN users u ON u.id=m.user_id
|
||||
LEFT JOIN permission_overrides o ON o.instance_id=m.instance_id AND o.user_id=m.user_id
|
||||
WHERE m.instance_id=? ORDER BY u.username COLLATE NOCASE, o.permission`, instanceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list instance memberships: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []authorization.Membership
|
||||
index := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var instanceID, userID, username, role, permission, effect string
|
||||
if err := rows.Scan(&instanceID, &userID, &username, &role, &permission, &effect); err != nil {
|
||||
return nil, fmt.Errorf("scan membership: %w", err)
|
||||
}
|
||||
position, exists := index[userID]
|
||||
if !exists {
|
||||
position = len(result)
|
||||
index[userID] = position
|
||||
result = append(result, authorization.Membership{InstanceID: instanceID, UserID: userID, Username: username, Role: role, Overrides: make(map[string]string)})
|
||||
}
|
||||
if permission != "" {
|
||||
result[position].Overrides[permission] = effect
|
||||
}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) CreateInstallationRequest(ctx context.Context, requesterID string, input authorization.RequestInput) (authorization.InstallationRequest, error) {
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
var players any
|
||||
if input.PlayerEstimate > 0 {
|
||||
players = input.PlayerEstimate
|
||||
}
|
||||
mods := 0
|
||||
if input.ModsRequested {
|
||||
mods = 1
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `INSERT INTO installation_requests(id, requested_by, template_id, template_version, suggested_name, player_estimate, desired_schedule, mods_requested, message, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, input.ID, requesterID, input.TemplateID, input.TemplateVersion, nullable(input.SuggestedName), players, nullable(input.DesiredSchedule), mods, nullable(input.Message), now, now)
|
||||
if err != nil {
|
||||
if isConstraintError(err) {
|
||||
return authorization.InstallationRequest{}, authorization.ErrConflict
|
||||
}
|
||||
return authorization.InstallationRequest{}, fmt.Errorf("create installation request: %w", err)
|
||||
}
|
||||
requests, err := r.ListInstallationRequests(ctx, requesterID, false)
|
||||
if err != nil {
|
||||
return authorization.InstallationRequest{}, err
|
||||
}
|
||||
for _, request := range requests {
|
||||
if request.ID == input.ID {
|
||||
return request, nil
|
||||
}
|
||||
}
|
||||
return authorization.InstallationRequest{}, authorization.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *Repository) ListInstallationRequests(ctx context.Context, userID string, all bool) ([]authorization.InstallationRequest, error) {
|
||||
query := `SELECT q.id, q.requested_by, requester.username, q.template_id, q.template_version, COALESCE(q.suggested_name, ''), COALESCE(q.player_estimate, 0), COALESCE(q.desired_schedule, ''), q.mods_requested, COALESCE(q.message, ''), q.status, COALESCE(q.reviewed_by, ''), COALESCE(reviewer.username, ''), COALESCE(q.review_reason, ''), q.created_at, COALESCE(q.reviewed_at, '')
|
||||
FROM installation_requests q JOIN users requester ON requester.id=q.requested_by LEFT JOIN users reviewer ON reviewer.id=q.reviewed_by`
|
||||
args := []any{}
|
||||
if !all {
|
||||
query += ` WHERE q.requested_by=?`
|
||||
args = append(args, userID)
|
||||
}
|
||||
query += ` ORDER BY q.created_at DESC, q.id`
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list installation requests: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []authorization.InstallationRequest
|
||||
for rows.Next() {
|
||||
var request authorization.InstallationRequest
|
||||
var mods int
|
||||
if err := rows.Scan(&request.ID, &request.RequestedBy, &request.RequesterName, &request.TemplateID, &request.TemplateVersion, &request.SuggestedName, &request.PlayerEstimate, &request.DesiredSchedule, &mods, &request.Message, &request.Status, &request.ReviewedBy, &request.ReviewerName, &request.ReviewReason, &request.CreatedAt, &request.ReviewedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan installation request: %w", err)
|
||||
}
|
||||
request.ModsRequested = mods != 0
|
||||
result = append(result, request)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) ReviewInstallationRequest(ctx context.Context, reviewerID, requestID, decision, reason string) (authorization.InstallationRequest, error) {
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
result, err := r.db.ExecContext(ctx, `UPDATE installation_requests SET status=?, reviewed_by=?, review_reason=?, reviewed_at=?, updated_at=? WHERE id=? AND status='pending'`, decision, reviewerID, nullable(reason), now, now, requestID)
|
||||
if err != nil {
|
||||
return authorization.InstallationRequest{}, fmt.Errorf("review installation request: %w", err)
|
||||
}
|
||||
changed, _ := result.RowsAffected()
|
||||
if changed != 1 {
|
||||
return authorization.InstallationRequest{}, authorization.ErrConflict
|
||||
}
|
||||
requests, err := r.ListInstallationRequests(ctx, reviewerID, true)
|
||||
if err != nil {
|
||||
return authorization.InstallationRequest{}, err
|
||||
}
|
||||
for _, request := range requests {
|
||||
if request.ID == requestID {
|
||||
return request, nil
|
||||
}
|
||||
}
|
||||
return authorization.InstallationRequest{}, authorization.ErrNotFound
|
||||
}
|
||||
|
||||
func isConstraintError(err error) bool {
|
||||
var sqliteError *modernsqlite.Error
|
||||
return errors.As(err, &sqliteError) && sqliteError.Code()&0xff == sqlite3.SQLITE_CONSTRAINT
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
|
||||
)
|
||||
|
||||
func (r *Repository) BeginBackup(ctx context.Context, value backup.Backup, operationID, actorID string) error {
|
||||
var operation, actor any
|
||||
if operationID != "" {
|
||||
operation = operationID
|
||||
}
|
||||
if actorID != "" {
|
||||
actor = actorID
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `INSERT INTO backups(id, instance_id, operation_id, origin, status, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, 'creating', ?, ?)`, value.ID, value.InstanceID, operation, value.Origin, actor, value.CreatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin backup metadata: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) CompleteBackup(ctx context.Context, id, relativePath string, size int64, checksum string, manifest backup.Manifest) error {
|
||||
encoded, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode backup manifest: %w", err)
|
||||
}
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
result, err := r.db.ExecContext(ctx, `UPDATE backups SET status='available', relative_path=?, size_bytes=?, sha256=?, manifest_json=?, completed_at=?, error_code=NULL
|
||||
WHERE id=? AND status='creating'`, relativePath, size, checksum, string(encoded), now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete backup metadata: %w", err)
|
||||
}
|
||||
changed, _ := result.RowsAffected()
|
||||
if changed != 1 {
|
||||
return backup.ErrInvalidState
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) FailBackup(ctx context.Context, id, code string) error {
|
||||
result, err := r.db.ExecContext(ctx, `UPDATE backups SET status='failed', error_code=?, completed_at=? WHERE id=? AND status='creating'`, code, r.now().UTC().Format(time.RFC3339Nano), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fail backup metadata: %w", err)
|
||||
}
|
||||
changed, _ := result.RowsAffected()
|
||||
if changed != 1 {
|
||||
return backup.ErrInvalidState
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListBackups(ctx context.Context, instanceID string) ([]backup.Backup, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id, instance_id, origin, status, COALESCE(relative_path, ''), COALESCE(size_bytes, 0), COALESCE(sha256, ''), created_at, COALESCE(completed_at, ''), COALESCE(error_code, '')
|
||||
FROM backups WHERE instance_id=? AND status!='deleted' ORDER BY created_at DESC, id`, instanceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list backups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []backup.Backup
|
||||
for rows.Next() {
|
||||
var value backup.Backup
|
||||
if err := rows.Scan(&value.ID, &value.InstanceID, &value.Origin, &value.Status, &value.RelativePath, &value.SizeBytes, &value.SHA256, &value.CreatedAt, &value.CompletedAt, &value.ErrorCode); err != nil {
|
||||
return nil, fmt.Errorf("scan backup: %w", err)
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) GetBackup(ctx context.Context, instanceID, id string) (backup.Backup, backup.Manifest, error) {
|
||||
var value backup.Backup
|
||||
var manifestJSON string
|
||||
err := r.db.QueryRowContext(ctx, `SELECT id, instance_id, origin, status, COALESCE(relative_path, ''), COALESCE(size_bytes, 0), COALESCE(sha256, ''), created_at, COALESCE(completed_at, ''), COALESCE(error_code, ''), COALESCE(manifest_json, '')
|
||||
FROM backups WHERE id=? AND instance_id=? AND status!='deleted'`, id, instanceID).Scan(&value.ID, &value.InstanceID, &value.Origin, &value.Status, &value.RelativePath, &value.SizeBytes, &value.SHA256, &value.CreatedAt, &value.CompletedAt, &value.ErrorCode, &manifestJSON)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return backup.Backup{}, backup.Manifest{}, backup.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return backup.Backup{}, backup.Manifest{}, fmt.Errorf("get backup: %w", err)
|
||||
}
|
||||
var manifest backup.Manifest
|
||||
if manifestJSON != "" {
|
||||
if err := json.Unmarshal([]byte(manifestJSON), &manifest); err != nil {
|
||||
return backup.Backup{}, backup.Manifest{}, backup.ErrIntegrity
|
||||
}
|
||||
}
|
||||
return value, manifest, nil
|
||||
}
|
||||
|
||||
func (r *Repository) RetentionCandidates(ctx context.Context, instanceID string, keep int) ([]backup.Backup, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id, instance_id, origin, status, relative_path, size_bytes, sha256, created_at, completed_at, ''
|
||||
FROM backups WHERE instance_id=? AND origin='scheduled' AND status='available'
|
||||
ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?`, instanceID, keep)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list retention candidates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []backup.Backup
|
||||
for rows.Next() {
|
||||
var value backup.Backup
|
||||
if err := rows.Scan(&value.ID, &value.InstanceID, &value.Origin, &value.Status, &value.RelativePath, &value.SizeBytes, &value.SHA256, &value.CreatedAt, &value.CompletedAt, &value.ErrorCode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) MarkBackupDeleted(ctx context.Context, id string) error {
|
||||
result, err := r.db.ExecContext(ctx, `UPDATE backups SET status='deleted', relative_path=NULL, deleted_at=? WHERE id=? AND status='available'`, r.now().UTC().Format(time.RFC3339Nano), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark backup deleted: %w", err)
|
||||
}
|
||||
changed, _ := result.RowsAffected()
|
||||
if changed != 1 {
|
||||
return backup.ErrInvalidState
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetBackupPolicy(ctx context.Context, instanceID string) (backup.Policy, error) {
|
||||
var value backup.Policy
|
||||
var enabled int
|
||||
err := r.db.QueryRowContext(ctx, `SELECT instance_id, enabled, COALESCE(cron_expression, ''), timezone, retention_count, COALESCE(next_run_at, '') FROM backup_policies WHERE instance_id=?`, instanceID).Scan(&value.InstanceID, &enabled, &value.CronExpression, &value.Timezone, &value.RetentionCount, &value.NextRunAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
instanceValue, instanceErr := r.GetInstance(ctx, instanceID)
|
||||
if instanceErr != nil {
|
||||
return backup.Policy{}, instanceErr
|
||||
}
|
||||
return backup.Policy{InstanceID: instanceID, Timezone: "UTC", RetentionCount: instanceValue.Preview.Backup.RetentionCount}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return backup.Policy{}, fmt.Errorf("get backup policy: %w", err)
|
||||
}
|
||||
value.Enabled = enabled != 0
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SetBackupPolicy(ctx context.Context, value backup.Policy) error {
|
||||
enabled := 0
|
||||
if value.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err := r.db.ExecContext(ctx, `INSERT INTO backup_policies(instance_id, enabled, cron_expression, timezone, retention_count, next_run_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(instance_id) DO UPDATE SET enabled=excluded.enabled, cron_expression=excluded.cron_expression, timezone=excluded.timezone, retention_count=excluded.retention_count, next_run_at=excluded.next_run_at, updated_at=excluded.updated_at`, value.InstanceID, enabled, nullable(value.CronExpression), value.Timezone, value.RetentionCount, nullable(value.NextRunAt), now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set backup policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListDueBackupPolicies(ctx context.Context, now string) ([]backup.Policy, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT instance_id, enabled, cron_expression, timezone, retention_count, next_run_at FROM backup_policies WHERE enabled=1 AND next_run_at<=? ORDER BY next_run_at`, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list due backup policies: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []backup.Policy
|
||||
for rows.Next() {
|
||||
var value backup.Policy
|
||||
var enabled int
|
||||
if err := rows.Scan(&value.InstanceID, &enabled, &value.CronExpression, &value.Timezone, &value.RetentionCount, &value.NextRunAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value.Enabled = enabled != 0
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
)
|
||||
|
||||
// Repository persists catalog snapshots and the desired instance registry.
|
||||
type Repository struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewRepository(db *sql.DB) *Repository {
|
||||
return &Repository{db: db, now: time.Now}
|
||||
}
|
||||
|
||||
func (r *Repository) Sync(ctx context.Context, snapshots []catalog.Snapshot) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin catalog sync: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, snapshot := range snapshots {
|
||||
var digest string
|
||||
err := tx.QueryRowContext(ctx, "SELECT digest FROM template_versions WHERE template_id = ? AND version = ?", snapshot.Template.ID, snapshot.Template.Version).Scan(&digest)
|
||||
if err == nil && digest != snapshot.Digest {
|
||||
return fmt.Errorf("%w: %s@%s", catalog.ErrImmutableSnapshot, snapshot.Template.ID, snapshot.Template.Version)
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("check template snapshot: %w", err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO templates(id, origin, trust_status, active_version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET active_version=excluded.active_version, updated_at=excluded.updated_at`,
|
||||
snapshot.Template.ID, snapshot.Origin, snapshot.Origin, snapshot.Template.Version, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert catalog template: %w", err)
|
||||
}
|
||||
if digest == "" {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO template_versions(template_id, version, schema_version, canonical_yaml, digest, game_id, game_name, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, snapshot.Template.ID, snapshot.Template.Version, snapshot.Template.SchemaVersion, snapshot.CanonicalYAML, snapshot.Digest, snapshot.Template.Game.ID, snapshot.Template.Game.Name, snapshot.Template.Game.Description, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert template snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit catalog sync: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) List(ctx context.Context) ([]catalog.Summary, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT t.id, t.active_version, v.game_id, v.game_name, v.description, t.trust_status, v.digest
|
||||
FROM templates t JOIN template_versions v ON v.template_id=t.id AND v.version=t.active_version ORDER BY v.game_name, t.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list catalog: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []catalog.Summary
|
||||
for rows.Next() {
|
||||
var summary catalog.Summary
|
||||
if err := rows.Scan(&summary.ID, &summary.Version, &summary.GameID, &summary.GameName, &summary.Description, &summary.TrustStatus, &summary.Digest); err != nil {
|
||||
return nil, fmt.Errorf("scan catalog: %w", err)
|
||||
}
|
||||
result = append(result, summary)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) Get(ctx context.Context, id, version string) (catalog.Snapshot, error) {
|
||||
var canonical, digest, origin string
|
||||
err := r.db.QueryRowContext(ctx, `SELECT v.canonical_yaml, v.digest, t.origin FROM template_versions v JOIN templates t ON t.id=v.template_id
|
||||
WHERE v.template_id=? AND v.version=?`, id, version).Scan(&canonical, &digest, &origin)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return catalog.Snapshot{}, catalog.ErrTemplateNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return catalog.Snapshot{}, fmt.Errorf("load template snapshot: %w", err)
|
||||
}
|
||||
var template catalog.Template
|
||||
if err := json.Unmarshal([]byte(canonical), &template); err != nil {
|
||||
return catalog.Snapshot{}, fmt.Errorf("decode stored template snapshot: %w", err)
|
||||
}
|
||||
return catalog.Snapshot{Template: template, CanonicalYAML: canonical, Digest: digest, Origin: origin}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateDraft(ctx context.Context, draft instance.Draft) error {
|
||||
if draft.ID == "" {
|
||||
return errors.New("draft instance ID is required")
|
||||
}
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
previewJSON, err := json.Marshal(draft.Preview)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode draft preview: %w", err)
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO instances(id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, custom_labels_json, docker_user_mode, docker_uid, docker_gid, image_tag_mode, image_tag, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, draft.ID, draft.Preview.Slug, draft.Preview.DisplayName, draft.Preview.Template.ID, draft.Preview.Template.Version, draft.Preview.Template.Digest, string(previewJSON), draft.Preview.PlanDigest, string(mustJSON(draft.Preview.CustomLabels)), draft.Preview.DockerUser.Mode, draft.Preview.DockerUser.UID, draft.Preview.DockerUser.GID, draft.Preview.ImageTag.Mode, draft.Preview.ImageTag.Tag, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create draft instance: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO configuration_revisions(instance_id, revision, redacted_snapshot, reason, created_at) VALUES(?,1,?,'creation',?)`, draft.ID, string(previewJSON), now); err != nil {
|
||||
return fmt.Errorf("create initial configuration revision: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *Repository) GetInstance(ctx context.Context, id string) (instance.StoredInstance, error) {
|
||||
return scanInstance(r.db.QueryRowContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running, container_config_pending FROM instances WHERE id=? AND deleted_at IS NULL`, id))
|
||||
}
|
||||
|
||||
func (r *Repository) ListLifecycleInstances(ctx context.Context) ([]instance.StoredInstance, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running, container_config_pending FROM instances WHERE deleted_at IS NULL AND lifecycle_state != 'draft' ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list lifecycle instances: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []instance.StoredInstance
|
||||
for rows.Next() {
|
||||
value, err := scanInstance(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
func scanInstance(row rowScanner) (instance.StoredInstance, error) {
|
||||
var value instance.StoredInstance
|
||||
var previewJSON string
|
||||
var desired int
|
||||
var pending int
|
||||
err := row.Scan(&value.ID, &previewJSON, &value.LifecycleState, &value.ObservedState, &value.ContainerID, &value.PlanDigest, &desired, &pending)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return instance.StoredInstance{}, instance.ErrInstanceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return instance.StoredInstance{}, fmt.Errorf("scan instance: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(previewJSON), &value.Preview); err != nil {
|
||||
return instance.StoredInstance{}, fmt.Errorf("decode instance preview: %w", err)
|
||||
}
|
||||
value.DesiredRunning = desired != 0
|
||||
value.ContainerConfigPending = pending != 0
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *Repository) BeginOperation(ctx context.Context, operationID, instanceID, kind, lifecycleState string) (instance.StoredInstance, error) {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return instance.StoredInstance{}, fmt.Errorf("begin instance operation: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
current, err := scanInstance(tx.QueryRowContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running, container_config_pending FROM instances WHERE id=? AND deleted_at IS NULL`, instanceID))
|
||||
if err != nil {
|
||||
return instance.StoredInstance{}, err
|
||||
}
|
||||
var active int
|
||||
err = tx.QueryRowContext(ctx, `SELECT 1 FROM instance_operations WHERE instance_id=? AND state='running' LIMIT 1`, instanceID).Scan(&active)
|
||||
if err == nil {
|
||||
return instance.StoredInstance{}, instance.ErrOperationConflict
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return instance.StoredInstance{}, fmt.Errorf("check active operation: %w", err)
|
||||
}
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO instance_operations(id, instance_id, kind, state, phase, created_at, updated_at) VALUES (?, ?, ?, 'running', 'dispatch', ?, ?)`, operationID, instanceID, kind, now, now); err != nil {
|
||||
return instance.StoredInstance{}, fmt.Errorf("insert instance operation: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE instances SET lifecycle_state=?, last_error_code=NULL, updated_at=? WHERE id=?`, lifecycleState, now, instanceID); err != nil {
|
||||
return instance.StoredInstance{}, fmt.Errorf("mark instance operation: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return instance.StoredInstance{}, fmt.Errorf("commit instance operation: %w", err)
|
||||
}
|
||||
current.LifecycleState = lifecycleState
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FinishOperation(ctx context.Context, operationID, lifecycleState, observedState, containerID, planDigest string, desiredRunning bool, errorCode string) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin operation completion: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
desired := 0
|
||||
if desiredRunning {
|
||||
desired = 1
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE instance_operations SET state='succeeded', phase='complete', error_code=NULL, updated_at=?, completed_at=? WHERE id=? AND state='running'`, now, now, operationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete instance operation: %w", err)
|
||||
}
|
||||
changed, _ := result.RowsAffected()
|
||||
if changed != 1 {
|
||||
return instance.ErrOperationConflict
|
||||
}
|
||||
var container any
|
||||
if containerID != "" {
|
||||
container = containerID
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE instances SET lifecycle_state=?, observed_state=?, container_id=?, plan_digest=?, desired_running=?, last_error_code=?, updated_at=? WHERE id=(SELECT instance_id FROM instance_operations WHERE id=?)`, lifecycleState, observedState, container, planDigest, desired, nullable(errorCode), now, operationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update completed instance: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit operation completion: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) FailOperation(ctx context.Context, operationID, lifecycleState, errorCode string) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin operation failure: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
result, err := tx.ExecContext(ctx, `UPDATE instance_operations SET state='failed', phase='failed', error_code=?, updated_at=?, completed_at=? WHERE id=? AND state='running'`, errorCode, now, now, operationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fail instance operation: %w", err)
|
||||
}
|
||||
changed, _ := result.RowsAffected()
|
||||
if changed != 1 {
|
||||
return instance.ErrOperationConflict
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE instances SET lifecycle_state=?, observed_state='unknown', last_error_code=?, updated_at=? WHERE id=(SELECT instance_id FROM instance_operations WHERE id=?)`, lifecycleState, errorCode, now, operationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update failed instance: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit operation failure: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateObservation(ctx context.Context, instanceID, lifecycleState, observedState, containerID string, desiredRunning bool, errorCode string) error {
|
||||
desired := 0
|
||||
if desiredRunning {
|
||||
desired = 1
|
||||
}
|
||||
var container any
|
||||
if containerID != "" {
|
||||
container = containerID
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, `UPDATE instances SET lifecycle_state=?, observed_state=?, container_id=?, desired_running=?, last_error_code=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, lifecycleState, observedState, container, desired, nullable(errorCode), r.now().UTC().Format(time.RFC3339Nano), instanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update instance observation: %w", err)
|
||||
}
|
||||
changed, _ := result.RowsAffected()
|
||||
if changed != 1 {
|
||||
return instance.ErrInstanceNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) RecoverInterruptedOperations(ctx context.Context) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin interrupted-operation recovery: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE instances SET lifecycle_state='intervention_required', last_error_code='operation_interrupted', updated_at=? WHERE id IN (SELECT instance_id FROM instance_operations WHERE state='running')`, now); err != nil {
|
||||
return fmt.Errorf("mark interrupted instances: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE instance_operations SET state='intervention_required', phase='interrupted', error_code='operation_interrupted', updated_at=?, completed_at=? WHERE state='running'`, now, now); err != nil {
|
||||
return fmt.Errorf("mark interrupted operations: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit interrupted-operation recovery: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullable(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
func TestCatalogSyncIsImmutableAndDraftPinsSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository := sqlite.NewRepository(db)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Sync(ctx, snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summaries, err := repository.List(ctx)
|
||||
if err != nil || len(summaries) != 1 || summaries[0].Digest != snapshots[0].Digest {
|
||||
t.Fatalf("summaries = %#v, error = %v", summaries, err)
|
||||
}
|
||||
loaded, err := repository.Get(ctx, snapshots[0].Template.ID, snapshots[0].Template.Version)
|
||||
if err != nil || loaded.Digest != snapshots[0].Digest {
|
||||
t.Fatalf("loaded snapshot = %#v, error = %v", loaded, err)
|
||||
}
|
||||
tampered := snapshots[0]
|
||||
tampered.Digest = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
||||
if err := repository.Sync(ctx, []catalog.Snapshot{tampered}); !errors.Is(err, catalog.ErrImmutableSnapshot) {
|
||||
t.Fatalf("immutable snapshot error = %v", err)
|
||||
}
|
||||
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{
|
||||
DisplayName: "Family Palworld", Slug: "family-palworld",
|
||||
HostPorts: map[string]int{"game": 8211},
|
||||
MountPaths: map[string]string{"saved": "/srv/game-servers/family-palworld/saved"},
|
||||
DataOrigin: "new", BackupRetention: 7,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.CreateDraft(ctx, instance.Draft{ID: "opaque-instance-id", Preview: preview}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var state, digest string
|
||||
if err := db.QueryRow("SELECT lifecycle_state, template_digest FROM instances WHERE id=?", "opaque-instance-id").Scan(&state, &digest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != "draft" || digest != snapshots[0].Digest {
|
||||
t.Fatalf("draft state=%q digest=%q", state, digest)
|
||||
}
|
||||
if _, err := repository.BeginOperation(ctx, "operation-one", "opaque-instance-id", "install", "installing"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repository.BeginOperation(ctx, "operation-two", "opaque-instance-id", "start", "starting"); !errors.Is(err, instance.ErrOperationConflict) {
|
||||
t.Fatalf("parallel operation error = %v", err)
|
||||
}
|
||||
if err := repository.FailOperation(ctx, "operation-one", "error", "test_failure"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repository.BeginOperation(ctx, "operation-three", "opaque-instance-id", "start", "starting"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.RecoverInterruptedOperations(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var operationState string
|
||||
if err := db.QueryRow("SELECT state FROM instance_operations WHERE id='operation-three'").Scan(&operationState); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow("SELECT lifecycle_state FROM instances WHERE id='opaque-instance-id'").Scan(&state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if operationState != "intervention_required" || state != "intervention_required" {
|
||||
t.Fatalf("recovered operation=%q instance=%q", operationState, state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
)
|
||||
|
||||
const globalLabelsKey = "game_container_labels"
|
||||
|
||||
func (r *Repository) GetGlobalLabels(ctx context.Context) (map[string]string, error) {
|
||||
var body string
|
||||
err := r.db.QueryRowContext(ctx, `SELECT value_json FROM system_settings WHERE key=?`, globalLabelsKey).Scan(&body)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load global game-container labels: %w", err)
|
||||
}
|
||||
var labels map[string]string
|
||||
if err := json.Unmarshal([]byte(body), &labels); err != nil {
|
||||
return nil, fmt.Errorf("decode global game-container labels: %w", err)
|
||||
}
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SetGlobalLabels(ctx context.Context, labels map[string]string, pending bool) (int, int, error) {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
body := string(mustJSON(labels))
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value_json,revision,updated_at) VALUES(?,?,1,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, revision=system_settings.revision+1, updated_at=excluded.updated_at`, globalLabelsKey, body, now); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, preview_json, desired_running FROM instances WHERE deleted_at IS NULL AND lifecycle_state!='draft'`)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
type update struct {
|
||||
id, body string
|
||||
running bool
|
||||
}
|
||||
var updates []update
|
||||
for rows.Next() {
|
||||
var id, previewBody string
|
||||
var running int
|
||||
if err := rows.Scan(&id, &previewBody, &running); err != nil {
|
||||
rows.Close()
|
||||
return 0, 0, err
|
||||
}
|
||||
var preview instance.Preview
|
||||
if json.Unmarshal([]byte(previewBody), &preview) != nil {
|
||||
rows.Close()
|
||||
return 0, 0, errors.New("decode instance configuration")
|
||||
}
|
||||
preview.GlobalLabels = labels
|
||||
updates = append(updates, update{id: id, body: string(mustJSON(preview)), running: running != 0})
|
||||
}
|
||||
rows.Close()
|
||||
running := 0
|
||||
pendingValue := 0
|
||||
if pending {
|
||||
pendingValue = 1
|
||||
}
|
||||
for _, update := range updates {
|
||||
if update.running {
|
||||
running++
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE instances SET preview_json=?, container_config_pending=?, revision=revision+1, updated_at=? WHERE id=?`, update.body, pendingValue, now, update.id); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO configuration_revisions(instance_id, revision, redacted_snapshot, reason, created_at) SELECT id, revision, preview_json, 'global_labels', ? FROM instances WHERE id=?`, now, update.id); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM configuration_revisions WHERE instance_id=? AND revision NOT IN (SELECT revision FROM configuration_revisions WHERE instance_id=? ORDER BY revision DESC LIMIT 10)`, update.id, update.id); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return len(updates), running, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SaveInstanceConfiguration(ctx context.Context, id string, preview instance.Preview, pending bool, reason, actorID string) error {
|
||||
body := string(mustJSON(preview))
|
||||
labels := string(mustJSON(preview.CustomLabels))
|
||||
pendingValue := 0
|
||||
if pending {
|
||||
pendingValue = 1
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
result, err := tx.ExecContext(ctx, `UPDATE instances SET preview_json=?, custom_labels_json=?, image_tag_mode=?, image_tag=?, container_config_pending=?, revision=revision+1, updated_at=? WHERE id=? AND deleted_at IS NULL`, body, labels, preview.ImageTag.Mode, preview.ImageTag.Tag, pendingValue, now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save instance container configuration: %w", err)
|
||||
}
|
||||
changed, _ := result.RowsAffected()
|
||||
if changed != 1 {
|
||||
return instance.ErrInstanceNotFound
|
||||
}
|
||||
var revision int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT revision FROM instances WHERE id=?`, id).Scan(&revision); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO configuration_revisions(instance_id, revision, redacted_snapshot, reason, created_by, created_at) VALUES(?,?,?,?,?,?)`, id, revision, body, reason, nullable(actorID), now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM configuration_revisions WHERE instance_id=? AND revision NOT IN (SELECT revision FROM configuration_revisions WHERE instance_id=? ORDER BY revision DESC LIMIT 10)`, id, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *Repository) ListConfigurationRevisions(ctx context.Context, id string) ([]instance.ConfigurationRevision, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT revision, redacted_snapshot, reason, COALESCE(created_by,''), created_at FROM configuration_revisions WHERE instance_id=? ORDER BY revision DESC`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []instance.ConfigurationRevision
|
||||
for rows.Next() {
|
||||
var value instance.ConfigurationRevision
|
||||
var body, created string
|
||||
value.InstanceID = id
|
||||
if err := rows.Scan(&value.Revision, &body, &value.Reason, &value.CreatedBy, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &value.Snapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value.CreatedAt, _ = time.Parse(time.RFC3339Nano, created)
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) GetConfigurationRevision(ctx context.Context, id string, revision int) (instance.ConfigurationRevision, error) {
|
||||
values, err := r.ListConfigurationRevisions(ctx, id)
|
||||
if err != nil {
|
||||
return instance.ConfigurationRevision{}, err
|
||||
}
|
||||
for _, value := range values {
|
||||
if value.Revision == revision {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
return instance.ConfigurationRevision{}, instance.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
func (r *Repository) ClearContainerConfigPending(ctx context.Context, id, planDigest string) error {
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE instances SET container_config_pending=0, plan_digest=?, updated_at=? WHERE id=?`, planDigest, r.now().UTC().Format(time.RFC3339Nano), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func mustJSON(value any) []byte {
|
||||
body, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
|
||||
)
|
||||
|
||||
func (r *Repository) BeginImport(ctx context.Context, value importexport.Import, actorID string) error {
|
||||
_, err := r.db.ExecContext(ctx, `INSERT INTO imports(id, requested_by, template_id, template_version, status, format, relative_stage_path, created_at, expires_at) VALUES (?, ?, ?, ?, 'staging', ?, ?, ?, ?)`, value.ID, actorID, value.TemplateID, value.TemplateVersion, value.Format, value.RelativeStagePath, r.now().UTC().Format(time.RFC3339Nano), value.ExpiresAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin import: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) CompleteImport(ctx context.Context, value importexport.Import) error {
|
||||
result, err := r.db.ExecContext(ctx, `UPDATE imports SET status='validated', data_root=?, detected_type=?, confidence=?, file_count=?, expanded_size_bytes=?, completed_at=? WHERE id=? AND status='staging'`, value.DataRoot, value.DetectedType, value.Confidence, value.FileCount, value.ExpandedSizeBytes, r.now().UTC().Format(time.RFC3339Nano), value.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete import: %w", err)
|
||||
}
|
||||
changed, _ := result.RowsAffected()
|
||||
if changed != 1 {
|
||||
return importexport.ErrInvalidInput
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetImport(ctx context.Context, id string) (importexport.Import, error) {
|
||||
var value importexport.Import
|
||||
err := r.db.QueryRowContext(ctx, `SELECT id, status, format, template_id, template_version, COALESCE(instance_id, ''), relative_stage_path, COALESCE(data_root, ''), COALESCE(detected_type, ''), COALESCE(confidence, ''), file_count, expanded_size_bytes, expires_at FROM imports WHERE id=?`, id).Scan(&value.ID, &value.Status, &value.Format, &value.TemplateID, &value.TemplateVersion, &value.InstanceID, &value.RelativeStagePath, &value.DataRoot, &value.DetectedType, &value.Confidence, &value.FileCount, &value.ExpandedSizeBytes, &value.ExpiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return importexport.Import{}, importexport.ErrInvalidInput
|
||||
}
|
||||
if err != nil {
|
||||
return importexport.Import{}, fmt.Errorf("get import: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *Repository) AttachImport(ctx context.Context, id, instanceID string) error {
|
||||
result, err := r.db.ExecContext(ctx, `UPDATE imports SET status='attached', instance_id=?, completed_at=? WHERE id=? AND status='validated'`, instanceID, r.now().UTC().Format(time.RFC3339Nano), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("attach import: %w", err)
|
||||
}
|
||||
changed, _ := result.RowsAffected()
|
||||
if changed != 1 {
|
||||
return importexport.ErrInvalidInput
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) FailImport(ctx context.Context, id, code string) error {
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE imports SET status='failed', error_code=?, completed_at=? WHERE id=? AND status='staging'`, code, r.now().UTC().Format(time.RFC3339Nano), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fail import: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ExpireImports(ctx context.Context, now string) ([]string, error) {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin import expiry: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
rows, err := tx.QueryContext(ctx, `SELECT relative_stage_path FROM imports WHERE status IN ('staging', 'validated') AND expires_at<=?`, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list expired imports: %w", err)
|
||||
}
|
||||
var paths []string
|
||||
for rows.Next() {
|
||||
var value string
|
||||
if err := rows.Scan(&value); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
paths = append(paths, value)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE imports SET status='expired', completed_at=? WHERE status IN ('staging', 'validated') AND expires_at<=?`, now, now); err != nil {
|
||||
return nil, fmt.Errorf("expire imports: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit import expiry: %w", err)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
@@ -2,10 +2,14 @@ package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/migrations"
|
||||
)
|
||||
|
||||
func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
|
||||
@@ -20,8 +24,17 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("got %d migrations, want 1", count)
|
||||
if count != 9 {
|
||||
t.Fatalf("got %d migrations, want 9", count)
|
||||
}
|
||||
for _, table := range []string{"instance_memberships", "permission_overrides", "installation_requests", "backup_policies", "backups", "imports", "notification_channels", "notification_deliveries", "audit_events"} {
|
||||
var found int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&found); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found != 1 {
|
||||
t.Fatalf("required table %q is missing", table)
|
||||
}
|
||||
}
|
||||
var foreignKeys, busyTimeout int
|
||||
var journalMode string
|
||||
@@ -49,7 +62,71 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("reopened database has %d migrations, want 1", count)
|
||||
if count != 9 {
|
||||
t.Fatalf("reopened database has %d migrations, want 9", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleMigrationPreservesMilestoneThreeDrafts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dogama.db")
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"0001_initial.sql", "0002_catalog_instances.sql"} {
|
||||
body, err := migrations.Files.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range strings.Split(string(body), ";") {
|
||||
if strings.TrimSpace(statement) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, statement); err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO templates(id, origin, trust_status, active_version, created_at, updated_at) VALUES ('template', 'official', 'official', '1.0.0', ?, ?)`, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO template_versions(template_id, version, schema_version, canonical_yaml, digest, game_id, game_name, description, created_at) VALUES ('template', '1.0.0', 1, '{}', ?, 'game', 'Game', 'Description', ?)`, strings.Repeat("a", 64), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO instances(id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, created_at, updated_at) VALUES ('instance', 'instance', 'Instance', 'template', '1.0.0', ?, 1, 'draft', '{}', ?, ?, ?)`, strings.Repeat("a", 64), strings.Repeat("b", 64), now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err = sqlite.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
var lifecycle, observed string
|
||||
if err := db.QueryRowContext(ctx, "SELECT lifecycle_state, observed_state FROM instances WHERE id='instance'").Scan(&lifecycle, &observed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lifecycle != "draft" || observed != "unknown" {
|
||||
t.Fatalf("migrated lifecycle=%q observed=%q", lifecycle, observed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
||||
)
|
||||
|
||||
func (s *server) requireRecentAdmin(w http.ResponseWriter, r *http.Request, api bool) (auth.User, bool) {
|
||||
var user auth.User
|
||||
var ok bool
|
||||
if api {
|
||||
user, ok = s.requireAPIUser(w, r, true)
|
||||
} else {
|
||||
var err error
|
||||
user, err = s.currentUser(r)
|
||||
ok = err == nil && user.Role == "admin"
|
||||
if ok {
|
||||
session, e := r.Cookie(sessionCookie)
|
||||
ok = e == nil && s.parseForm(w, r) && s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token"))
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return auth.User{}, false
|
||||
}
|
||||
if time.Since(user.AuthenticatedAt) > 10*time.Minute {
|
||||
if api {
|
||||
s.apiProblem(w, http.StatusForbidden, "reauthentication_required", "Recent authentication is required.")
|
||||
} else {
|
||||
s.problem(w, http.StatusForbidden, "Recent authentication is required.")
|
||||
}
|
||||
return auth.User{}, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
func (s *server) recordAudit(r *http.Request, actor auth.User, action, outcome string, summary map[string]string) {
|
||||
if s.audit == nil {
|
||||
return
|
||||
}
|
||||
if err := s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, Action: action, Outcome: outcome, Summary: summary}); err != nil {
|
||||
s.logger.Warn("audit recording failed", "event", "audit.record.failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) auditList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAPIUser(w, r, true); !ok {
|
||||
return
|
||||
}
|
||||
f := audit.Filter{ActorID: r.URL.Query().Get("actor_id"), InstanceID: r.URL.Query().Get("instance_id"), Action: r.URL.Query().Get("action"), Outcome: r.URL.Query().Get("outcome")}
|
||||
events, err := s.audit.List(r.Context(), f)
|
||||
if err != nil {
|
||||
s.apiProblem(w, 500, "audit_unavailable", "Audit events are unavailable.")
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, map[string]any{"events": events})
|
||||
}
|
||||
func (s *server) auditPolicyGet(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAPIUser(w, r, true); !ok {
|
||||
return
|
||||
}
|
||||
p, err := s.audit.Policy(r.Context())
|
||||
if err != nil {
|
||||
s.apiProblem(w, 500, "audit_unavailable", "Audit policy is unavailable.")
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, p)
|
||||
}
|
||||
func (s *server) auditPolicyPut(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p audit.Policy
|
||||
if !s.decodeAPIJSON(w, r, &p) {
|
||||
return
|
||||
}
|
||||
if err := s.audit.SetPolicy(r.Context(), p); err != nil {
|
||||
s.recordAudit(r, actor, "audit.policy.update", "failed", map[string]string{"reason_code": "invalid_policy"})
|
||||
s.apiProblem(w, 422, "invalid_policy", "The audit policy is invalid.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "audit.policy.update", "allowed", nil)
|
||||
s.apiJSON(w, 200, p)
|
||||
}
|
||||
func (s *server) auditPurge(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Before string `json:"before"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
if !s.decodeAPIJSON(w, r, &in) || !in.Confirm {
|
||||
s.apiProblem(w, 422, "confirmation_required", "Purge confirmation is required.")
|
||||
return
|
||||
}
|
||||
before, err := time.Parse(time.RFC3339, in.Before)
|
||||
if err != nil {
|
||||
s.apiProblem(w, 422, "invalid_boundary", "The purge boundary is invalid.")
|
||||
return
|
||||
}
|
||||
n, err := s.audit.Purge(r.Context(), before)
|
||||
if err != nil {
|
||||
s.apiProblem(w, 422, "purge_failed", "The audit purge failed.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)})
|
||||
s.apiJSON(w, 200, map[string]int64{"deleted_count": n})
|
||||
}
|
||||
|
||||
func (s *server) notificationList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAPIUser(w, r, true); !ok {
|
||||
return
|
||||
}
|
||||
values, err := s.notifications.List(r.Context())
|
||||
if err != nil {
|
||||
s.apiProblem(w, 500, "channels_unavailable", "Notification channels are unavailable.")
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, map[string]any{"channels": values})
|
||||
}
|
||||
func (s *server) notificationCreate(w http.ResponseWriter, r *http.Request) {
|
||||
s.notificationUpsert(w, r, "")
|
||||
}
|
||||
func (s *server) notificationUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
s.notificationUpsert(w, r, r.PathValue("id"))
|
||||
}
|
||||
func (s *server) notificationUpsert(w http.ResponseWriter, r *http.Request, id string) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in notification.Input
|
||||
if !s.decodeAPIJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
value, err := s.notifications.Upsert(r.Context(), id, in)
|
||||
if err != nil {
|
||||
s.recordAudit(r, actor, "notification.channel.update", "failed", map[string]string{"channel_type": in.Type, "reason_code": "invalid_channel"})
|
||||
s.apiProblem(w, 422, "invalid_channel", "The notification channel is invalid.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type})
|
||||
status := http.StatusOK
|
||||
if id == "" {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
s.apiJSON(w, status, value)
|
||||
}
|
||||
func (s *server) notificationDelete(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.notifications.Delete(r.Context(), r.PathValue("id")); err != nil {
|
||||
s.apiProblem(w, 500, "delete_failed", "The channel could not be deleted.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
func (s *server) notificationTest(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.requireEmptyBody(w, r) {
|
||||
return
|
||||
}
|
||||
if err := s.notifications.Test(r.Context(), r.PathValue("id")); err != nil {
|
||||
s.apiProblem(w, 404, "channel_not_found", "The channel was not found.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"})
|
||||
s.apiJSON(w, 202, map[string]string{"status": "queued"})
|
||||
}
|
||||
|
||||
func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
config := map[string]string{"url": r.FormValue("url"), "signing_secret": r.FormValue("signing_secret"), "host": r.FormValue("host"), "port": r.FormValue("port"), "username": r.FormValue("smtp_username"), "password": r.FormValue("smtp_password"), "from": r.FormValue("from"), "to": r.FormValue("to")}
|
||||
value, err := s.notifications.Upsert(r.Context(), "", notification.Input{Name: r.FormValue("name"), Type: r.FormValue("type"), Enabled: true, Events: strings.Fields(r.FormValue("events")), Config: config})
|
||||
if err != nil {
|
||||
s.problem(w, 422, "Invalid notification channel.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) notificationTestForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.notifications.Test(r.Context(), r.PathValue("id")); err != nil {
|
||||
s.problem(w, 404, "Notification channel not found.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) notificationDeleteForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.notifications.Delete(r.Context(), r.PathValue("id")); err != nil {
|
||||
s.problem(w, 500, "Notification channel could not be deleted.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) auditPolicyForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
days, _ := strconv.Atoi(r.FormValue("retention_days"))
|
||||
maximum, _ := strconv.Atoi(r.FormValue("maximum_count"))
|
||||
if err := s.audit.SetPolicy(r.Context(), audit.Policy{RetentionDays: days, MaximumCount: maximum}); err != nil {
|
||||
s.problem(w, 422, "Invalid audit policy.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "audit.policy.update", "allowed", nil)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) auditPurgeForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.FormValue("confirm") != "yes" {
|
||||
s.problem(w, 422, "Purge confirmation is required.")
|
||||
return
|
||||
}
|
||||
before, err := time.Parse("2006-01-02", r.FormValue("before"))
|
||||
if err != nil {
|
||||
s.problem(w, 422, "Invalid purge date.")
|
||||
return
|
||||
}
|
||||
n, err := s.audit.Purge(r.Context(), before)
|
||||
if err != nil {
|
||||
s.problem(w, 422, "Audit purge failed.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
+1250
-10
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,441 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
"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"
|
||||
)
|
||||
|
||||
type webLifecycleAgent struct{}
|
||||
|
||||
func (webLifecycleAgent) CreateInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
|
||||
return agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: "container-1", PlanDigest: plan.PlanDigest, Health: "stopped"}, nil
|
||||
}
|
||||
func (webLifecycleAgent) InspectInstance(_ context.Context, id string) (agentwire.InstanceState, error) {
|
||||
return agentwire.InstanceState{InstanceID: id, ContainerID: "container-1", Health: "stopped"}, nil
|
||||
}
|
||||
func (webLifecycleAgent) StartInstance(_ context.Context, id string) (agentwire.InstanceState, error) {
|
||||
return agentwire.InstanceState{InstanceID: id, ContainerID: "container-1", Running: true, Ready: true, Health: "healthy"}, nil
|
||||
}
|
||||
func (webLifecycleAgent) StopInstance(_ context.Context, id string, _ int) (agentwire.InstanceState, error) {
|
||||
return agentwire.InstanceState{InstanceID: id, ContainerID: "container-1", Health: "stopped"}, nil
|
||||
}
|
||||
func (webLifecycleAgent) RestartInstance(ctx context.Context, id string, _ int) (agentwire.InstanceState, error) {
|
||||
return webLifecycleAgent{}.StartInstance(ctx, id)
|
||||
}
|
||||
func (webLifecycleAgent) DeleteContainer(context.Context, string) error { return nil }
|
||||
func (webLifecycleAgent) GetInstanceStats(_ context.Context, id string) (agentwire.InstanceStats, error) {
|
||||
return agentwire.InstanceStats{InstanceID: id}, nil
|
||||
}
|
||||
|
||||
func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository := sqlite.NewRepository(db)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Sync(ctx, snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authService := auth.New(db)
|
||||
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := NewHandlerWithRepository(authService, repository, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
icon := request(t, handler, http.MethodGet, "/public/game-icons/palworld", nil)
|
||||
assertStatus(t, icon, http.StatusOK)
|
||||
if icon.Header().Get("Content-Type") != "image/png" {
|
||||
t.Fatalf("icon content type = %q", icon.Header().Get("Content-Type"))
|
||||
}
|
||||
traversal := request(t, handler, http.MethodGet, "/public/game-icons/..%2Fprivate", nil)
|
||||
if traversal.Code == http.StatusOK {
|
||||
t.Fatal("icon traversal accepted")
|
||||
}
|
||||
unauthenticated := request(t, handler, http.MethodGet, "/api/v1/catalog", nil)
|
||||
assertStatus(t, unauthenticated, http.StatusUnauthorized)
|
||||
sessionCookieValue := &http.Cookie{Name: sessionCookie, Value: session.Token}
|
||||
catalogResponse := request(t, handler, http.MethodGet, "/api/v1/catalog", []*http.Cookie{sessionCookieValue})
|
||||
assertStatus(t, catalogResponse, http.StatusOK)
|
||||
if !strings.Contains(catalogResponse.Body.String(), "palworld-official") {
|
||||
t.Fatalf("catalog response = %s", catalogResponse.Body.String())
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"template_id": "palworld-official", "template_version": "1.0.0",
|
||||
"display_name": "Family Palworld", "slug": "family-palworld",
|
||||
"host_ports": map[string]int{"game": 8211},
|
||||
"mount_paths": map[string]string{"saved": "/srv/game-servers/family-palworld/saved"},
|
||||
"data_origin": "new", "backup_retention": 7,
|
||||
})
|
||||
denied := jsonRequest(t, handler, "/api/v1/instances/preview", payload, sessionCookieValue, "")
|
||||
assertStatus(t, denied, http.StatusForbidden)
|
||||
preview := jsonRequest(t, handler, "/api/v1/instances/preview", payload, sessionCookieValue, session.CSRFToken)
|
||||
assertStatus(t, preview, http.StatusOK)
|
||||
if !strings.Contains(preview.Body.String(), snapshots[0].Digest) {
|
||||
t.Fatalf("preview response = %s", preview.Body.String())
|
||||
}
|
||||
trailingJSON := jsonRequest(t, handler, "/api/v1/instances/preview", append(payload, []byte("{}")...), sessionCookieValue, session.CSRFToken)
|
||||
assertStatus(t, trailingJSON, http.StatusBadRequest)
|
||||
draft := jsonRequest(t, handler, "/api/v1/instances/drafts", payload, sessionCookieValue, session.CSRFToken)
|
||||
assertStatus(t, draft, http.StatusCreated)
|
||||
var draftResponse map[string]string
|
||||
if err := json.Unmarshal(draft.Body.Bytes(), &draftResponse); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM instances WHERE lifecycle_state='draft'").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("draft count = %d, error = %v", count, err)
|
||||
}
|
||||
lifecycleHandler, err := NewHandlerWithLifecycle(authService, repository, webLifecycleAgent{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installPath := "/api/v1/instances/" + draftResponse["id"] + "/install"
|
||||
deniedInstall := jsonRequest(t, lifecycleHandler, installPath, nil, sessionCookieValue, "")
|
||||
assertStatus(t, deniedInstall, http.StatusForbidden)
|
||||
install := jsonRequest(t, lifecycleHandler, installPath, nil, sessionCookieValue, session.CSRFToken)
|
||||
assertStatus(t, install, http.StatusOK)
|
||||
unsafeDelete := httptest.NewRequest(http.MethodDelete, "/api/v1/instances/"+draftResponse["id"], strings.NewReader(`{"scope":"player_data"}`))
|
||||
unsafeDelete.AddCookie(sessionCookieValue)
|
||||
unsafeDelete.Header.Set("X-CSRF-Token", session.CSRFToken)
|
||||
unsafeResponse := httptest.NewRecorder()
|
||||
lifecycleHandler.ServeHTTP(unsafeResponse, unsafeDelete)
|
||||
assertStatus(t, unsafeResponse, http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
func TestNotificationAndAuditAdministration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository := sqlite.NewRepository(db)
|
||||
authService := auth.New(db)
|
||||
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auditService := audit.New(db)
|
||||
notificationService, err := notification.New(db, bytes.Repeat([]byte{3}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := NewHandlerComplete(authService, repository, nil, nil, nil, auditService, notificationService, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookie := &http.Cookie{Name: sessionCookie, Value: session.Token}
|
||||
payload, _ := json.Marshal(map[string]any{"name": "operations", "type": "webhook", "enabled": true, "events": []string{"backup.failed"}, "config": map[string]string{"url": "https://example.com/hook", "signing_secret": "do-not-return"}})
|
||||
created := jsonMethodRequest(t, handler, http.MethodPost, "/api/v1/admin/notification-channels", payload, cookie, session.CSRFToken)
|
||||
assertStatus(t, created, http.StatusCreated)
|
||||
if strings.Contains(created.Body.String(), "do-not-return") {
|
||||
t.Fatal("channel secret returned")
|
||||
}
|
||||
listed := request(t, handler, http.MethodGet, "/api/v1/admin/notification-channels", []*http.Cookie{cookie})
|
||||
assertStatus(t, listed, http.StatusOK)
|
||||
if strings.Contains(listed.Body.String(), "do-not-return") || !strings.Contains(listed.Body.String(), "operations") {
|
||||
t.Fatalf("unsafe channel response: %s", listed.Body.String())
|
||||
}
|
||||
auditResponse := request(t, handler, http.MethodGet, "/api/v1/admin/audit", []*http.Cookie{cookie})
|
||||
assertStatus(t, auditResponse, http.StatusOK)
|
||||
if !strings.Contains(auditResponse.Body.String(), "notification.channel.update") {
|
||||
t.Fatalf("missing audit event: %s", auditResponse.Body.String())
|
||||
}
|
||||
home := request(t, handler, http.MethodGet, "/", []*http.Cookie{cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken}})
|
||||
assertStatus(t, home, http.StatusOK)
|
||||
if !strings.Contains(home.Body.String(), "Notification channels") || !strings.Contains(home.Body.String(), "Recent audit events") {
|
||||
t.Fatal("administration UI sections missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupAPIEnforcesPermissionsAndRestores(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
serversRoot := filepath.Join(root, "servers")
|
||||
backupsRoot := filepath.Join(root, "backups")
|
||||
mount := filepath.Join(serversRoot, "instance", "saved")
|
||||
if err := os.MkdirAll(mount, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
world := filepath.Join(mount, "Level.sav")
|
||||
if err := os.WriteFile(world, []byte("world-v1"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := sqlite.Open(ctx, filepath.Join(root, "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository := sqlite.NewRepository(db)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Sync(ctx, snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authService := auth.New(db)
|
||||
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminSession, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, err := authService.Authenticate(ctx, adminSession.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
player, err := authService.CreateUser(ctx, "player", "another correct battery staple", "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playerSession, err := authService.Login(ctx, "player", "another correct battery staple", "192.0.2.2:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Backup API", Slug: "backup-api", HostPorts: map[string]int{"game": 38211}, MountPaths: map[string]string{"saved": mount}, DataOrigin: "new", BackupRetention: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const instanceID = "backup-api-instance"
|
||||
if err := repository.CreateDraft(ctx, instance.Draft{ID: instanceID, Preview: preview}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE instances SET lifecycle_state='online', observed_state='ready', container_id='container', desired_running=1 WHERE id=?`, instanceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backupService, err := backup.New(repository, webLifecycleAgent{}, serversRoot, backupsRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
importService, err := importexport.New(repository, filepath.Join(root, "imports"), serversRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := NewHandlerWithLifecycleAndBackup(authService, repository, webLifecycleAgent{}, backupService, importService, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminCookie := &http.Cookie{Name: sessionCookie, Value: adminSession.Token}
|
||||
playerCookie := &http.Cookie{Name: sessionCookie, Value: playerSession.Token}
|
||||
denied := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID+"/backups", []*http.Cookie{playerCookie})
|
||||
assertStatus(t, denied, http.StatusForbidden)
|
||||
membership := jsonMethodRequest(t, handler, http.MethodPut, "/api/v1/instances/"+instanceID+"/memberships/"+player.ID, []byte(`{"role":"manager"}`), adminCookie, adminSession.CSRFToken)
|
||||
assertStatus(t, membership, http.StatusNoContent)
|
||||
created := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/backups", nil, playerCookie, playerSession.CSRFToken)
|
||||
assertStatus(t, created, http.StatusCreated)
|
||||
var value backup.Backup
|
||||
if err := json.Unmarshal(created.Body.Bytes(), &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listed := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID+"/backups", []*http.Cookie{playerCookie})
|
||||
assertStatus(t, listed, http.StatusOK)
|
||||
exportDenied := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID+"/backups/"+value.ID+"/export", []*http.Cookie{playerCookie})
|
||||
assertStatus(t, exportDenied, http.StatusForbidden)
|
||||
exported := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID+"/backups/"+value.ID+"/export", []*http.Cookie{adminCookie})
|
||||
assertStatus(t, exported, http.StatusOK)
|
||||
if exported.Header().Get("X-Content-SHA256") == "" {
|
||||
t.Fatal("export checksum header missing")
|
||||
}
|
||||
if err := os.WriteFile(world, []byte("world-v2"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/backups/"+value.ID+"/restore", nil, adminCookie, adminSession.CSRFToken)
|
||||
assertStatus(t, restored, http.StatusOK)
|
||||
body, err := os.ReadFile(world)
|
||||
if err != nil || string(body) != "world-v1" {
|
||||
t.Fatalf("restored world=%q error=%v", body, err)
|
||||
}
|
||||
policy := jsonMethodRequest(t, handler, http.MethodPut, "/api/v1/instances/"+instanceID+"/backup-policy", []byte(`{"enabled":true,"cron_expression":"0 3 * * *","timezone":"Europe/Paris","retention_count":5}`), adminCookie, adminSession.CSRFToken)
|
||||
assertStatus(t, policy, http.StatusOK)
|
||||
|
||||
imported, err := importService.Stage(ctx, admin.ID, "zip", bytes.NewReader(palworldImportZIP(t)), importexport.Policy{TemplateID: snapshots[0].Template.ID, TemplateVersion: snapshots[0].Template.Version, AcceptedFormats: snapshots[0].Template.Imports.AcceptedFormats, MaxExpandedBytes: int64(snapshots[0].Template.Imports.MaxExtractedSizeGB) << 30, RequiredPaths: snapshots[0].Template.Imports.RequiredPaths})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
importMount := filepath.Join(serversRoot, "imported", "saved")
|
||||
importPreview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Imported API", Slug: "imported-api", HostPorts: map[string]int{"game": 38212}, MountPaths: map[string]string{"saved": importMount}, DataOrigin: "import", ImportID: imported.ID, BackupRetention: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const importedInstanceID = "imported-api-instance"
|
||||
if err := repository.CreateDraft(ctx, instance.Draft{ID: importedInstanceID, Preview: importPreview}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installed := jsonRequest(t, handler, "/api/v1/instances/"+importedInstanceID+"/install", nil, adminCookie, adminSession.CSRFToken)
|
||||
assertStatus(t, installed, http.StatusOK)
|
||||
importedWorld := filepath.Join(importMount, "SaveGames", "0", "Level.sav")
|
||||
if body, err := os.ReadFile(importedWorld); err != nil || string(body) != "imported-world" {
|
||||
t.Fatalf("imported world=%q error=%v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func palworldImportZIP(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
writer := zip.NewWriter(&buffer)
|
||||
for name, body := range map[string]string{"Save/Level.sav": "imported-world", "Save/Players/player.sav": "player"} {
|
||||
entry, err := writer.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(entry, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func TestInstanceAuthorizationAndInstallationRequestWorkflow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository := sqlite.NewRepository(db)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Sync(ctx, snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authService := auth.New(db)
|
||||
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
player, err := authService.CreateUser(ctx, "player", "another correct battery staple", "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminSession, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playerSession, err := authService.Login(ctx, "player", "another correct battery staple", "192.0.2.2:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := NewHandlerWithLifecycle(authService, repository, webLifecycleAgent{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminCookie := &http.Cookie{Name: sessionCookie, Value: adminSession.Token}
|
||||
playerCookie := &http.Cookie{Name: sessionCookie, Value: playerSession.Token}
|
||||
|
||||
draftPayload, _ := json.Marshal(map[string]any{
|
||||
"template_id": "palworld-official", "template_version": "1.0.0",
|
||||
"display_name": "Authorization Test", "slug": "authorization-test",
|
||||
"host_ports": map[string]int{"game": 8211},
|
||||
"mount_paths": map[string]string{"saved": "/srv/game-servers/authorization-test/saved"},
|
||||
"data_origin": "new", "backup_retention": 7,
|
||||
})
|
||||
draft := jsonRequest(t, handler, "/api/v1/instances/drafts", draftPayload, adminCookie, adminSession.CSRFToken)
|
||||
assertStatus(t, draft, http.StatusCreated)
|
||||
var draftResult map[string]string
|
||||
if err := json.Unmarshal(draft.Body.Bytes(), &draftResult); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
instanceID := draftResult["id"]
|
||||
install := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/install", nil, adminCookie, adminSession.CSRFToken)
|
||||
assertStatus(t, install, http.StatusOK)
|
||||
|
||||
denied := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID, []*http.Cookie{playerCookie})
|
||||
assertStatus(t, denied, http.StatusForbidden)
|
||||
membershipPath := "/api/v1/instances/" + instanceID + "/memberships/" + player.ID
|
||||
membership := jsonMethodRequest(t, handler, http.MethodPut, membershipPath, []byte(`{"role":"user"}`), adminCookie, adminSession.CSRFToken)
|
||||
assertStatus(t, membership, http.StatusNoContent)
|
||||
inspect := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID, []*http.Cookie{playerCookie})
|
||||
assertStatus(t, inspect, http.StatusOK)
|
||||
start := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/start", nil, playerCookie, playerSession.CSRFToken)
|
||||
assertStatus(t, start, http.StatusOK)
|
||||
restart := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/restart", nil, playerCookie, playerSession.CSRFToken)
|
||||
assertStatus(t, restart, http.StatusForbidden)
|
||||
membership = jsonMethodRequest(t, handler, http.MethodPut, membershipPath, []byte(`{"role":"manager"}`), adminCookie, adminSession.CSRFToken)
|
||||
assertStatus(t, membership, http.StatusNoContent)
|
||||
restart = jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/restart", nil, playerCookie, playerSession.CSRFToken)
|
||||
assertStatus(t, restart, http.StatusOK)
|
||||
overridePath := membershipPath + "/permissions/instance.restart"
|
||||
override := jsonMethodRequest(t, handler, http.MethodPut, overridePath, []byte(`{"effect":"deny"}`), adminCookie, adminSession.CSRFToken)
|
||||
assertStatus(t, override, http.StatusNoContent)
|
||||
restart = jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/restart", nil, playerCookie, playerSession.CSRFToken)
|
||||
assertStatus(t, restart, http.StatusForbidden)
|
||||
substitution := request(t, handler, http.MethodGet, "/api/v1/instances/not-the-member-instance", []*http.Cookie{playerCookie})
|
||||
assertStatus(t, substitution, http.StatusForbidden)
|
||||
|
||||
requestPayload := []byte(`{"template_id":"palworld-official","template_version":"1.0.0","suggested_name":"Friends","player_estimate":8,"desired_schedule":"evenings","mods_requested":true,"message":"Private group"}`)
|
||||
installationRequest := jsonRequest(t, handler, "/api/v1/installation-requests", requestPayload, playerCookie, playerSession.CSRFToken)
|
||||
assertStatus(t, installationRequest, http.StatusCreated)
|
||||
var createdRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(installationRequest.Body.Bytes(), &createdRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
duplicate := jsonRequest(t, handler, "/api/v1/installation-requests", requestPayload, playerCookie, playerSession.CSRFToken)
|
||||
assertStatus(t, duplicate, http.StatusConflict)
|
||||
playerList := request(t, handler, http.MethodGet, "/api/v1/installation-requests", []*http.Cookie{playerCookie})
|
||||
assertStatus(t, playerList, http.StatusOK)
|
||||
if !strings.Contains(playerList.Body.String(), createdRequest.ID) {
|
||||
t.Fatalf("request list = %s", playerList.Body.String())
|
||||
}
|
||||
var instancesBefore int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM instances").Scan(&instancesBefore); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
review := jsonRequest(t, handler, "/api/v1/installation-requests/"+createdRequest.ID+"/review", []byte(`{"decision":"approved","reason":"capacity available"}`), adminCookie, adminSession.CSRFToken)
|
||||
assertStatus(t, review, http.StatusOK)
|
||||
var instancesAfter int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM instances").Scan(&instancesAfter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if instancesAfter != instancesBefore {
|
||||
t.Fatalf("approval deployed an instance: before=%d after=%d", instancesBefore, instancesAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapAuthenticationAndLogoutFlow(t *testing.T) {
|
||||
handler := testHandler(t)
|
||||
|
||||
@@ -163,6 +584,21 @@ func formRequest(t *testing.T, handler http.Handler, target string, values url.V
|
||||
return response
|
||||
}
|
||||
|
||||
func jsonRequest(t *testing.T, handler http.Handler, target string, body []byte, session *http.Cookie, csrf string) *httptest.ResponseRecorder {
|
||||
return jsonMethodRequest(t, handler, http.MethodPost, target, body, session, csrf)
|
||||
}
|
||||
|
||||
func jsonMethodRequest(t *testing.T, handler http.Handler, method, target string, body []byte, session *http.Cookie, csrf string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, target, bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-CSRF-Token", csrf)
|
||||
request.AddCookie(session)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func namedCookie(t *testing.T, response *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5; }
|
||||
body { margin: 0; background: #eef2f7; color: #172033; }
|
||||
main { width: min(32rem, calc(100% - 2rem)); margin: 8vh auto; padding: 2rem; background: white; border-radius: .75rem; box-shadow: 0 .5rem 2rem #17203318; }
|
||||
main { width: min(64rem, calc(100% - 2rem)); margin: 4vh auto; padding: 2rem; background: white; border-radius: .75rem; box-shadow: 0 .5rem 2rem #17203318; }
|
||||
section { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #d0d5dd; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 2rem; background: white; }
|
||||
form { display: grid; gap: 1rem; }
|
||||
header form { display: block; }
|
||||
.inline { display: inline; margin-left: .5rem; }
|
||||
.inline button { padding: .35rem .55rem; }
|
||||
label { display: grid; gap: .35rem; font-weight: 600; }
|
||||
input, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; }
|
||||
input, textarea, select, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
|
||||
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } th, td { padding: .6rem; text-align: left; border-bottom: 1px solid #d0d5dd; white-space: nowrap; }
|
||||
.danger { background: #b42318; }
|
||||
.warning { padding: .75rem; border-left: .25rem solid #b54708; background: #fffaeb; color: #7a2e0e; }
|
||||
button { border: 0; background: #3157d5; color: white; font-weight: 700; cursor: pointer; }
|
||||
.error { padding: .75rem; border-left: .25rem solid #b42318; background: #fee4e2; color: #7a271a; }
|
||||
@media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } main, header { background: #1f2937; } input { background: #111827; color: #e5e7eb; } }
|
||||
@media (max-width: 40rem) { main { padding: 1rem; margin: 1rem; width: auto; } header { padding: 1rem; } .grid { grid-template-columns: 1fr; } }
|
||||
@media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } main, header { background: #1f2937; } input, textarea, select { background: #111827; color: #e5e7eb; } }
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
{{define "home.html"}}<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head>
|
||||
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header><main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p></main></body></html>{{end}}
|
||||
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header>
|
||||
<main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p>
|
||||
{{if .IsAdmin}}
|
||||
<section><h2>Notification channels</h2><p>Secrets are encrypted and never displayed after saving. Delivery is queued and retried without blocking operations.</p>
|
||||
<ul>{{range .Channels}}<li><strong>{{.Name}}</strong> — {{.Type}} · {{if .Enabled}}enabled{{else}}disabled{{end}}
|
||||
<form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit">Send test</button></form>
|
||||
<form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/delete"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button class="danger" type="submit">Delete</button></form></li>{{else}}<li>No channel configured.</li>{{end}}</ul>
|
||||
<form method="post" action="/admin/notification-channels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Name<input name="name" required></label><label>Type<select name="type"><option value="webhook">HTTPS webhook</option><option value="discord">Discord webhook</option><option value="email">SMTP email</option></select></label><label>HTTPS URL<input name="url" type="url" placeholder="https://…"></label><label>Signing secret<input name="signing_secret" type="password" autocomplete="new-password"></label><div class="grid"><label>SMTP host<input name="host"></label><label>Port<input name="port" inputmode="numeric" placeholder="587"></label><label>Username<input name="smtp_username"></label><label>Password<input name="smtp_password" type="password" autocomplete="new-password"></label><label>From<input name="from" type="email"></label><label>Recipients<input name="to"></label></div><label>Events (space separated)<input name="events" value="backup.failed update.failed restore.failed security.required"></label><button type="submit">Add channel</button></form></section>
|
||||
<section><h2>Audit retention</h2><form method="post" action="/admin/audit-policy"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><div class="grid"><label>Retention days<input name="retention_days" type="number" min="0" max="3650" value="{{.AuditPolicy.RetentionDays}}"></label><label>Maximum entries<input name="maximum_count" type="number" min="0" max="1000000" value="{{.AuditPolicy.MaximumCount}}"></label></div><p>Zero means unlimited and may grow the database indefinitely.</p><button type="submit">Save retention</button></form>
|
||||
<form method="post" action="/admin/audit-purge"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Delete events before<input name="before" type="date" required></label><label><input name="confirm" type="checkbox" value="yes"> Confirm bounded audit purge</label><button class="danger" type="submit">Purge audit events</button></form></section>
|
||||
<section><h2>Recent audit events</h2><form method="get" action="/"><div class="grid"><label>Actor ID<input name="actor_id" value="{{.AuditActor}}"></label><label>Instance ID<input name="instance_id" value="{{.AuditInstance}}"></label><label>Action<input name="action" value="{{.AuditAction}}"></label><label>Outcome<select name="outcome"><option value="">Any</option><option value="allowed">Allowed</option><option value="denied">Denied</option><option value="failed">Failed</option></select></label></div><button type="submit">Filter audit</button></form>
|
||||
<div class="table-wrap"><table><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Outcome</th><th>Instance</th></tr></thead><tbody>{{range .AuditEvents}}<tr><td>{{.OccurredAt.Format "2006-01-02 15:04:05Z"}}</td><td>{{.ActorLabel}}</td><td><code>{{.Action}}</code></td><td>{{.Outcome}}</td><td>{{.InstanceID}}</td></tr>{{else}}<tr><td colspan="5">No audit event.</td></tr>{{end}}</tbody></table></div></section>
|
||||
<section><h2>Game-container labels</h2><form method="post" action="/admin/game-container-labels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Global labels<textarea name="labels" rows="8" placeholder="key=value">{{.GlobalLabels}}</textarea></label><p>One label per line. Available variables: <code>{{`{{game.name}}`}}</code>, <code>{{`{{game.id}}`}}</code>, <code>{{`{{game.icon_url}}`}}</code>, <code>{{`{{instance.name}}`}}</code>, <code>{{`{{instance.id}}`}}</code>, <code>{{`{{instance.slug}}`}}</code>, <code>{{`{{server.name}}`}}</code>.</p><label><input type="radio" name="apply" value="next_start" checked> Apply on next start</label><label><input type="radio" name="apply" value="immediate"> Apply immediately</label><aside class="warning"><strong>Immediate application stops and recreates affected containers.</strong> Connected players are disconnected immediately. Persistent data is preserved and each instance returns to its previous running or stopped state.</aside><label><input type="checkbox" name="confirm_disconnection" value="yes"> I understand the immediate-disconnection warning</label><button type="submit">Save game-container labels</button></form></section>
|
||||
{{end}}</main></body></html>{{end}}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TABLE templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
origin TEXT NOT NULL,
|
||||
trust_status TEXT NOT NULL,
|
||||
active_version TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE template_versions (
|
||||
template_id TEXT NOT NULL REFERENCES templates(id) ON DELETE RESTRICT,
|
||||
version TEXT NOT NULL,
|
||||
schema_version INTEGER NOT NULL,
|
||||
canonical_yaml TEXT NOT NULL,
|
||||
digest TEXT NOT NULL,
|
||||
game_id TEXT NOT NULL,
|
||||
game_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (template_id, version),
|
||||
UNIQUE (digest)
|
||||
);
|
||||
|
||||
CREATE TABLE instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
template_id TEXT NOT NULL,
|
||||
template_version TEXT NOT NULL,
|
||||
template_digest TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state = 'draft'),
|
||||
preview_json TEXT NOT NULL,
|
||||
plan_digest TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX instances_template_idx ON instances(template_id, template_version);
|
||||
@@ -0,0 +1,46 @@
|
||||
ALTER TABLE instances RENAME TO instances_v2;
|
||||
|
||||
CREATE TABLE instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
template_id TEXT NOT NULL,
|
||||
template_version TEXT NOT NULL,
|
||||
template_digest TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('draft', 'installing', 'stopped', 'starting', 'online', 'stopping', 'backup', 'restore', 'update', 'degraded', 'error', 'unknown', 'intervention_required', 'deleting', 'deleted')),
|
||||
observed_state TEXT NOT NULL DEFAULT 'unknown' CHECK (observed_state IN ('unknown', 'missing', 'stopped', 'running', 'ready', 'degraded')),
|
||||
preview_json TEXT NOT NULL,
|
||||
plan_digest TEXT NOT NULL,
|
||||
container_id TEXT,
|
||||
desired_running INTEGER NOT NULL DEFAULT 0 CHECK (desired_running IN (0, 1)),
|
||||
last_error_code TEXT,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
INSERT INTO instances(id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, created_at, updated_at)
|
||||
SELECT id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, created_at, updated_at
|
||||
FROM instances_v2;
|
||||
|
||||
DROP TABLE instances_v2;
|
||||
|
||||
CREATE INDEX instances_template_idx ON instances(template_id, template_version);
|
||||
CREATE INDEX instances_lifecycle_idx ON instances(lifecycle_state);
|
||||
|
||||
CREATE TABLE instance_operations (
|
||||
id TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('install', 'start', 'stop', 'restart', 'delete_container', 'reconcile')),
|
||||
state TEXT NOT NULL CHECK (state IN ('running', 'succeeded', 'failed', 'intervention_required')),
|
||||
phase TEXT NOT NULL,
|
||||
error_code TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX instance_operation_active_idx ON instance_operations(instance_id) WHERE state = 'running';
|
||||
CREATE INDEX instance_operation_history_idx ON instance_operations(instance_id, created_at DESC);
|
||||
@@ -0,0 +1,46 @@
|
||||
CREATE TABLE instance_memberships (
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'manager')),
|
||||
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (instance_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX instance_memberships_user_idx ON instance_memberships(user_id, instance_id);
|
||||
|
||||
CREATE TABLE permission_overrides (
|
||||
instance_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
permission TEXT NOT NULL CHECK (permission IN ('instance.view', 'instance.start', 'instance.stop', 'instance.restart', 'instance.update', 'instance.configure', 'instance.delete', 'instance.welcome.edit', 'metrics.view', 'players.view', 'players.kick', 'players.ban', 'players.unban', 'announcements.send', 'logs.view', 'mods.manage', 'backup.create', 'backup.list', 'backup.export', 'backup.restore', 'backup.delete', 'request.create')),
|
||||
effect TEXT NOT NULL CHECK (effect IN ('allow', 'deny')),
|
||||
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (instance_id, user_id, permission),
|
||||
FOREIGN KEY (instance_id, user_id) REFERENCES instance_memberships(instance_id, user_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE installation_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
requested_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
template_id TEXT NOT NULL,
|
||||
template_version TEXT NOT NULL,
|
||||
suggested_name TEXT,
|
||||
player_estimate INTEGER CHECK (player_estimate IS NULL OR (player_estimate >= 1 AND player_estimate <= 10000)),
|
||||
desired_schedule TEXT,
|
||||
mods_requested INTEGER NOT NULL DEFAULT 0 CHECK (mods_requested IN (0, 1)),
|
||||
message TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'refused', 'cancelled')),
|
||||
reviewed_by TEXT REFERENCES users(id) ON DELETE RESTRICT,
|
||||
review_reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
reviewed_at TEXT,
|
||||
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX installation_requests_pending_idx ON installation_requests(requested_by, template_id, template_version) WHERE status = 'pending';
|
||||
CREATE INDEX installation_requests_status_idx ON installation_requests(status, created_at);
|
||||
CREATE INDEX installation_requests_requester_idx ON installation_requests(requested_by, created_at DESC);
|
||||
@@ -0,0 +1,76 @@
|
||||
ALTER TABLE instance_operations RENAME TO instance_operations_v4;
|
||||
|
||||
CREATE TABLE instance_operations (
|
||||
id TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('install', 'start', 'stop', 'restart', 'delete_container', 'reconcile', 'backup', 'restore', 'import')),
|
||||
state TEXT NOT NULL CHECK (state IN ('running', 'succeeded', 'failed', 'intervention_required')),
|
||||
phase TEXT NOT NULL,
|
||||
error_code TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
INSERT INTO instance_operations(id, instance_id, kind, state, phase, error_code, created_at, updated_at, completed_at)
|
||||
SELECT id, instance_id, kind, state, phase, error_code, created_at, updated_at, completed_at
|
||||
FROM instance_operations_v4;
|
||||
|
||||
DROP TABLE instance_operations_v4;
|
||||
|
||||
CREATE UNIQUE INDEX instance_operation_active_idx ON instance_operations(instance_id) WHERE state = 'running';
|
||||
CREATE INDEX instance_operation_history_idx ON instance_operations(instance_id, created_at DESC);
|
||||
|
||||
CREATE TABLE backup_policies (
|
||||
instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE,
|
||||
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
|
||||
cron_expression TEXT,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
retention_count INTEGER NOT NULL DEFAULT 7 CHECK (retention_count BETWEEN 1 AND 1000),
|
||||
next_run_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE backups (
|
||||
id TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT,
|
||||
operation_id TEXT REFERENCES instance_operations(id) ON DELETE SET NULL,
|
||||
origin TEXT NOT NULL CHECK (origin IN ('manual', 'scheduled', 'pre_update', 'pre_restore', 'idle_shutdown', 'imported', 'system')),
|
||||
status TEXT NOT NULL CHECK (status IN ('creating', 'available', 'failed', 'deleted')),
|
||||
relative_path TEXT,
|
||||
size_bytes INTEGER CHECK (size_bytes IS NULL OR size_bytes >= 0),
|
||||
sha256 TEXT CHECK (sha256 IS NULL OR length(sha256) = 64),
|
||||
manifest_json TEXT,
|
||||
error_code TEXT,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX backups_instance_created_idx ON backups(instance_id, created_at DESC);
|
||||
CREATE INDEX backups_retention_idx ON backups(instance_id, origin, status, created_at);
|
||||
|
||||
CREATE TABLE imports (
|
||||
id TEXT PRIMARY KEY,
|
||||
requested_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE,
|
||||
template_id TEXT NOT NULL,
|
||||
template_version TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('staging', 'validated', 'attached', 'failed', 'expired')),
|
||||
format TEXT NOT NULL,
|
||||
relative_stage_path TEXT NOT NULL,
|
||||
data_root TEXT,
|
||||
detected_type TEXT,
|
||||
confidence TEXT CHECK (confidence IS NULL OR confidence IN ('confirmed', 'probable', 'recognized_unknown_version', 'unrecognized')),
|
||||
file_count INTEGER NOT NULL DEFAULT 0,
|
||||
expanded_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
error_code TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX imports_expiry_idx ON imports(status, expires_at);
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE module_versions (
|
||||
module_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
game_id TEXT NOT NULL,
|
||||
abi TEXT NOT NULL,
|
||||
manifest_json TEXT NOT NULL,
|
||||
wasm_sha256 TEXT NOT NULL CHECK (length(wasm_sha256) = 64),
|
||||
wasm_size_bytes INTEGER NOT NULL CHECK (wasm_size_bytes > 0),
|
||||
source TEXT NOT NULL CHECK (source IN ('bundled', 'uploaded')),
|
||||
installed_at TEXT NOT NULL,
|
||||
PRIMARY KEY (module_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE instance_module_bindings (
|
||||
instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE,
|
||||
module_id TEXT NOT NULL,
|
||||
module_version TEXT NOT NULL,
|
||||
port_id TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
|
||||
configuration_json TEXT NOT NULL DEFAULT '{}',
|
||||
secret_references_json TEXT NOT NULL DEFAULT '{}',
|
||||
last_health TEXT NOT NULL DEFAULT 'unknown' CHECK (last_health IN ('ready', 'degraded', 'offline', 'unknown')),
|
||||
last_error_code TEXT,
|
||||
activated_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (module_id, module_version) REFERENCES module_versions(module_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX instance_module_health_idx ON instance_module_bindings(enabled, last_health);
|
||||
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE instances ADD COLUMN custom_labels_json TEXT NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE instances ADD COLUMN docker_user_mode TEXT NOT NULL DEFAULT 'dogama' CHECK (docker_user_mode IN ('dogama', 'custom', 'image'));
|
||||
ALTER TABLE instances ADD COLUMN docker_uid INTEGER CHECK (docker_uid BETWEEN 0 AND 4294967295);
|
||||
ALTER TABLE instances ADD COLUMN docker_gid INTEGER CHECK (docker_gid BETWEEN 0 AND 4294967295);
|
||||
ALTER TABLE instances ADD COLUMN image_tag_mode TEXT NOT NULL DEFAULT 'tracked' CHECK (image_tag_mode IN ('tracked', 'pinned'));
|
||||
ALTER TABLE instances ADD COLUMN image_tag TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE instances ADD COLUMN container_config_pending INTEGER NOT NULL DEFAULT 0 CHECK (container_config_pending IN (0, 1));
|
||||
|
||||
CREATE TABLE system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE configuration_revisions (
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
redacted_snapshot TEXT NOT NULL,
|
||||
reason TEXT NOT NULL CHECK (length(reason) BETWEEN 1 AND 100),
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (instance_id, revision)
|
||||
);
|
||||
|
||||
CREATE INDEX configuration_revision_history_idx ON configuration_revisions(instance_id, revision DESC);
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TABLE notification_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('email', 'webhook', 'discord')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
encrypted_config BLOB NOT NULL,
|
||||
event_filter_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE notification_deliveries (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_redacted TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'retrying', 'succeeded', 'failed')),
|
||||
attempt INTEGER NOT NULL DEFAULT 0 CHECK (attempt >= 0),
|
||||
next_attempt_at TEXT NOT NULL,
|
||||
last_error_code TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
CREATE INDEX notification_deliveries_due_idx ON notification_deliveries(status, next_attempt_at);
|
||||
|
||||
CREATE TABLE audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
occurred_at TEXT NOT NULL,
|
||||
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor_label TEXT NOT NULL,
|
||||
instance_id TEXT REFERENCES instances(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL CHECK (outcome IN ('allowed', 'denied', 'failed')),
|
||||
summary_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX audit_events_time_idx ON audit_events(occurred_at DESC, id DESC);
|
||||
CREATE INDEX audit_events_filters_idx ON audit_events(actor_id, instance_id, action, outcome);
|
||||
|
||||
INSERT INTO system_settings(key, value_json, revision, updated_at)
|
||||
VALUES ('audit_policy', '{"retention_days":30,"maximum_count":10000}', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
@@ -0,0 +1,17 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright 2026 DoGaMa contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,40 +1,38 @@
|
||||
# Palworld REST reference adapter
|
||||
|
||||
This is the source specification for DoGaMa's reference WebAssembly adapter. It is intentionally a translator only. Container lifecycle and archive handling stay in generic DoGaMa workflows.
|
||||
This package contains DoGaMa's reference WebAssembly translator for the private Palworld REST API. Container lifecycle and archive handling remain in generic DoGaMa workflows.
|
||||
|
||||
## Endpoint mapping
|
||||
|
||||
| Normalized operation | Palworld REST operation |
|
||||
| Normalized operation | Palworld REST endpoint |
|
||||
|---|---|
|
||||
| `test_connection`, `get_server_info` | server info |
|
||||
| `get_server_status` | info plus metrics readiness |
|
||||
| `get_metrics` | metrics |
|
||||
| `list_players` | players |
|
||||
| `save_world` | save |
|
||||
| `shutdown` | shutdown with bounded wait/message |
|
||||
| `send_announcement` | announce |
|
||||
| `kick_player` | kick by stable player ID |
|
||||
| `ban_player` | ban by stable player ID |
|
||||
| `unban_player` | unban by stable player ID |
|
||||
| `test_connection`, `get_server_info`, `get_server_status` | `GET /v1/api/info` |
|
||||
| `get_metrics` | `GET /v1/api/metrics` |
|
||||
| `list_players` | `GET /v1/api/players` |
|
||||
| `save_world` | `POST /v1/api/save` |
|
||||
| `shutdown` | `POST /v1/api/shutdown` |
|
||||
| `send_announcement` | `POST /v1/api/announce` |
|
||||
| `kick_player` | `POST /v1/api/kick` |
|
||||
| `ban_player` | `POST /v1/api/ban` |
|
||||
| `unban_player` | `POST /v1/api/unban` |
|
||||
|
||||
The official API category is [Palworld REST API](https://docs.palworldgame.com/category/rest-api/). Implementation must verify exact current paths, methods and response fields against the pinned game/API version and use fixtures for that version.
|
||||
The mappings follow the [official Palworld REST API](https://docs.palworldgame.com/category/rest-api/).
|
||||
|
||||
## Network and credentials
|
||||
## Isolation
|
||||
|
||||
The host binds logical handle `instance_api` to template port `rest_api`. The module never constructs a host or accepts a URL. It requests only relative `/v1/api/...` paths. Authentication values come from declared configuration and are placed in the request by module logic; diagnostics must never contain the header or password.
|
||||
The host binds the logical `instance_api` destination to the instance container and its private `rest_api` port. The adapter receives no destination URL and can import only restricted WASI plus `dogama_host` functions. The runtime provides no filesystem, environment, process, raw socket, DNS or host clock capability.
|
||||
|
||||
## Save and shutdown semantics
|
||||
Credentials come only from the declared `username` configuration and `admin_password` secret. Responses, requests, host-call count, memory, execution time and concurrency are bounded by the runtime.
|
||||
|
||||
`save_world` returns success only after the API acknowledges the save operation. DoGaMa then archives the `saved` mount. `shutdown` sends the in-game shutdown request; generic lifecycle code observes container exit and asks the restricted agent for a bounded stop only when necessary.
|
||||
## Reproducible build
|
||||
|
||||
## Build status
|
||||
From the repository root, using Go 1.25 or newer:
|
||||
|
||||
No WebAssembly binary is included in this specification baseline. The zero checksum in `manifest.yaml` is a visible placeholder. The implementation task must:
|
||||
|
||||
1. define the WIT/typed bindings for normalized API v1;
|
||||
2. implement and test all declared capabilities;
|
||||
3. compile `module.wasm` without ambient WASI capabilities;
|
||||
4. replace the placeholder with the real SHA-256;
|
||||
5. verify runtime limits and offline/unauthorized/malformed-response cases;
|
||||
6. package the manifest, binary, README and license.
|
||||
```sh
|
||||
CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build \
|
||||
-trimpath -buildmode=c-shared \
|
||||
-o modules/palworld-rest/module.wasm ./modules/palworld-rest/src
|
||||
sha256sum modules/palworld-rest/module.wasm
|
||||
```
|
||||
|
||||
The checksum must exactly match `manifest.yaml`. Repository tests instantiate the real artifact under wazero and exercise it against a bounded fake Palworld transport.
|
||||
|
||||
@@ -58,4 +58,4 @@ configuration:
|
||||
|
||||
artifacts:
|
||||
wasm: module.wasm
|
||||
sha256: "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
sha256: "e4b19ecfe66f4d9fb2c6451b82e6989c6eaf8887ec12cef2e97811527b1ff4ea"
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,294 @@
|
||||
//go:build wasip1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport dogama_host http_request
|
||||
func hostHTTPRequest(requestPtr, requestLen, responsePtr, responseCap uint32) int32
|
||||
|
||||
//go:wasmimport dogama_host get_secret
|
||||
func hostGetSecret(keyPtr, keyLen, valuePtr, valueCap uint32) int32
|
||||
|
||||
//go:wasmimport dogama_host get_config
|
||||
func hostGetConfig(keyPtr, keyLen, valuePtr, valueCap uint32) int32
|
||||
|
||||
var allocations [][]byte
|
||||
|
||||
//go:wasmexport dogama_alloc
|
||||
func dogamaAlloc(size uint32) uint32 {
|
||||
if size == 0 {
|
||||
size = 1
|
||||
}
|
||||
value := make([]byte, size)
|
||||
allocations = append(allocations, value)
|
||||
return uint32(uintptr(unsafe.Pointer(&value[0])))
|
||||
}
|
||||
|
||||
type hostRequest struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Header map[string]string `json:"header,omitempty"`
|
||||
Body []byte `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
type hostResponse struct {
|
||||
Status int `json:"status"`
|
||||
Body []byte `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
type moduleError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Error *moduleError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func bytesAt(ptr, size uint32) []byte {
|
||||
if size == 0 {
|
||||
return nil
|
||||
}
|
||||
return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), size)
|
||||
}
|
||||
|
||||
func output(outPtr, outCap uint32, value any) int32 {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil || len(encoded) > int(outCap) {
|
||||
return -1
|
||||
}
|
||||
copy(bytesAt(outPtr, uint32(len(encoded))), encoded)
|
||||
return int32(len(encoded))
|
||||
}
|
||||
|
||||
func boundValue(secret bool, key string) (string, bool) {
|
||||
keyBytes := []byte(key)
|
||||
buffer := make([]byte, 4096)
|
||||
var size int32
|
||||
if secret {
|
||||
size = hostGetSecret(uint32(uintptr(unsafe.Pointer(&keyBytes[0]))), uint32(len(keyBytes)), uint32(uintptr(unsafe.Pointer(&buffer[0]))), uint32(len(buffer)))
|
||||
} else {
|
||||
size = hostGetConfig(uint32(uintptr(unsafe.Pointer(&keyBytes[0]))), uint32(len(keyBytes)), uint32(uintptr(unsafe.Pointer(&buffer[0]))), uint32(len(buffer)))
|
||||
}
|
||||
if size < 0 {
|
||||
return "", false
|
||||
}
|
||||
return string(buffer[:size]), true
|
||||
}
|
||||
|
||||
func call(method, endpoint string, body any, result any) *moduleError {
|
||||
username, usernameOK := boundValue(false, "username")
|
||||
password, passwordOK := boundValue(true, "admin_password")
|
||||
if !usernameOK || !passwordOK || username == "" || password == "" {
|
||||
return &moduleError{Code: "invalid_configuration", Message: "Palworld REST credentials are incomplete."}
|
||||
}
|
||||
var encodedBody []byte
|
||||
if body != nil {
|
||||
encodedBody, _ = json.Marshal(body)
|
||||
}
|
||||
request := hostRequest{Method: method, Path: "/v1/api/" + endpoint, Header: map[string]string{"Accept": "application/json", "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))}, Body: encodedBody}
|
||||
if body != nil {
|
||||
request.Header["Content-Type"] = "application/json"
|
||||
}
|
||||
encoded, _ := json.Marshal(request)
|
||||
responseBuffer := make([]byte, 1<<20)
|
||||
size := hostHTTPRequest(uint32(uintptr(unsafe.Pointer(&encoded[0]))), uint32(len(encoded)), uint32(uintptr(unsafe.Pointer(&responseBuffer[0]))), uint32(len(responseBuffer)))
|
||||
if size < 0 {
|
||||
return &moduleError{Code: "unreachable", Message: "The Palworld REST API could not be reached.", Retryable: true}
|
||||
}
|
||||
var response hostResponse
|
||||
if json.Unmarshal(responseBuffer[:size], &response) != nil {
|
||||
return &moduleError{Code: "invalid_response", Message: "Palworld returned an invalid response."}
|
||||
}
|
||||
if response.Status == 401 {
|
||||
return &moduleError{Code: "unauthorized", Message: "Palworld rejected the configured credentials."}
|
||||
}
|
||||
if response.Status < 200 || response.Status >= 300 {
|
||||
return &moduleError{Code: "game_error", Message: "Palworld rejected the operation.", Retryable: response.Status >= 500}
|
||||
}
|
||||
if result != nil && len(response.Body) > 0 && json.Unmarshal(response.Body, result) != nil {
|
||||
return &moduleError{Code: "invalid_response", Message: "Palworld returned an invalid response."}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeResult(outPtr, outCap uint32, data any, failure *moduleError) int32 {
|
||||
return output(outPtr, outCap, envelope{OK: failure == nil, Data: data, Error: failure})
|
||||
}
|
||||
|
||||
var capabilities = []string{"server_info", "metrics", "player_list", "online_save", "graceful_shutdown", "announcement", "kick", "ban", "unban"}
|
||||
|
||||
//go:wasmexport initialize
|
||||
func initialize(_, _ uint32, outPtr, outCap uint32) int32 {
|
||||
username, usernameOK := boundValue(false, "username")
|
||||
_, passwordOK := boundValue(true, "admin_password")
|
||||
if !usernameOK || !passwordOK || username == "" {
|
||||
return writeResult(outPtr, outCap, nil, &moduleError{Code: "invalid_configuration", Message: "Palworld REST credentials are incomplete."})
|
||||
}
|
||||
return writeResult(outPtr, outCap, map[string]any{"module_id": "palworld-rest", "module_version": "1.0.0", "api_version": "1.0.0", "capabilities": capabilities}, nil)
|
||||
}
|
||||
|
||||
type serverInfo struct {
|
||||
Version string `json:"version"`
|
||||
ServerName string `json:"servername"`
|
||||
Description string `json:"description"`
|
||||
WorldGUID string `json:"worldguid"`
|
||||
}
|
||||
|
||||
//go:wasmexport test_connection
|
||||
func testConnection(_, _ uint32, outPtr, outCap uint32) int32 {
|
||||
var info serverInfo
|
||||
failure := call("GET", "info", nil, &info)
|
||||
return writeResult(outPtr, outCap, map[string]any{"connected": failure == nil, "game_version": info.Version}, failure)
|
||||
}
|
||||
|
||||
//go:wasmexport get_server_status
|
||||
func getServerStatus(_, _ uint32, outPtr, outCap uint32) int32 {
|
||||
var info serverInfo
|
||||
failure := call("GET", "info", nil, &info)
|
||||
status := "ready"
|
||||
if failure != nil {
|
||||
status = "offline"
|
||||
}
|
||||
return writeResult(outPtr, outCap, map[string]any{"status": status}, failure)
|
||||
}
|
||||
|
||||
//go:wasmexport get_server_info
|
||||
func getServerInfo(_, _ uint32, outPtr, outCap uint32) int32 {
|
||||
var info serverInfo
|
||||
failure := call("GET", "info", nil, &info)
|
||||
data := map[string]any{"name": info.ServerName, "game_version": info.Version, "description": info.Description, "world_id": info.WorldGUID}
|
||||
return writeResult(outPtr, outCap, data, failure)
|
||||
}
|
||||
|
||||
type palMetrics struct {
|
||||
ServerFPS int `json:"serverfps"`
|
||||
CurrentPlayers int `json:"currentplayernum"`
|
||||
ServerFrameTime float64 `json:"serverframetime"`
|
||||
MaxPlayers int `json:"maxplayernum"`
|
||||
UptimeSeconds int64 `json:"uptime"`
|
||||
BaseCampCount int `json:"basecampnum"`
|
||||
InGameDay int `json:"days"`
|
||||
}
|
||||
|
||||
//go:wasmexport get_metrics
|
||||
func getMetrics(_, _ uint32, outPtr, outCap uint32) int32 {
|
||||
var metrics palMetrics
|
||||
failure := call("GET", "metrics", nil, &metrics)
|
||||
return writeResult(outPtr, outCap, map[string]any{"server_fps": metrics.ServerFPS, "current_players": metrics.CurrentPlayers, "frame_time_ms": metrics.ServerFrameTime, "max_players": metrics.MaxPlayers, "uptime_seconds": metrics.UptimeSeconds, "base_camps": metrics.BaseCampCount, "game_day": metrics.InGameDay}, failure)
|
||||
}
|
||||
|
||||
type palPlayer struct {
|
||||
Name string `json:"name"`
|
||||
PlayerID string `json:"playerId"`
|
||||
UserID string `json:"userId"`
|
||||
Ping float64 `json:"ping"`
|
||||
}
|
||||
|
||||
//go:wasmexport list_players
|
||||
func listPlayers(_, _ uint32, outPtr, outCap uint32) int32 {
|
||||
var response struct {
|
||||
Players []palPlayer `json:"players"`
|
||||
}
|
||||
failure := call("GET", "players", nil, &response)
|
||||
players := make([]map[string]any, 0, len(response.Players))
|
||||
for _, player := range response.Players {
|
||||
players = append(players, map[string]any{"player_id": player.PlayerID, "user_id": player.UserID, "display_name": player.Name, "ping_ms": player.Ping})
|
||||
}
|
||||
return writeResult(outPtr, outCap, map[string]any{"players": players}, failure)
|
||||
}
|
||||
|
||||
//go:wasmexport save_world
|
||||
func saveWorld(_, _ uint32, outPtr, outCap uint32) int32 {
|
||||
failure := call("POST", "save", nil, nil)
|
||||
return writeResult(outPtr, outCap, map[string]any{"completed": failure == nil}, failure)
|
||||
}
|
||||
|
||||
func action(outPtr, outCap uint32, endpoint string, body any) int32 {
|
||||
failure := call("POST", endpoint, body, nil)
|
||||
return writeResult(outPtr, outCap, map[string]any{"accepted": failure == nil}, failure)
|
||||
}
|
||||
|
||||
func decodeRequest(inPtr, inLen uint32, value any) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(bytesAt(inPtr, inLen)))
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(value) == nil
|
||||
}
|
||||
|
||||
func invalidAction(outPtr, outCap uint32) int32 {
|
||||
return writeResult(outPtr, outCap, nil, &moduleError{Code: "invalid_configuration", Message: "The operation request is invalid."})
|
||||
}
|
||||
|
||||
type shutdownRequest struct {
|
||||
WaitSeconds int `json:"wait_seconds"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type messageRequest struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type playerActionRequest struct {
|
||||
PlayerID string `json:"player_id"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type unbanRequest struct {
|
||||
PlayerID string `json:"player_id"`
|
||||
}
|
||||
|
||||
//go:wasmexport shutdown
|
||||
func shutdown(inPtr, inLen, outPtr, outCap uint32) int32 {
|
||||
var request shutdownRequest
|
||||
if !decodeRequest(inPtr, inLen, &request) || request.WaitSeconds < 0 || request.WaitSeconds > 900 {
|
||||
return invalidAction(outPtr, outCap)
|
||||
}
|
||||
return action(outPtr, outCap, "shutdown", map[string]any{"waittime": request.WaitSeconds, "message": request.Message})
|
||||
}
|
||||
|
||||
//go:wasmexport send_announcement
|
||||
func sendAnnouncement(inPtr, inLen, outPtr, outCap uint32) int32 {
|
||||
var request messageRequest
|
||||
if !decodeRequest(inPtr, inLen, &request) || request.Message == "" || len(request.Message) > 1000 {
|
||||
return invalidAction(outPtr, outCap)
|
||||
}
|
||||
return action(outPtr, outCap, "announce", request)
|
||||
}
|
||||
|
||||
func playerAction(inPtr, inLen, outPtr, outCap uint32, endpoint string) int32 {
|
||||
var request playerActionRequest
|
||||
if !decodeRequest(inPtr, inLen, &request) || request.PlayerID == "" || len(request.PlayerID) > 256 || len(request.Reason) > 1000 {
|
||||
return invalidAction(outPtr, outCap)
|
||||
}
|
||||
return action(outPtr, outCap, endpoint, map[string]any{"userid": request.PlayerID, "message": request.Reason})
|
||||
}
|
||||
|
||||
//go:wasmexport kick_player
|
||||
func kickPlayer(inPtr, inLen, outPtr, outCap uint32) int32 {
|
||||
return playerAction(inPtr, inLen, outPtr, outCap, "kick")
|
||||
}
|
||||
|
||||
//go:wasmexport ban_player
|
||||
func banPlayer(inPtr, inLen, outPtr, outCap uint32) int32 {
|
||||
return playerAction(inPtr, inLen, outPtr, outCap, "ban")
|
||||
}
|
||||
|
||||
//go:wasmexport unban_player
|
||||
func unbanPlayer(inPtr, inLen, outPtr, outCap uint32) int32 {
|
||||
var request unbanRequest
|
||||
if !decodeRequest(inPtr, inLen, &request) || request.PlayerID == "" || len(request.PlayerID) > 256 {
|
||||
return invalidAction(outPtr, outCap)
|
||||
}
|
||||
return action(outPtr, outCap, "unban", map[string]any{"userid": request.PlayerID})
|
||||
}
|
||||
|
||||
func main() {}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package main
|
||||
|
||||
// The adapter is built only for GOOS=wasip1. This stub keeps repository-wide
|
||||
// host-platform validation deterministic without pretending to run the guest.
|
||||
func main() {}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Package specs exposes the checked-in machine-readable contracts.
|
||||
package specs
|
||||
|
||||
import "embed"
|
||||
|
||||
// Files contains the versioned JSON Schemas used at runtime.
|
||||
//
|
||||
//go:embed *.schema.json
|
||||
var Files embed.FS
|
||||
Reference in New Issue
Block a user