panel public release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAllManifestsLoadAndEventRegexesCompile is the repo-wide consistency
|
||||
// gate for module manifests: every modules/<id>/module.yaml must LoadDir
|
||||
// cleanly, and every declared events regex must compile under Go's RE2
|
||||
// engine (AMPTemplates uses .NET (?<n>) syntax — these must have been
|
||||
// converted to (?P<n>)) and carry at least one named capture group the
|
||||
// agent's tracker actually reads (see agent/internal/state/tracker.go
|
||||
// OnLogLine: name/player_name, id-family, msg/detail/reason).
|
||||
func TestAllManifestsLoadAndEventRegexesCompile(t *testing.T) {
|
||||
dir := filepath.Join("..", "..", "modules")
|
||||
manifests, err := LoadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDir(%s): %v", dir, err)
|
||||
}
|
||||
if len(manifests) < 20 {
|
||||
t.Fatalf("expected >=20 manifests, got %d — wrong dir?", len(manifests))
|
||||
}
|
||||
|
||||
// Group names the tracker extracts (tracker.go firstGroup calls).
|
||||
known := map[string]bool{
|
||||
"name": true, "player_name": true,
|
||||
"platform_id": true, "owner": true, "cross_id": true, "id": true, "player_id": true,
|
||||
"msg": true, "detail": true, "reason": true,
|
||||
}
|
||||
validKinds := map[string]bool{"join": true, "leave": true, "chat": true, "death": true, "custom": true}
|
||||
|
||||
for _, m := range manifests {
|
||||
m := m
|
||||
t.Run(m.ID, func(t *testing.T) {
|
||||
for evName, ev := range m.Events {
|
||||
re, err := regexp.Compile(ev.Pattern)
|
||||
if err != nil {
|
||||
t.Errorf("event %q: pattern does not compile: %v", evName, err)
|
||||
continue
|
||||
}
|
||||
if !validKinds[ev.Kind] {
|
||||
t.Errorf("event %q: unknown kind %q", evName, ev.Kind)
|
||||
}
|
||||
// Every event must expose at least one group the tracker
|
||||
// knows how to read, otherwise the event emits empty
|
||||
// PlayerName/PlayerId/Detail and is a silently-useless knob.
|
||||
got := false
|
||||
for _, g := range re.SubexpNames() {
|
||||
if known[g] {
|
||||
got = true
|
||||
}
|
||||
}
|
||||
if !got {
|
||||
t.Errorf("event %q: no tracker-known named group (want one of name/id/msg families); groups=%v", evName, re.SubexpNames())
|
||||
}
|
||||
// join/leave should identify the player by name or id.
|
||||
if ev.Kind == "join" || ev.Kind == "leave" {
|
||||
hasIdent := false
|
||||
for _, g := range re.SubexpNames() {
|
||||
if g == "name" || g == "player_name" || g == "id" || g == "platform_id" || g == "player_id" || g == "owner" || g == "cross_id" {
|
||||
hasIdent = true
|
||||
}
|
||||
}
|
||||
if !hasIdent {
|
||||
t.Errorf("event %q (kind %s): no player-identifying named group", evName, ev.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// LoadFile parses a single module.yaml at path.
|
||||
func LoadFile(path string) (*Manifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
var m Manifest
|
||||
dec := yaml.NewDecoder(bytes.NewReader(data))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
if err := m.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate %s: %w", path, err)
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abs %s: %w", path, err)
|
||||
}
|
||||
m.Path = abs
|
||||
m.Dir = filepath.Dir(abs)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// LoadDir scans dir for subdirectories containing a module.yaml and loads each.
|
||||
// Subdirectories without module.yaml are skipped silently.
|
||||
func LoadDir(dir string) ([]*Manifest, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read dir %s: %w", dir, err)
|
||||
}
|
||||
var out []*Manifest
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(dir, e.Name(), "module.yaml")
|
||||
info, err := os.Stat(p)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat %s: %w", p, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
continue
|
||||
}
|
||||
m, err := LoadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Validate enforces required fields and basic structural invariants.
|
||||
// It does NOT validate configs against JSON schemas or regex compilability
|
||||
// (callers do that after load, since it's policy-specific).
|
||||
func (m *Manifest) Validate() error {
|
||||
if m.ID == "" {
|
||||
return errors.New("id is required")
|
||||
}
|
||||
if m.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
if m.Version == "" {
|
||||
return errors.New("version is required")
|
||||
}
|
||||
if len(m.SupportedModes) == 0 {
|
||||
return errors.New("supported_modes must list at least one mode")
|
||||
}
|
||||
for _, mode := range m.SupportedModes {
|
||||
switch mode {
|
||||
case "docker":
|
||||
case "host":
|
||||
// Declared in the manifest schema but the agent has no host
|
||||
// runtime — accepting it here would advertise a capability
|
||||
// that silently doesn't work. Reject loudly until host mode
|
||||
// is actually implemented.
|
||||
return fmt.Errorf("run mode %q is not implemented (only %q is supported); remove it from supported_modes", mode, "docker")
|
||||
default:
|
||||
return fmt.Errorf("unknown run mode %q (valid: docker)", mode)
|
||||
}
|
||||
}
|
||||
if m.HasMode("docker") && (m.Runtime.Docker == nil || m.Runtime.Docker.Image == "") {
|
||||
return errors.New("supported_modes includes docker but runtime.docker.image is missing")
|
||||
}
|
||||
if m.HasMode("host") && m.Runtime.Host == nil {
|
||||
return errors.New("supported_modes includes host but runtime.host is missing")
|
||||
}
|
||||
if len(m.Ports) == 0 {
|
||||
return errors.New("at least one port must be declared")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, p := range m.Ports {
|
||||
if p.Name == "" {
|
||||
return errors.New("port.name is required")
|
||||
}
|
||||
if seen[p.Name] {
|
||||
return fmt.Errorf("duplicate port name %q", p.Name)
|
||||
}
|
||||
seen[p.Name] = true
|
||||
switch p.Proto {
|
||||
case "tcp", "udp":
|
||||
default:
|
||||
return fmt.Errorf("port %q: proto must be tcp or udp (got %q)", p.Name, p.Proto)
|
||||
}
|
||||
if p.Default == 0 {
|
||||
return fmt.Errorf("port %q: default is required", p.Name)
|
||||
}
|
||||
}
|
||||
if m.RCON != nil {
|
||||
if m.RCON.Adapter == "" {
|
||||
return errors.New("rcon.adapter is required when rcon is set")
|
||||
}
|
||||
if m.RCON.HostPort != "" && m.Port(m.RCON.HostPort) == nil {
|
||||
return fmt.Errorf("rcon.host_port %q does not match any declared port", m.RCON.HostPort)
|
||||
}
|
||||
}
|
||||
if len(m.UpdateProviders) == 0 {
|
||||
return errors.New("at least one update_provider is required")
|
||||
}
|
||||
providerIDs := map[string]bool{}
|
||||
for _, p := range m.UpdateProviders {
|
||||
if p.ID == "" {
|
||||
return errors.New("update_provider.id is required")
|
||||
}
|
||||
if providerIDs[p.ID] {
|
||||
return fmt.Errorf("duplicate update_provider id %q", p.ID)
|
||||
}
|
||||
providerIDs[p.ID] = true
|
||||
switch p.Kind {
|
||||
case "steamcmd":
|
||||
if p.AppID == "" {
|
||||
return fmt.Errorf("update_provider %q: app_id required for steamcmd kind", p.ID)
|
||||
}
|
||||
case "github":
|
||||
if p.Repo == "" {
|
||||
return fmt.Errorf("update_provider %q: repo required for github kind", p.ID)
|
||||
}
|
||||
case "direct":
|
||||
if p.URL == "" {
|
||||
return fmt.Errorf("update_provider %q: url required for direct kind", p.ID)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("update_provider %q: unknown kind %q", p.ID, p.Kind)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
// Package module defines the on-disk manifest format (module.yaml) that
|
||||
// declares a game plugin: how to install it, how to run it, what ports it
|
||||
// exposes, what RCON dialect it speaks, and what patterns identify state
|
||||
// and player events in its logs.
|
||||
//
|
||||
// Both Controller and Target load manifests from a local modules/ directory.
|
||||
// The Controller owns authoring/UI form generation; the Target uses the
|
||||
// manifest at runtime to shape container launch and state-tracking pipelines.
|
||||
package module
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Manifest is the root structure parsed from a module.yaml file.
|
||||
type Manifest struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
Authors []string `yaml:"authors,omitempty"`
|
||||
SupportedModes []string `yaml:"supported_modes"`
|
||||
Runtime Runtime `yaml:"runtime"`
|
||||
Ports []Port `yaml:"ports"`
|
||||
Resources Resources `yaml:"resources,omitempty"`
|
||||
ConfigFiles []ConfigFile `yaml:"config_files,omitempty"`
|
||||
RCON *RCONConfig `yaml:"rcon,omitempty"`
|
||||
StateSources []StateSource `yaml:"state_sources,omitempty"`
|
||||
Events map[string]Event `yaml:"events,omitempty"`
|
||||
UpdateProviders []UpdateProvider `yaml:"update_providers"`
|
||||
Secrets []Secret `yaml:"secrets,omitempty"`
|
||||
Lifecycle *Lifecycle `yaml:"lifecycle,omitempty"`
|
||||
// AutoUpdateOnCreate — run the first update_provider automatically
|
||||
// after a successful create, so the operator doesn't have to click
|
||||
// Update as a separate step. Default is `nil` which the loader
|
||||
// interprets as true for modules that declare a real provider. Set
|
||||
// explicitly to `false` for modules whose image handles its own
|
||||
// downloading at runtime (e.g. the ark-sa community image).
|
||||
AutoUpdateOnCreate *bool `yaml:"auto_update_on_create,omitempty"`
|
||||
// AutoStartOnCreate — after a successful create (and first-time update,
|
||||
// if one ran), boot the server automatically. Default nil = true for any
|
||||
// module. Opt out with `auto_start_on_create: false` for modules whose
|
||||
// first boot needs operator attention (e.g. filling in secrets first).
|
||||
// Independent of AutoUpdateOnCreate: images like ark-sa/ASA skip the
|
||||
// external updater but still need to auto-start so their in-container
|
||||
// entrypoint can run the bundled SteamCMD + launch the game in one go.
|
||||
AutoStartOnCreate *bool `yaml:"auto_start_on_create,omitempty"`
|
||||
// Backup declares which paths the panel should archive when an
|
||||
// operator clicks Back-up-now. Without this the agent tars the
|
||||
// whole BrowseableRoot — fine for tiny games, catastrophic for ARK
|
||||
// SA where that's a 18 GB SteamCMD install plus 200 MB of saves.
|
||||
// Paths are container-relative (resolved against BrowseableRoot)
|
||||
// and use shell-style globs. Saves-only is the goal.
|
||||
Backup *BackupSpec `yaml:"backup,omitempty"`
|
||||
|
||||
// ConfigValues is the on-disk *declaration* of operator-tunable knobs.
|
||||
// The dashboard hardcodes its config-form schemas separately (see
|
||||
// static/index.html `configSchemas`), so this field is not consumed by
|
||||
// the loader yet — it's accepted only to keep the strict YAML decoder
|
||||
// happy when modules document their tunables in the manifest. Future
|
||||
// work: drive the dashboard form generator off this field directly so
|
||||
// the schema lives in one place.
|
||||
ConfigValues []map[string]any `yaml:"config_values,omitempty"`
|
||||
|
||||
// ReadyPattern is the log regex that marks the server as actually
|
||||
// joinable (world loaded, accepting connections) — CONTROLLER/UI
|
||||
// metadata only; the agent never compiles it. The string is a
|
||||
// JavaScript regex: either a bare source ("Done \\(...\\)! For help")
|
||||
// or slash-delimited with flags ("/StartGame done/i"). It may use JS
|
||||
// constructs Go's regexp lacks (lookahead) — do not compile in Go.
|
||||
// Source of truth moved here from the dashboard's READY_PATTERNS map.
|
||||
ReadyPattern string `yaml:"ready_pattern,omitempty"`
|
||||
// Appearance carries the dashboard's visual identity for this module
|
||||
// (emoji, tile gradient, packaged art, Steam appid, optional display
|
||||
// name/tagline/accent). CONTROLLER/UI metadata only. Source of truth
|
||||
// moved here from the dashboard's moduleAppearance map.
|
||||
Appearance *Appearance `yaml:"appearance,omitempty"`
|
||||
|
||||
// Path is the absolute path to module.yaml, set by the loader.
|
||||
Path string `yaml:"-"`
|
||||
// Dir is the directory containing the manifest; template/schema paths
|
||||
// in this manifest are resolved relative to Dir.
|
||||
Dir string `yaml:"-"`
|
||||
}
|
||||
|
||||
// Runtime declares how the module launches under each supported mode.
|
||||
type Runtime struct {
|
||||
Docker *DockerSpec `yaml:"docker,omitempty"`
|
||||
Host *HostSpec `yaml:"host,omitempty"`
|
||||
}
|
||||
|
||||
// DockerSpec is the Docker-mode launch recipe.
|
||||
type DockerSpec struct {
|
||||
Image string `yaml:"image"`
|
||||
Volumes []Volume `yaml:"volumes,omitempty"`
|
||||
Entrypoint []string `yaml:"entrypoint,omitempty"`
|
||||
Command []string `yaml:"command,omitempty"`
|
||||
Env map[string]string `yaml:"env,omitempty"`
|
||||
User string `yaml:"user,omitempty"`
|
||||
// BrowseableRoot is the container-absolute path the file manager
|
||||
// treats as "root" when operating on this container (e.g. "/data").
|
||||
// If empty, the resolver defaults to the first volume's container
|
||||
// path. If neither is set, the file manager falls back to the
|
||||
// host-side data_path bind-mount root.
|
||||
BrowseableRoot string `yaml:"browseable_root,omitempty"`
|
||||
// BrowseableRoots lets a module expose multiple independent "views"
|
||||
// to the file manager — e.g. 7DTD's /game-saves (worlds, configs)
|
||||
// AND /game (mods, plugins, binaries). Each entry becomes a picker
|
||||
// option in the Files tab. First entry is the default view.
|
||||
// Empty → fall back to BrowseableRoot / first volume.
|
||||
BrowseableRoots []BrowseableRoot `yaml:"browseable_roots,omitempty"`
|
||||
// NetworkMode is the Docker network mode. Empty → default bridge with
|
||||
// published ports. Use "host" for modules that rely on long-lived UDP
|
||||
// sessions to external services (e.g. Windrose's P2P coop gateway, where
|
||||
// Docker Desktop's UDP NAT rebinds break TURN consent checks). In host
|
||||
// mode the Ports block is informational only — the container listens on
|
||||
// the host's interfaces directly; the default bind-mount NAT is skipped.
|
||||
NetworkMode string `yaml:"network_mode,omitempty"`
|
||||
}
|
||||
|
||||
// BrowseableRoot names one container-absolute directory for the file
|
||||
// manager to surface in its roots picker. JSON tags match what the
|
||||
// dashboard's Files-tab picker consumes via GET /api/modules.
|
||||
type BrowseableRoot struct {
|
||||
Name string `yaml:"name" json:"name"` // human label — shown in the UI
|
||||
Path string `yaml:"path" json:"path"` // container-absolute path
|
||||
Hint string `yaml:"hint,omitempty" json:"hint,omitempty"` // optional sub-line shown in the picker
|
||||
}
|
||||
|
||||
// Appearance is UI-only visual metadata for a module. Every field is
|
||||
// optional; the dashboard falls back to its built-in defaults for any
|
||||
// missing field. JSON keys mirror the dashboard's moduleAppearance map
|
||||
// entries (emoji/grad/art/steam) so the payload drops in unchanged.
|
||||
type Appearance struct {
|
||||
// DisplayName overrides Manifest.Name in UI card headers when set.
|
||||
DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"`
|
||||
// Tagline is an optional one-line blurb shown under the name.
|
||||
Tagline string `yaml:"tagline,omitempty" json:"tagline,omitempty"`
|
||||
// Accent is an optional CSS color for module-tinted UI chrome.
|
||||
Accent string `yaml:"accent,omitempty" json:"accent,omitempty"`
|
||||
// Emoji shown when no art is available.
|
||||
Emoji string `yaml:"emoji,omitempty" json:"emoji,omitempty"`
|
||||
// Grad is the CSS gradient behind the tile / while art loads.
|
||||
Grad string `yaml:"grad,omitempty" json:"grad,omitempty"`
|
||||
// Art is the panel-hosted art path, e.g. "/game-art/7dtd.jpg".
|
||||
Art string `yaml:"art,omitempty" json:"art,omitempty"`
|
||||
// Steam is the Steam appid used for CDN header-art fallback.
|
||||
Steam string `yaml:"steam,omitempty" json:"steam,omitempty"`
|
||||
}
|
||||
|
||||
// HostSpec is the host-mode launch recipe.
|
||||
type HostSpec struct {
|
||||
ExecutableLinux string `yaml:"executable_linux,omitempty"`
|
||||
ExecutableWindows string `yaml:"executable_windows,omitempty"`
|
||||
Args []string `yaml:"args,omitempty"`
|
||||
Env map[string]string `yaml:"env,omitempty"`
|
||||
WorkingDir string `yaml:"working_dir,omitempty"`
|
||||
}
|
||||
|
||||
// Volume declares a mount point on the instance's container.
|
||||
//
|
||||
// Two kinds are supported:
|
||||
//
|
||||
// type: bind — Target is the host path (may contain $DATA_PATH).
|
||||
// Useful for configs + logs operators want to read/edit.
|
||||
// type: volume — Name is a Docker named volume (may contain $INSTANCE_ID).
|
||||
// Lives inside the WSL2/container-storage filesystem;
|
||||
// dramatically faster than Windows bind-mounts for
|
||||
// high-I/O data like game installs.
|
||||
//
|
||||
// The agent's docker runtime auto-creates named volumes on first use.
|
||||
type Volume struct {
|
||||
Type string `yaml:"type,omitempty"` // "bind" (default if Target set) or "volume"
|
||||
Target string `yaml:"target,omitempty"` // host path for bind (may contain $DATA_PATH)
|
||||
Name string `yaml:"name,omitempty"` // volume name for named volumes (may contain $INSTANCE_ID)
|
||||
Container string `yaml:"container"`
|
||||
ReadOnly bool `yaml:"read_only,omitempty"`
|
||||
}
|
||||
|
||||
// Port is a declared network port.
|
||||
type Port struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Proto string `yaml:"proto" json:"proto"` // "tcp" or "udp"
|
||||
Default uint32 `yaml:"default" json:"default"` // default port number
|
||||
Required bool `yaml:"required,omitempty" json:"required,omitempty"`
|
||||
Internal bool `yaml:"internal,omitempty" json:"internal,omitempty"` // if true, not exposed outside the Target
|
||||
// Env, when set, names the container env var the agent should
|
||||
// populate with this port's allocated HOST port at create time.
|
||||
// Lets the image's entrypoint translate panel-allocated ports
|
||||
// into game launch args (e.g. ARK's ASA_PORT/QUERY_PORT/RCON_PORT
|
||||
// → ?Port=N ?QueryPort=N ?RCONPort=N). Without this, two ARK
|
||||
// instances all advertise the manifest defaults to Steam and only
|
||||
// one of them is reachable via the server browser.
|
||||
Env string `yaml:"env,omitempty" json:"env,omitempty"`
|
||||
}
|
||||
|
||||
// Resources declares RAM / CPU guidance for the UI and default limits.
|
||||
type Resources struct {
|
||||
MinRAMMB uint32 `yaml:"min_ram_mb,omitempty"`
|
||||
RecommendedRAMMB uint32 `yaml:"recommended_ram_mb,omitempty"`
|
||||
MinCPUShares uint32 `yaml:"min_cpu_shares,omitempty"`
|
||||
}
|
||||
|
||||
// BackupSpec narrows what gets included when a backup runs.
|
||||
//
|
||||
// Paths are interpreted relative to BrowseableRoot (the same path the
|
||||
// File Manager treats as "the instance root"). Both Include and Exclude
|
||||
// support tar-style patterns (e.g. "ShooterGame/Saved/SavedArks").
|
||||
//
|
||||
// Without a BackupSpec, the agent falls back to "tar the entire
|
||||
// BrowseableRoot" — preserves backwards compat for modules that
|
||||
// haven't been profiled yet but is wasteful for any game with bulky
|
||||
// install dirs (ARK SA, Empyrion, Conan). Each module's manifest
|
||||
// should ship one of these as soon as someone has time to verify
|
||||
// which paths actually need saving.
|
||||
//
|
||||
// Notes label is shown in the UI alongside backups, e.g. "Saves +
|
||||
// configs (~250 MB typical)".
|
||||
type BackupSpec struct {
|
||||
// Root is the absolute in-container path to cd into before tarring.
|
||||
// Defaults to the runtime's BrowseableRoot when empty. Set this when
|
||||
// the module's save data lives in a different volume than the file
|
||||
// browser default (7DTD: file browser shows /game by default, but
|
||||
// saves live in /game-saves).
|
||||
Root string `yaml:"root,omitempty"`
|
||||
Include []string `yaml:"include,omitempty"`
|
||||
Exclude []string `yaml:"exclude,omitempty"`
|
||||
Notes string `yaml:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// ConfigFile declares a config file the panel manages for the instance.
|
||||
type ConfigFile struct {
|
||||
Path string `yaml:"path"` // relative to $DATA_PATH
|
||||
Format string `yaml:"format"` // xml, properties, yaml, ini, json, toml, kv
|
||||
Schema string `yaml:"schema,omitempty"` // relative path to JSON-schema file in the module dir
|
||||
Template string `yaml:"template,omitempty"`
|
||||
// TemplateByBranch overrides Template for instances installed from a
|
||||
// specific Steam branch (the normalized branch from resolveCreateBranch,
|
||||
// e.g. "" for public/2.6 default, "latest_experimental" / "v3.0" for
|
||||
// 3.0). Keyed by the normalized branch string; an exact match wins,
|
||||
// otherwise Template (the default) is used. This is how 7DTD selects
|
||||
// serverconfig.v3.xml.tmpl for a 3.0 server while ≤2.6 servers keep the
|
||||
// untouched serverconfig.xml.tmpl. Kept data-driven so adding a future
|
||||
// version is a manifest edit, not a code change. See
|
||||
// panel/memory/7dtd-v3-version-and-sandboxcode-plan.md (Workstream D).
|
||||
TemplateByBranch map[string]string `yaml:"template_by_branch,omitempty"`
|
||||
}
|
||||
|
||||
// TemplateForBranch returns the template path to render for this config file
|
||||
// on the given normalized branch — a branch-specific override if one is
|
||||
// declared, else the default Template. An empty result means "no template"
|
||||
// (the file is operator-managed and Render skips it).
|
||||
func (cf ConfigFile) TemplateForBranch(branch string) string {
|
||||
if cf.TemplateByBranch != nil {
|
||||
if t, ok := cf.TemplateByBranch[branch]; ok {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return cf.Template
|
||||
}
|
||||
|
||||
// RCONConfig declares the instance's RCON adapter.
|
||||
type RCONConfig struct {
|
||||
Adapter string `yaml:"adapter"` // source_rcon, telnet, http, stdin_pipe, custom_wasm
|
||||
HostPort string `yaml:"host_port"` // port name from Ports
|
||||
Auth string `yaml:"auth,omitempty"` // password_prompt, source_rcon_login, bearer, none
|
||||
PasswordSecret string `yaml:"password_secret,omitempty"` // name from Secrets to use as RCON password
|
||||
PasswordLiteral string `yaml:"password_literal,omitempty"` // literal password (useful when the module's env pins one — lower priority than PasswordSecret / config_values)
|
||||
// PasswordFromFile reads the password at dial time from a path *inside
|
||||
// the container*. Lowest priority — used as a fallback when the module's
|
||||
// entrypoint generates its own RCON secret on first boot (e.g. 7DTD's
|
||||
// TelnetPassword, which we auto-populate to force 0.0.0.0 binding and
|
||||
// stash in /game-saves/.panel-telnet-password).
|
||||
PasswordFromFile string `yaml:"password_from_file,omitempty"`
|
||||
// PasswordFromINI extracts the password from a specific key in a
|
||||
// specific section of an INI file inside the container. Useful for
|
||||
// modules whose config file holds the RCON password alongside other
|
||||
// settings (ARK: SA stores it as
|
||||
// [ServerSettings]ServerAdminPassword=... in GameUserSettings.ini).
|
||||
// Read fresh on each dial so changes the operator makes via the Config
|
||||
// tab take effect as soon as the server picks them up on restart.
|
||||
PasswordFromINI *PasswordFromINI `yaml:"password_from_ini,omitempty"`
|
||||
Commands map[string]string `yaml:"commands,omitempty"` // named command templates
|
||||
}
|
||||
|
||||
// PasswordFromINI locates a single key/value in an INI file inside the
|
||||
// container. Section is optional — when empty we match the key anywhere.
|
||||
type PasswordFromINI struct {
|
||||
File string `yaml:"file"`
|
||||
Section string `yaml:"section,omitempty"`
|
||||
Key string `yaml:"key"`
|
||||
}
|
||||
|
||||
// StateSource is a state-feed declaration — either an RCON poll or a log tail.
|
||||
type StateSource struct {
|
||||
Type string `yaml:"type"` // rcon, log_tail, http_probe
|
||||
Command string `yaml:"command,omitempty"`
|
||||
Every Duration `yaml:"every,omitempty"`
|
||||
Parse *ParseConfig `yaml:"parse,omitempty"`
|
||||
Files []string `yaml:"files,omitempty"`
|
||||
URL string `yaml:"url,omitempty"`
|
||||
}
|
||||
|
||||
// ParseConfig describes how to turn a raw response into named fields.
|
||||
type ParseConfig struct {
|
||||
Kind string `yaml:"kind"` // regex, json, kv
|
||||
Pattern string `yaml:"pattern,omitempty"`
|
||||
Fields map[string]string `yaml:"fields,omitempty"` // output_name -> named_group
|
||||
}
|
||||
|
||||
// Event is a log-derived event mapping: regex with named groups, mapped to a kind.
|
||||
type Event struct {
|
||||
Pattern string `yaml:"pattern"`
|
||||
Kind string `yaml:"kind"` // join, leave, chat, death, custom
|
||||
}
|
||||
|
||||
// UpdateProvider declares a source the panel can fetch new versions from.
|
||||
type UpdateProvider struct {
|
||||
ID string `yaml:"id"`
|
||||
Kind string `yaml:"kind"` // steamcmd, github, direct
|
||||
AppID string `yaml:"app_id,omitempty"`
|
||||
Beta string `yaml:"beta,omitempty"`
|
||||
// Platform, when non-empty, forces SteamCMD to download a specific
|
||||
// platform's depots via +@sSteamCmdForcePlatformType. Used for Windows-
|
||||
// only server builds installed on Linux hosts (Empyrion, a few others).
|
||||
// Values: "windows", "linux", "macos".
|
||||
Platform string `yaml:"platform,omitempty"`
|
||||
Repo string `yaml:"repo,omitempty"`
|
||||
AssetRegex string `yaml:"asset_regex,omitempty"`
|
||||
URL string `yaml:"url,omitempty"`
|
||||
// TargetPath is where downloaded bytes land, relative to the
|
||||
// instance's BrowseableRoot (if the container exists) or its
|
||||
// DataPath bind mount. Required for `direct` + `github` kinds.
|
||||
TargetPath string `yaml:"target_path,omitempty"`
|
||||
// InstallPath is the container-absolute install dir for SteamCMD's
|
||||
// +force_install_dir. Defaults to BrowseableRoot if empty.
|
||||
InstallPath string `yaml:"install_path,omitempty"`
|
||||
// Extract tells the `direct` / `github` provider to treat the
|
||||
// downloaded bytes as an archive (zip / tar / tar.gz / tar.bz2 /
|
||||
// tar.xz) and extract its contents INTO TargetPath (a directory)
|
||||
// instead of writing the raw bytes as a single file. Factorio's
|
||||
// headless .tar.xz uses this. When false, the provider still
|
||||
// auto-extracts if the content is a recognized archive AND
|
||||
// TargetPath has no file extension (looks like a directory) —
|
||||
// but a TargetPath like /server.jar or /terraria-server.zip is
|
||||
// always written verbatim (minecraft jars ARE zips; terraria's
|
||||
// entrypoint expects the zip file on disk).
|
||||
Extract bool `yaml:"extract,omitempty"`
|
||||
// SkipValidate omits the `validate` keyword from the `app_update`
|
||||
// command. Conan Exiles (app 443030) and a handful of other apps
|
||||
// fail with "Missing configuration" when validate runs against an
|
||||
// empty install dir; plain `app_update` succeeds. Operators who
|
||||
// need periodic integrity checks can run Update manually later.
|
||||
SkipValidate bool `yaml:"skip_validate,omitempty"`
|
||||
// RequiresSteamLogin means `+login anonymous` won't work — the app
|
||||
// is paid content (DayZ 223350, Arma 3 229470, etc.) and SteamCMD
|
||||
// needs a real Steam account that owns it. When true, the controller
|
||||
// halts the update flow with {steam_login_required: true} if no
|
||||
// cached credential is available, letting the UI pop a login modal.
|
||||
// Once creds are stored, they're injected as `+login <user> <pass>`.
|
||||
RequiresSteamLogin bool `yaml:"requires_steam_login,omitempty"`
|
||||
}
|
||||
|
||||
// Secret declares an operator-supplied or panel-generated credential.
|
||||
type Secret struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Generated bool `yaml:"generated,omitempty"`
|
||||
}
|
||||
|
||||
// Lifecycle declares shell commands (or WASM hooks — future) that run at
|
||||
// specific lifecycle phases. Commands run inside the instance sandbox.
|
||||
type Lifecycle struct {
|
||||
PreInstall []string `yaml:"pre_install,omitempty"`
|
||||
PostInstall []string `yaml:"post_install,omitempty"`
|
||||
PreStart []string `yaml:"pre_start,omitempty"`
|
||||
PostStart []string `yaml:"post_start,omitempty"`
|
||||
PreStop []string `yaml:"pre_stop,omitempty"`
|
||||
PostStop []string `yaml:"post_stop,omitempty"`
|
||||
}
|
||||
|
||||
// Duration is a time.Duration that parses from YAML strings like "30s".
|
||||
type Duration time.Duration
|
||||
|
||||
// UnmarshalYAML parses "30s" / "5m" / "1h" style values into a Duration.
|
||||
func (d *Duration) UnmarshalYAML(node *yaml.Node) error {
|
||||
var s string
|
||||
if err := node.Decode(&s); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == "" {
|
||||
*d = 0
|
||||
return nil
|
||||
}
|
||||
v, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration %q: %w", s, err)
|
||||
}
|
||||
*d = Duration(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Std returns the underlying time.Duration.
|
||||
func (d Duration) Std() time.Duration { return time.Duration(d) }
|
||||
|
||||
// HasMode reports whether the module supports the given runtime mode.
|
||||
func (m *Manifest) HasMode(mode string) bool {
|
||||
for _, x := range m.SupportedModes {
|
||||
if x == mode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Port returns the named port declaration, or nil if not declared.
|
||||
func (m *Manifest) Port(name string) *Port {
|
||||
for i := range m.Ports {
|
||||
if m.Ports[i].Name == name {
|
||||
return &m.Ports[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProvider returns the named update provider, or nil.
|
||||
func (m *Manifest) UpdateProvider(id string) *UpdateProvider {
|
||||
for i := range m.UpdateProviders {
|
||||
if m.UpdateProviders[i].ID == id {
|
||||
return &m.UpdateProviders[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLoad7DTDManifest loads the real 7 Days to Die manifest shipped in
|
||||
// modules/7dtd and asserts the panel-critical fields parsed correctly.
|
||||
// Keeping this coupled to the real file (rather than a testdata copy) means
|
||||
// changes to the shipped manifest must keep the test green.
|
||||
func TestLoad7DTDManifest(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "modules", "7dtd", "module.yaml")
|
||||
m, err := LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile: %v", err)
|
||||
}
|
||||
|
||||
if m.ID != "7dtd" {
|
||||
t.Errorf("id = %q, want 7dtd", m.ID)
|
||||
}
|
||||
if !m.HasMode("docker") {
|
||||
t.Errorf("expected docker mode, got %v", m.SupportedModes)
|
||||
}
|
||||
if m.Runtime.Docker == nil || m.Runtime.Docker.Image == "" {
|
||||
t.Error("expected docker runtime with image")
|
||||
}
|
||||
if m.RCON == nil || m.RCON.Adapter != "telnet" {
|
||||
t.Errorf("rcon adapter = %+v, want telnet", m.RCON)
|
||||
}
|
||||
if p := m.Port("telnet"); p == nil || p.Proto != "tcp" || !p.Internal {
|
||||
t.Errorf("telnet port malformed: %+v", p)
|
||||
}
|
||||
if up := m.UpdateProvider("stable"); up == nil || up.Kind != "steamcmd" || up.AppID != "294420" {
|
||||
t.Errorf("stable update provider malformed: %+v", up)
|
||||
}
|
||||
if len(m.Events) < 3 {
|
||||
t.Errorf("expected at least join/leave/chat events, got %d", len(m.Events))
|
||||
}
|
||||
// state_sources[0] should be rcon poll with a 30s cadence
|
||||
if len(m.StateSources) == 0 {
|
||||
t.Fatal("expected at least one state source")
|
||||
}
|
||||
ss := m.StateSources[0]
|
||||
if ss.Type != "rcon" || ss.Command != "lp" {
|
||||
t.Errorf("first state source = %+v, want rcon lp", ss)
|
||||
}
|
||||
if ss.Every.Std() != 30*time.Second {
|
||||
t.Errorf("state source every = %v, want 30s", ss.Every.Std())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDirSkipsDirWithoutManifest(t *testing.T) {
|
||||
// modules/ has 7dtd + minecraft-java + valheim dirs, but only 7dtd has a
|
||||
// module.yaml right now. Loader should return 1 module.
|
||||
dir := filepath.Join("..", "..", "modules")
|
||||
ms, err := LoadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
if len(ms) < 1 {
|
||||
t.Errorf("want >= 1 modules, got %d", len(ms))
|
||||
}
|
||||
found := false
|
||||
for _, m := range ms {
|
||||
if m.ID == "7dtd" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("7dtd not present in LoadDir result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsMissingID(t *testing.T) {
|
||||
m := &Manifest{}
|
||||
if err := m.Validate(); err == nil {
|
||||
t.Error("expected validation error for empty manifest")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Registry is a concurrency-safe in-memory index of loaded manifests.
|
||||
// It's held as a shared component by both Controller and Target.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
modules map[string]*Manifest
|
||||
}
|
||||
|
||||
// NewRegistry returns an empty registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{modules: make(map[string]*Manifest)}
|
||||
}
|
||||
|
||||
// LoadDir scans dir for modules and inserts them. Duplicate IDs are rejected.
|
||||
func (r *Registry) LoadDir(dir string) error {
|
||||
ms, err := LoadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, m := range ms {
|
||||
if _, exists := r.modules[m.ID]; exists {
|
||||
return fmt.Errorf("duplicate module id %q in %s", m.ID, dir)
|
||||
}
|
||||
r.modules[m.ID] = m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Put inserts or replaces a manifest.
|
||||
func (r *Registry) Put(m *Manifest) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.modules[m.ID] = m
|
||||
}
|
||||
|
||||
// Get returns the manifest for id, or (nil, false) if unknown.
|
||||
func (r *Registry) Get(id string) (*Manifest, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
m, ok := r.modules[id]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// List returns all manifests sorted by id.
|
||||
func (r *Registry) List() []*Manifest {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]*Manifest, 0, len(r.modules))
|
||||
for _, m := range r.modules {
|
||||
out = append(out, m)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// Len returns the number of modules currently registered.
|
||||
func (r *Registry) Len() int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.modules)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// Render writes out each ConfigFile declared by the manifest into dataPath,
|
||||
// applying `values` (which typically combines user config_values + generated
|
||||
// secrets) via Go text/template. Existing files are overwritten.
|
||||
//
|
||||
// Templates are resolved relative to the manifest's Dir. If a ConfigFile
|
||||
// declares no Template, it is skipped — operators can also manage those
|
||||
// files by direct edit via the (future) SFTP/file manager.
|
||||
//
|
||||
// Render uses each ConfigFile's default Template. Callers that know the
|
||||
// instance's Steam branch should use RenderForBranch so version-specific
|
||||
// templates (7DTD 3.0's serverconfig.v3.xml.tmpl) are selected.
|
||||
func Render(manifest *Manifest, dataPath string, values map[string]string) error {
|
||||
return RenderForBranch(manifest, dataPath, values, "")
|
||||
}
|
||||
|
||||
// RenderForBranch is Render with branch-aware template selection: each
|
||||
// ConfigFile renders cf.TemplateForBranch(branch) instead of cf.Template, so
|
||||
// an instance on a 3.0 branch picks serverconfig.v3.xml.tmpl while ≤2.6
|
||||
// instances (branch "") keep the default template. branch is the normalized
|
||||
// Steam branch (see resolveCreateBranch); "" means the module default.
|
||||
func RenderForBranch(manifest *Manifest, dataPath string, values map[string]string, branch string) error {
|
||||
if manifest == nil {
|
||||
return fmt.Errorf("render: manifest is nil")
|
||||
}
|
||||
if manifest.Dir == "" {
|
||||
return fmt.Errorf("render: manifest.Dir is empty (loader must set it)")
|
||||
}
|
||||
for _, cf := range manifest.ConfigFiles {
|
||||
tmpl := cf.TemplateForBranch(branch)
|
||||
if tmpl == "" {
|
||||
continue
|
||||
}
|
||||
if err := renderOne(manifest.Dir, cf, tmpl, dataPath, values); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderOne(moduleDir string, cf ConfigFile, templateRel string, dataPath string, values map[string]string) error {
|
||||
templatePath := filepath.Join(moduleDir, templateRel)
|
||||
tmplData, err := os.ReadFile(templatePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read template %s: %w", templatePath, err)
|
||||
}
|
||||
tmpl, err := template.New(cf.Path).Option("missingkey=zero").Parse(string(tmplData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse template %s: %w", templateRel, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
ctx := struct {
|
||||
Values map[string]string
|
||||
}{Values: values}
|
||||
if err := tmpl.Execute(&buf, ctx); err != nil {
|
||||
return fmt.Errorf("execute template %s: %w", templateRel, err)
|
||||
}
|
||||
|
||||
outPath := filepath.Join(dataPath, cf.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", filepath.Dir(outPath), err)
|
||||
}
|
||||
if err := os.WriteFile(outPath, buf.Bytes(), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", outPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateSecrets emits a random value for each manifest Secret flagged
|
||||
// Generated. Values are URL-safe base64 of 24 bytes — safe in XML, shell,
|
||||
// URLs, and long enough to resist brute force on a local RCON surface.
|
||||
//
|
||||
// Callers typically merge this with user-supplied config_values before
|
||||
// rendering templates and resolving RCON passwords.
|
||||
func GenerateSecrets(manifest *Manifest) (map[string]string, error) {
|
||||
if manifest == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, s := range manifest.Secrets {
|
||||
if !s.Generated {
|
||||
continue
|
||||
}
|
||||
val, err := randomToken(24)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate secret %q: %w", s.Name, err)
|
||||
}
|
||||
out[s.Name] = val
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// randomToken returns a URL-safe base64 string of n random bytes.
|
||||
func randomToken(nBytes int) (string, error) {
|
||||
b := make([]byte, nBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRenderWritesTemplate verifies the Go text/template pipeline:
|
||||
// - template file read from manifest.Dir/<cf.Template>
|
||||
// - values flow via .Values.key
|
||||
// - output written under dataPath/<cf.Path>, with sub-dirs created
|
||||
// - missing keys render as empty (missingkey=zero)
|
||||
func TestRenderWritesTemplate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Simulate a module directory with a template subdir.
|
||||
if err := os.MkdirAll(filepath.Join(dir, "templates"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmplPath := filepath.Join(dir, "templates", "demo.conf.tmpl")
|
||||
tmplBody := "name={{ .Values.name }}\npassword={{ .Values.password }}\nmissing={{ .Values.nope }}END\n"
|
||||
if err := os.WriteFile(tmplPath, []byte(tmplBody), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := &Manifest{
|
||||
ID: "demo",
|
||||
Name: "Demo",
|
||||
Dir: dir,
|
||||
ConfigFiles: []ConfigFile{
|
||||
{Path: "conf/demo.conf", Format: "kv", Template: "templates/demo.conf.tmpl"},
|
||||
},
|
||||
}
|
||||
|
||||
dataPath := filepath.Join(t.TempDir(), "instance-data")
|
||||
values := map[string]string{
|
||||
"name": "hello",
|
||||
"password": "s3cret",
|
||||
}
|
||||
if err := Render(m, dataPath, values); err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
out, err := os.ReadFile(filepath.Join(dataPath, "conf", "demo.conf"))
|
||||
if err != nil {
|
||||
t.Fatalf("read rendered: %v", err)
|
||||
}
|
||||
got := string(out)
|
||||
if !strings.Contains(got, "name=hello") {
|
||||
t.Errorf("missing 'name=hello' in %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "password=s3cret") {
|
||||
t.Errorf("missing 'password=s3cret' in %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "missing=END") {
|
||||
t.Errorf("missing-key didn't render as empty in %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderSkipsConfigFilesWithoutTemplate verifies a ConfigFile entry
|
||||
// without a Template is left alone (operator edits it directly, for example
|
||||
// via SFTP/file manager).
|
||||
func TestRenderSkipsConfigFilesWithoutTemplate(t *testing.T) {
|
||||
m := &Manifest{
|
||||
ID: "demo",
|
||||
Name: "Demo",
|
||||
Dir: t.TempDir(),
|
||||
ConfigFiles: []ConfigFile{
|
||||
{Path: "manual.cfg", Format: "kv"}, // no Template
|
||||
},
|
||||
}
|
||||
dataPath := filepath.Join(t.TempDir(), "instance-data")
|
||||
if err := Render(m, dataPath, nil); err != nil {
|
||||
t.Errorf("Render with no-template ConfigFile should not error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dataPath, "manual.cfg")); err == nil {
|
||||
t.Error("should not have written manual.cfg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSecretsUnique(t *testing.T) {
|
||||
m := &Manifest{
|
||||
Secrets: []Secret{
|
||||
{Name: "telnet_password", Generated: true},
|
||||
{Name: "admin_token", Generated: true},
|
||||
{Name: "unused_static", Generated: false},
|
||||
},
|
||||
}
|
||||
secrets, err := GenerateSecrets(m)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSecrets: %v", err)
|
||||
}
|
||||
if len(secrets) != 2 {
|
||||
t.Errorf("got %d secrets, want 2 (only Generated=true)", len(secrets))
|
||||
}
|
||||
if secrets["telnet_password"] == "" || secrets["admin_token"] == "" {
|
||||
t.Errorf("empty secret values: %+v", secrets)
|
||||
}
|
||||
if secrets["telnet_password"] == secrets["admin_token"] {
|
||||
t.Errorf("two secrets collided: %q", secrets["telnet_password"])
|
||||
}
|
||||
if _, ok := secrets["unused_static"]; ok {
|
||||
t.Errorf("non-generated secret should not be in output")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderAgainst7DTDTemplate is an integration smoke test that renders
|
||||
// the shipped 7 Days to Die template with a plausible value set and asserts
|
||||
// the resulting XML contains the expected property values.
|
||||
func TestRenderAgainst7DTDTemplate(t *testing.T) {
|
||||
m, err := LoadFile(filepath.Join("..", "..", "modules", "7dtd", "module.yaml"))
|
||||
if err != nil {
|
||||
t.Skipf("7dtd manifest not loadable: %v", err)
|
||||
}
|
||||
// The template path under the manifest may not exist yet during early
|
||||
// development. Skip cleanly if so.
|
||||
var tmplRef, outRel string
|
||||
for _, cf := range m.ConfigFiles {
|
||||
if cf.Template != "" {
|
||||
tmplRef = filepath.Join(m.Dir, cf.Template)
|
||||
outRel = cf.Path
|
||||
break
|
||||
}
|
||||
}
|
||||
if tmplRef == "" {
|
||||
t.Skip("7dtd manifest has no config_files with templates")
|
||||
}
|
||||
if _, err := os.Stat(tmplRef); err != nil {
|
||||
t.Skipf("7dtd template not present: %v", err)
|
||||
}
|
||||
|
||||
dataPath := t.TempDir()
|
||||
values := map[string]string{
|
||||
"server_name": "Refuge Test",
|
||||
"telnet_password": "test-pw-123",
|
||||
"max_players": "16",
|
||||
"world_seed": "Navezgane",
|
||||
}
|
||||
if err := Render(m, dataPath, values); err != nil {
|
||||
t.Fatalf("Render 7dtd: %v", err)
|
||||
}
|
||||
out, err := os.ReadFile(filepath.Join(dataPath, outRel))
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
got := string(out)
|
||||
for _, needle := range []string{"Refuge Test", "test-pw-123", `value="16"`} {
|
||||
if !strings.Contains(got, needle) {
|
||||
t.Errorf("rendered serverconfig.xml missing %q; got:\n%s", needle, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user