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 }