panel v0.9.2 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user