Files
panel/pkg/module/loader.go
T
dbledeez 295eb22826 panel public release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 01:26:41 -07:00

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
}