// Package servicedef handles parsing and writing TOML service definition files. package servicedef import ( "fmt" "os" "path/filepath" "sort" "strings" toml "github.com/pelletier/go-toml/v2" mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1" ) // ServiceDef is the top-level TOML structure for a service definition file. type ServiceDef struct { Name string `toml:"name"` Node string `toml:"node"` Active *bool `toml:"active,omitempty"` Components []ComponentDef `toml:"components"` } // ComponentDef describes a single container component within a service. type ComponentDef struct { Name string `toml:"name"` Image string `toml:"image"` Network string `toml:"network,omitempty"` User string `toml:"user,omitempty"` Restart string `toml:"restart,omitempty"` Ports []string `toml:"ports,omitempty"` Volumes []string `toml:"volumes,omitempty"` Cmd []string `toml:"cmd,omitempty"` } // Load reads and parses a TOML service definition file. If the active field // is omitted, it defaults to true. func Load(path string) (*ServiceDef, error) { data, err := os.ReadFile(path) //nolint:gosec // path from trusted config dir if err != nil { return nil, fmt.Errorf("read service def %q: %w", path, err) } var def ServiceDef if err := toml.Unmarshal(data, &def); err != nil { return nil, fmt.Errorf("parse service def %q: %w", path, err) } if err := validate(&def); err != nil { return nil, fmt.Errorf("validate service def %q: %w", path, err) } if def.Active == nil { t := true def.Active = &t } return &def, nil } // Write serializes a ServiceDef to TOML and writes it to the given path // with 0644 permissions. Parent directories are created if needed. func Write(path string, def *ServiceDef) error { if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { //nolint:gosec // service defs are non-secret return fmt.Errorf("create parent dirs for %q: %w", path, err) } data, err := toml.Marshal(def) if err != nil { return fmt.Errorf("marshal service def: %w", err) } if err := os.WriteFile(path, data, 0o644); err != nil { //nolint:gosec // service defs are non-secret return fmt.Errorf("write service def %q: %w", path, err) } return nil } // LoadAll reads all .toml files from dir, parses each one, and returns the // list sorted by service name. func LoadAll(dir string) ([]*ServiceDef, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("read service dir %q: %w", dir, err) } var defs []*ServiceDef for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".toml") { continue } def, err := Load(filepath.Join(dir, e.Name())) if err != nil { return nil, err } defs = append(defs, def) } sort.Slice(defs, func(i, j int) bool { return defs[i].Name < defs[j].Name }) return defs, nil } // validate checks that a ServiceDef has all required fields and that // component names are unique. func validate(def *ServiceDef) error { if def.Name == "" { return fmt.Errorf("service name is required") } if def.Node == "" { return fmt.Errorf("service node is required") } if len(def.Components) == 0 { return fmt.Errorf("service %q must have at least one component", def.Name) } seen := make(map[string]bool) for _, c := range def.Components { if c.Name == "" { return fmt.Errorf("component name is required in service %q", def.Name) } if c.Image == "" { return fmt.Errorf("component %q image is required in service %q", c.Name, def.Name) } if seen[c.Name] { return fmt.Errorf("duplicate component name %q in service %q", c.Name, def.Name) } seen[c.Name] = true } return nil } // ToProto converts a ServiceDef to a proto ServiceSpec. func ToProto(def *ServiceDef) *mcpv1.ServiceSpec { spec := &mcpv1.ServiceSpec{ Name: def.Name, Active: def.Active != nil && *def.Active, } for _, c := range def.Components { spec.Components = append(spec.Components, &mcpv1.ComponentSpec{ Name: c.Name, Image: c.Image, Network: c.Network, User: c.User, Restart: c.Restart, Ports: c.Ports, Volumes: c.Volumes, Cmd: c.Cmd, }) } return spec } // FromProto converts a proto ServiceSpec back to a ServiceDef. The node // parameter is required because ServiceSpec does not include the node field // (it is a CLI-side routing concern). func FromProto(spec *mcpv1.ServiceSpec, node string) *ServiceDef { active := spec.GetActive() def := &ServiceDef{ Name: spec.GetName(), Node: node, Active: &active, } for _, c := range spec.GetComponents() { def.Components = append(def.Components, ComponentDef{ Name: c.GetName(), Image: c.GetImage(), Network: c.GetNetwork(), User: c.GetUser(), Restart: c.GetRestart(), Ports: c.GetPorts(), Volumes: c.GetVolumes(), Cmd: c.GetCmd(), }) } return def }