package stack import ( "fmt" "gopkg.in/yaml.v3" ) // ComposeSpec is a minimal, lenient representation of a compose file. // We only decode fields we need for validation + label-based proxy routing; // everything else is preserved as-is and passed to `docker compose`. type ComposeSpec struct { Version string `yaml:"version,omitempty"` Services map[string]ServiceSpec `yaml:"services"` } // ServiceSpec captures the subset of compose service fields we inspect. type ServiceSpec struct { Image string `yaml:"image,omitempty"` Ports []any `yaml:"ports,omitempty"` Labels map[string]string `yaml:"labels,omitempty"` Privileged bool `yaml:"privileged,omitempty"` } // Parse decodes YAML into a ComposeSpec. Returns a descriptive error on failure. func Parse(yamlText string) (ComposeSpec, error) { var spec ComposeSpec if err := yaml.Unmarshal([]byte(yamlText), &spec); err != nil { return ComposeSpec{}, fmt.Errorf("invalid yaml: %w", err) } if len(spec.Services) == 0 { return ComposeSpec{}, fmt.Errorf("compose file has no services") } return spec, nil } // Validate enforces Tinyforge-level constraints beyond compose schema validity. // Current rules: // - No service may set `privileged: true`. // - Every service must declare an image (compose supports build: too, but // Tinyforge v1 disallows building from context to avoid arbitrary-code exec). func Validate(spec ComposeSpec) error { for name, svc := range spec.Services { if svc.Privileged { return fmt.Errorf("service %q: privileged mode is not allowed", name) } if svc.Image == "" { return fmt.Errorf("service %q: image is required (build contexts not supported)", name) } } return nil }