295eb22826
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
166 lines
4.4 KiB
Go
166 lines
4.4 KiB
Go
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
|
|
}
|
|
|