All import paths updated to git.wntrmute.dev/mc/. Bumps mcdsl to v1.2.0, mc-proxy to v1.1.0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
260 lines
7.0 KiB
Go
260 lines
7.0 KiB
Go
// 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
|
|
}
|