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>
This commit is contained in:
185
internal/servicedef/servicedef.go
Normal file
185
internal/servicedef/servicedef.go
Normal file
@@ -0,0 +1,185 @@
|
||||
// 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
|
||||
}
|
||||
289
internal/servicedef/servicedef_test.go
Normal file
289
internal/servicedef/servicedef_test.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package servicedef
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
func sampleDef() *ServiceDef {
|
||||
return &ServiceDef{
|
||||
Name: "metacrypt",
|
||||
Node: "rift",
|
||||
Active: boolPtr(true),
|
||||
Components: []ComponentDef{
|
||||
{
|
||||
Name: "api",
|
||||
Image: "mcr.svc.mcp.metacircular.net:8443/metacrypt:latest",
|
||||
Network: "docker_default",
|
||||
User: "0:0",
|
||||
Restart: "unless-stopped",
|
||||
Ports: []string{"127.0.0.1:18443:8443", "127.0.0.1:19443:9443"},
|
||||
Volumes: []string{"/srv/metacrypt:/srv/metacrypt"},
|
||||
},
|
||||
{
|
||||
Name: "web",
|
||||
Image: "mcr.svc.mcp.metacircular.net:8443/metacrypt-web:latest",
|
||||
Network: "docker_default",
|
||||
User: "0:0",
|
||||
Restart: "unless-stopped",
|
||||
Ports: []string{"127.0.0.1:18080:8080"},
|
||||
Volumes: []string{"/srv/metacrypt:/srv/metacrypt"},
|
||||
Cmd: []string{"server", "--config", "/srv/metacrypt/metacrypt.toml"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// compareComponents asserts that two component slices are equal field by field.
|
||||
func compareComponents(t *testing.T, prefix string, got, want []ComponentDef) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s components: got %d, want %d", prefix, len(got), len(want))
|
||||
}
|
||||
for i, wantC := range want {
|
||||
gotC := got[i]
|
||||
if gotC.Name != wantC.Name {
|
||||
t.Fatalf("%s component[%d] name: got %q, want %q", prefix, i, gotC.Name, wantC.Name)
|
||||
}
|
||||
if gotC.Image != wantC.Image {
|
||||
t.Fatalf("%s component[%d] image: got %q, want %q", prefix, i, gotC.Image, wantC.Image)
|
||||
}
|
||||
if gotC.Network != wantC.Network {
|
||||
t.Fatalf("%s component[%d] network: got %q, want %q", prefix, i, gotC.Network, wantC.Network)
|
||||
}
|
||||
if gotC.User != wantC.User {
|
||||
t.Fatalf("%s component[%d] user: got %q, want %q", prefix, i, gotC.User, wantC.User)
|
||||
}
|
||||
if gotC.Restart != wantC.Restart {
|
||||
t.Fatalf("%s component[%d] restart: got %q, want %q", prefix, i, gotC.Restart, wantC.Restart)
|
||||
}
|
||||
compareStrSlice(t, prefix, i, "ports", gotC.Ports, wantC.Ports)
|
||||
compareStrSlice(t, prefix, i, "volumes", gotC.Volumes, wantC.Volumes)
|
||||
compareStrSlice(t, prefix, i, "cmd", gotC.Cmd, wantC.Cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func compareStrSlice(t *testing.T, prefix string, idx int, field string, got, want []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s component[%d] %s: got %d, want %d", prefix, idx, field, len(got), len(want))
|
||||
}
|
||||
for j := range want {
|
||||
if got[j] != want[j] {
|
||||
t.Fatalf("%s component[%d] %s[%d]: got %q, want %q", prefix, idx, field, j, got[j], want[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWrite(t *testing.T) {
|
||||
def := sampleDef()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "metacrypt.toml")
|
||||
|
||||
if err := Write(path, def); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
if got.Name != def.Name {
|
||||
t.Fatalf("name: got %q, want %q", got.Name, def.Name)
|
||||
}
|
||||
if got.Node != def.Node {
|
||||
t.Fatalf("node: got %q, want %q", got.Node, def.Node)
|
||||
}
|
||||
if *got.Active != *def.Active {
|
||||
t.Fatalf("active: got %v, want %v", *got.Active, *def.Active)
|
||||
}
|
||||
compareComponents(t, "load-write", got.Components, def.Components)
|
||||
}
|
||||
|
||||
func TestValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
def *ServiceDef
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing name",
|
||||
def: &ServiceDef{
|
||||
Node: "rift",
|
||||
Components: []ComponentDef{{Name: "api", Image: "img:v1"}},
|
||||
},
|
||||
wantErr: "service name is required",
|
||||
},
|
||||
{
|
||||
name: "missing node",
|
||||
def: &ServiceDef{
|
||||
Name: "svc",
|
||||
Components: []ComponentDef{{Name: "api", Image: "img:v1"}},
|
||||
},
|
||||
wantErr: "service node is required",
|
||||
},
|
||||
{
|
||||
name: "empty components",
|
||||
def: &ServiceDef{
|
||||
Name: "svc",
|
||||
Node: "rift",
|
||||
},
|
||||
wantErr: "must have at least one component",
|
||||
},
|
||||
{
|
||||
name: "duplicate component names",
|
||||
def: &ServiceDef{
|
||||
Name: "svc",
|
||||
Node: "rift",
|
||||
Components: []ComponentDef{
|
||||
{Name: "api", Image: "img:v1"},
|
||||
{Name: "api", Image: "img:v2"},
|
||||
},
|
||||
},
|
||||
wantErr: "duplicate component name",
|
||||
},
|
||||
{
|
||||
name: "component missing name",
|
||||
def: &ServiceDef{
|
||||
Name: "svc",
|
||||
Node: "rift",
|
||||
Components: []ComponentDef{{Image: "img:v1"}},
|
||||
},
|
||||
wantErr: "component name is required",
|
||||
},
|
||||
{
|
||||
name: "component missing image",
|
||||
def: &ServiceDef{
|
||||
Name: "svc",
|
||||
Node: "rift",
|
||||
Components: []ComponentDef{{Name: "api"}},
|
||||
},
|
||||
wantErr: "image is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validate(tt.def)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, tt.wantErr) {
|
||||
t.Fatalf("error %q does not contain %q", got, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAll(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Write services in non-alphabetical order.
|
||||
defs := []*ServiceDef{
|
||||
{
|
||||
Name: "mcr",
|
||||
Node: "rift",
|
||||
Active: boolPtr(true),
|
||||
Components: []ComponentDef{{Name: "api", Image: "mcr:latest"}},
|
||||
},
|
||||
{
|
||||
Name: "metacrypt",
|
||||
Node: "rift",
|
||||
Active: boolPtr(true),
|
||||
Components: []ComponentDef{{Name: "api", Image: "metacrypt:latest"}},
|
||||
},
|
||||
{
|
||||
Name: "mcias",
|
||||
Node: "rift",
|
||||
Active: boolPtr(false),
|
||||
Components: []ComponentDef{{Name: "api", Image: "mcias:latest"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, d := range defs {
|
||||
if err := Write(filepath.Join(dir, d.Name+".toml"), d); err != nil {
|
||||
t.Fatalf("write %s: %v", d.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write a non-TOML file that should be ignored.
|
||||
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# ignore"), 0o600); err != nil {
|
||||
t.Fatalf("write readme: %v", err)
|
||||
}
|
||||
|
||||
got, err := LoadAll(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load all: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("count: got %d, want 3", len(got))
|
||||
}
|
||||
|
||||
wantOrder := []string{"mcias", "mcr", "metacrypt"}
|
||||
for i, name := range wantOrder {
|
||||
if got[i].Name != name {
|
||||
t.Fatalf("order[%d]: got %q, want %q", i, got[i].Name, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "svc.toml")
|
||||
|
||||
content := `name = "svc"
|
||||
node = "rift"
|
||||
|
||||
[[components]]
|
||||
name = "api"
|
||||
image = "img:latest"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
def, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
if def.Active == nil {
|
||||
t.Fatal("active should not be nil")
|
||||
}
|
||||
if !*def.Active {
|
||||
t.Fatal("active should default to true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtoConversion(t *testing.T) {
|
||||
def := sampleDef()
|
||||
|
||||
spec := ToProto(def)
|
||||
if spec.Name != def.Name {
|
||||
t.Fatalf("proto name: got %q, want %q", spec.Name, def.Name)
|
||||
}
|
||||
if !spec.Active {
|
||||
t.Fatal("proto active should be true")
|
||||
}
|
||||
if len(spec.Components) != len(def.Components) {
|
||||
t.Fatalf("proto components: got %d, want %d", len(spec.Components), len(def.Components))
|
||||
}
|
||||
|
||||
got := FromProto(spec, def.Node)
|
||||
if got.Name != def.Name {
|
||||
t.Fatalf("round-trip name: got %q, want %q", got.Name, def.Name)
|
||||
}
|
||||
if got.Node != def.Node {
|
||||
t.Fatalf("round-trip node: got %q, want %q", got.Node, def.Node)
|
||||
}
|
||||
if *got.Active != *def.Active {
|
||||
t.Fatalf("round-trip active: got %v, want %v", *got.Active, *def.Active)
|
||||
}
|
||||
compareComponents(t, "round-trip", got.Components, def.Components)
|
||||
}
|
||||
Reference in New Issue
Block a user