Files
mcp/internal/servicedef/servicedef.go
Kyle Isom 15b8823810 P1.2-P1.5: Complete Phase 1 core libraries
Four packages built in parallel:

- P1.2 runtime: Container runtime abstraction with podman implementation.
  Interface (Pull/Run/Stop/Remove/Inspect/List), ContainerSpec/ContainerInfo
  types, CLI arg building, version extraction from image tags. 2 tests.

- P1.3 servicedef: TOML service definition file parsing. Load/Write/LoadAll,
  validation (required fields, unique component names), proto conversion.
  5 tests.

- P1.4 config: CLI and agent config loading from TOML. Duration type for
  time fields, env var overrides (MCP_*/MCP_AGENT_*), required field
  validation, sensible defaults. 7 tests.

- P1.5 auth: MCIAS integration. Token validator with 30s SHA-256 cache,
  gRPC unary interceptor (admin role enforcement, audit logging),
  Login/LoadToken/SaveToken for CLI. 9 tests.

All packages pass build, vet, lint, and test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:36:12 -07:00

186 lines
4.8 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/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
}