// 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/mc/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"` Path string `toml:"path,omitempty"` Build *BuildDef `toml:"build,omitempty"` Components []ComponentDef `toml:"components"` } // BuildDef describes how to build container images for a service. type BuildDef struct { Images map[string]string `toml:"images"` UsesMCDSL bool `toml:"uses_mcdsl,omitempty"` } // RouteDef describes a route for a component, used for automatic port // allocation and mc-proxy integration. type RouteDef struct { Name string `toml:"name,omitempty"` Port int `toml:"port"` Mode string `toml:"mode,omitempty"` Hostname string `toml:"hostname,omitempty"` } // 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"` Routes []RouteDef `toml:"routes,omitempty"` Env []string `toml:"env,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 if err := validateRoutes(c.Name, def.Name, c.Routes); err != nil { return err } } return nil } // validateRoutes checks that routes within a component are valid. func validateRoutes(compName, svcName string, routes []RouteDef) error { if len(routes) == 0 { return nil } routeNames := make(map[string]bool) for i, r := range routes { if r.Port <= 0 { return fmt.Errorf("route port must be > 0 in component %q of service %q", compName, svcName) } if r.Mode != "" && r.Mode != "l4" && r.Mode != "l7" { return fmt.Errorf("route mode must be \"l4\" or \"l7\" in component %q of service %q", compName, svcName) } if len(routes) > 1 && r.Name == "" { return fmt.Errorf("route name is required when component has multiple routes in component %q of service %q", compName, svcName) } // Use index-based key for unnamed single routes. key := r.Name if key == "" { key = fmt.Sprintf("_route_%d", i) } if routeNames[key] { return fmt.Errorf("duplicate route name %q in component %q of service %q", r.Name, compName, svcName) } routeNames[key] = 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 { cs := &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, Env: c.Env, } for _, r := range c.Routes { cs.Routes = append(cs.Routes, &mcpv1.RouteSpec{ Name: r.Name, Port: int32(r.Port), Mode: r.Mode, Hostname: r.Hostname, }) } spec.Components = append(spec.Components, cs) } 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() { cd := 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(), Env: c.GetEnv(), } for _, r := range c.GetRoutes() { cd.Routes = append(cd.Routes, RouteDef{ Name: r.GetName(), Port: int(r.GetPort()), Mode: r.GetMode(), Hostname: r.GetHostname(), }) } def.Components = append(def.Components, cd) } return def }