/* Manifest Validation — Validates Volt v2 manifests before execution. Checks include: - Required fields (name, mode) - Enum validation for mode, network, landlock, seccomp, writable_layer - Resource limit parsing (human-readable: "512M", "2G") - Port mapping parsing ("80:80/tcp", "443:443/udp") - CAS reference validation ("cas://sha256:") - Kernel path existence for hybrid modes - Workload name safety (delegates to validate.WorkloadName) Provides both strict Validate() and informational DryRun(). Copyright (c) Armored Gates LLC. All rights reserved. */ package manifest import ( "fmt" "os" "regexp" "strconv" "strings" "github.com/armoredgate/volt/pkg/validate" ) // ── Validation Errors ──────────────────────────────────────────────────────── // ValidationError collects one or more field-level errors. type ValidationError struct { Errors []FieldError } func (ve *ValidationError) Error() string { var b strings.Builder b.WriteString("manifest validation failed:\n") for _, fe := range ve.Errors { fmt.Fprintf(&b, " [%s] %s\n", fe.Field, fe.Message) } return b.String() } // FieldError records a single validation failure for a specific field. type FieldError struct { Field string // e.g. "workload.name", "resources.memory_limit" Message string } // ── Dry Run Report ─────────────────────────────────────────────────────────── // Severity classifies a report finding. type Severity string const ( SeverityError Severity = "error" SeverityWarning Severity = "warning" SeverityInfo Severity = "info" ) // Finding is a single line item in a DryRun report. type Finding struct { Severity Severity Field string Message string } // Report is the output of DryRun. It contains findings at varying severity // levels and a summary of resolved resource values. type Report struct { Findings []Finding // Resolved values (populated during dry run for display) ResolvedMemoryLimit int64 // bytes ResolvedMemorySoft int64 // bytes ResolvedPortMaps []PortMapping } // HasErrors returns true if any finding is severity error. func (r *Report) HasErrors() bool { for _, f := range r.Findings { if f.Severity == SeverityError { return true } } return false } // PortMapping is the parsed representation of a port string like "80:80/tcp". type PortMapping struct { HostPort int ContainerPort int Protocol string // "tcp" or "udp" } // ── Validate ───────────────────────────────────────────────────────────────── // Validate performs strict validation of a manifest. Returns nil if the // manifest is valid. Returns a *ValidationError containing all field errors // otherwise. func (m *Manifest) Validate() error { var errs []FieldError // ── workload ───────────────────────────────────────────────────────── if m.Workload.Name == "" { errs = append(errs, FieldError{ Field: "workload.name", Message: "required field is empty", }) } else if err := validate.WorkloadName(m.Workload.Name); err != nil { errs = append(errs, FieldError{ Field: "workload.name", Message: err.Error(), }) } if m.Workload.Mode == "" { errs = append(errs, FieldError{ Field: "workload.mode", Message: "required field is empty", }) } else if !ValidModes[m.Workload.Mode] { errs = append(errs, FieldError{ Field: "workload.mode", Message: fmt.Sprintf("invalid mode %q (valid: container, hybrid-native, hybrid-kvm, hybrid-emulated)", m.Workload.Mode), }) } // ── kernel (hybrid modes only) ─────────────────────────────────────── if m.NeedsKernel() { if m.Kernel.Path != "" { if _, err := os.Stat(m.Kernel.Path); err != nil { errs = append(errs, FieldError{ Field: "kernel.path", Message: fmt.Sprintf("kernel not found: %s", m.Kernel.Path), }) } } // If no path and no version, the kernel manager will use defaults at // runtime — that's acceptable. We only error if an explicit path is // given and missing. } // ── security ───────────────────────────────────────────────────────── if m.Security.LandlockProfile != "" { lp := LandlockProfile(m.Security.LandlockProfile) if !ValidLandlockProfiles[lp] { // Could be a file path for custom profile — check if it looks like // a path (contains / or .) if !looksLikePath(m.Security.LandlockProfile) { errs = append(errs, FieldError{ Field: "security.landlock_profile", Message: fmt.Sprintf("invalid profile %q (valid: strict, default, permissive, custom, or a file path)", m.Security.LandlockProfile), }) } } } if m.Security.SeccompProfile != "" { validSeccomp := map[string]bool{ "strict": true, "default": true, "unconfined": true, } if !validSeccomp[m.Security.SeccompProfile] && !looksLikePath(m.Security.SeccompProfile) { errs = append(errs, FieldError{ Field: "security.seccomp_profile", Message: fmt.Sprintf("invalid profile %q (valid: strict, default, unconfined, or a file path)", m.Security.SeccompProfile), }) } } if len(m.Security.Capabilities) > 0 { for _, cap := range m.Security.Capabilities { if !isValidCapability(cap) { errs = append(errs, FieldError{ Field: "security.capabilities", Message: fmt.Sprintf("unknown capability %q", cap), }) } } } // ── resources ──────────────────────────────────────────────────────── if m.Resources.MemoryLimit != "" { if _, err := ParseMemorySize(m.Resources.MemoryLimit); err != nil { errs = append(errs, FieldError{ Field: "resources.memory_limit", Message: err.Error(), }) } } if m.Resources.MemorySoft != "" { if _, err := ParseMemorySize(m.Resources.MemorySoft); err != nil { errs = append(errs, FieldError{ Field: "resources.memory_soft", Message: err.Error(), }) } } if m.Resources.CPUWeight != 0 { if m.Resources.CPUWeight < 1 || m.Resources.CPUWeight > 10000 { errs = append(errs, FieldError{ Field: "resources.cpu_weight", Message: fmt.Sprintf("cpu_weight %d out of range [1, 10000]", m.Resources.CPUWeight), }) } } if m.Resources.CPUSet != "" { if err := validateCPUSet(m.Resources.CPUSet); err != nil { errs = append(errs, FieldError{ Field: "resources.cpu_set", Message: err.Error(), }) } } if m.Resources.IOWeight != 0 { if m.Resources.IOWeight < 1 || m.Resources.IOWeight > 10000 { errs = append(errs, FieldError{ Field: "resources.io_weight", Message: fmt.Sprintf("io_weight %d out of range [1, 10000]", m.Resources.IOWeight), }) } } if m.Resources.PidsMax != 0 { if m.Resources.PidsMax < 1 { errs = append(errs, FieldError{ Field: "resources.pids_max", Message: "pids_max must be positive", }) } } // ── network ────────────────────────────────────────────────────────── if m.Network.Mode != "" && !ValidNetworkModes[m.Network.Mode] { errs = append(errs, FieldError{ Field: "network.mode", Message: fmt.Sprintf("invalid network mode %q (valid: bridge, host, none, custom)", m.Network.Mode), }) } for i, port := range m.Network.Ports { if _, err := ParsePortMapping(port); err != nil { errs = append(errs, FieldError{ Field: fmt.Sprintf("network.ports[%d]", i), Message: err.Error(), }) } } // ── storage ────────────────────────────────────────────────────────── if m.Storage.Rootfs != "" && m.HasCASRootfs() { if err := validateCASRef(m.Storage.Rootfs); err != nil { errs = append(errs, FieldError{ Field: "storage.rootfs", Message: err.Error(), }) } } if m.Storage.WritableLayer != "" && !ValidWritableLayerModes[m.Storage.WritableLayer] { errs = append(errs, FieldError{ Field: "storage.writable_layer", Message: fmt.Sprintf("invalid writable_layer %q (valid: overlay, tmpfs, none)", m.Storage.WritableLayer), }) } for i, vol := range m.Storage.Volumes { if vol.Host == "" { errs = append(errs, FieldError{ Field: fmt.Sprintf("storage.volumes[%d].host", i), Message: "host path is required", }) } if vol.Container == "" { errs = append(errs, FieldError{ Field: fmt.Sprintf("storage.volumes[%d].container", i), Message: "container path is required", }) } } if len(errs) > 0 { return &ValidationError{Errors: errs} } return nil } // ── DryRun ─────────────────────────────────────────────────────────────────── // DryRun performs validation and additionally resolves human-readable resource // values into machine values, returning a Report with findings and resolved // values. Unlike Validate(), DryRun never returns an error — the Report itself // carries severity information. func (m *Manifest) DryRun() *Report { r := &Report{} // Run validation and collect errors as findings. if err := m.Validate(); err != nil { if ve, ok := err.(*ValidationError); ok { for _, fe := range ve.Errors { r.Findings = append(r.Findings, Finding{ Severity: SeverityError, Field: fe.Field, Message: fe.Message, }) } } } // ── Informational findings ─────────────────────────────────────────── // Resolve memory limits. if m.Resources.MemoryLimit != "" { if bytes, err := ParseMemorySize(m.Resources.MemoryLimit); err == nil { r.ResolvedMemoryLimit = bytes r.Findings = append(r.Findings, Finding{ Severity: SeverityInfo, Field: "resources.memory_limit", Message: fmt.Sprintf("resolved to %d bytes (%s)", bytes, m.Resources.MemoryLimit), }) } } else { r.Findings = append(r.Findings, Finding{ Severity: SeverityWarning, Field: "resources.memory_limit", Message: "not set — workload will have no memory limit", }) } if m.Resources.MemorySoft != "" { if bytes, err := ParseMemorySize(m.Resources.MemorySoft); err == nil { r.ResolvedMemorySoft = bytes } } // Resolve port mappings. for _, port := range m.Network.Ports { if pm, err := ParsePortMapping(port); err == nil { r.ResolvedPortMaps = append(r.ResolvedPortMaps, pm) } } // Warn about container mode with kernel section. if m.Workload.Mode == ModeContainer && (m.Kernel.Path != "" || m.Kernel.Version != "") { r.Findings = append(r.Findings, Finding{ Severity: SeverityWarning, Field: "kernel", Message: "kernel section is set but mode is 'container' — kernel config will be ignored", }) } // Warn about hybrid modes without kernel section. if m.NeedsKernel() && m.Kernel.Path == "" && m.Kernel.Version == "" { r.Findings = append(r.Findings, Finding{ Severity: SeverityWarning, Field: "kernel", Message: "hybrid mode selected but no kernel specified — will use host default", }) } // Check soft < hard memory. if r.ResolvedMemoryLimit > 0 && r.ResolvedMemorySoft > 0 { if r.ResolvedMemorySoft > r.ResolvedMemoryLimit { r.Findings = append(r.Findings, Finding{ Severity: SeverityWarning, Field: "resources.memory_soft", Message: "memory_soft exceeds memory_limit — soft limit will have no effect", }) } } // Info about writable layer. if m.Storage.WritableLayer == WritableNone { r.Findings = append(r.Findings, Finding{ Severity: SeverityInfo, Field: "storage.writable_layer", Message: "writable_layer is 'none' — rootfs will be completely read-only", }) } return r } // ── Parsers ────────────────────────────────────────────────────────────────── // ParseMemorySize parses a human-readable memory size string into bytes. // Supports: "512M", "2G", "1024K", "1T", "256m", "100" (raw bytes). func ParseMemorySize(s string) (int64, error) { s = strings.TrimSpace(s) if s == "" { return 0, fmt.Errorf("empty memory size") } // Raw integer (bytes). if n, err := strconv.ParseInt(s, 10, 64); err == nil { return n, nil } // Strip unit suffix. upper := strings.ToUpper(s) var multiplier int64 = 1 var numStr string switch { case strings.HasSuffix(upper, "T"): multiplier = 1024 * 1024 * 1024 * 1024 numStr = s[:len(s)-1] case strings.HasSuffix(upper, "G"): multiplier = 1024 * 1024 * 1024 numStr = s[:len(s)-1] case strings.HasSuffix(upper, "M"): multiplier = 1024 * 1024 numStr = s[:len(s)-1] case strings.HasSuffix(upper, "K"): multiplier = 1024 numStr = s[:len(s)-1] default: return 0, fmt.Errorf("invalid memory size %q: expected a number with optional suffix K/M/G/T", s) } n, err := strconv.ParseFloat(strings.TrimSpace(numStr), 64) if err != nil { return 0, fmt.Errorf("invalid memory size %q: %w", s, err) } if n < 0 { return 0, fmt.Errorf("invalid memory size %q: negative value", s) } return int64(n * float64(multiplier)), nil } // portRegex matches "hostPort:containerPort/protocol" or "hostPort:containerPort". var portRegex = regexp.MustCompile(`^(\d+):(\d+)(?:/(tcp|udp))?$`) // ParsePortMapping parses a port mapping string like "80:80/tcp". func ParsePortMapping(s string) (PortMapping, error) { s = strings.TrimSpace(s) matches := portRegex.FindStringSubmatch(s) if matches == nil { return PortMapping{}, fmt.Errorf("invalid port mapping %q: expected hostPort:containerPort[/tcp|udp]", s) } hostPort, _ := strconv.Atoi(matches[1]) containerPort, _ := strconv.Atoi(matches[2]) proto := matches[3] if proto == "" { proto = "tcp" } if hostPort < 1 || hostPort > 65535 { return PortMapping{}, fmt.Errorf("invalid host port %d: must be 1-65535", hostPort) } if containerPort < 1 || containerPort > 65535 { return PortMapping{}, fmt.Errorf("invalid container port %d: must be 1-65535", containerPort) } return PortMapping{ HostPort: hostPort, ContainerPort: containerPort, Protocol: proto, }, nil } // ── Internal Helpers ───────────────────────────────────────────────────────── // casRefRegex matches "cas://sha256:" or "cas://sha512:". var casRefRegex = regexp.MustCompile(`^cas://(sha256|sha512):([0-9a-fA-F]+)$`) // validateCASRef validates a CAS reference string. func validateCASRef(ref string) error { if !casRefRegex.MatchString(ref) { return fmt.Errorf("invalid CAS reference %q: expected cas://sha256: or cas://sha512:", ref) } return nil } // cpuSetRegex matches ranges like "0-3", "0,1,2,3", "0-3,8-11". var cpuSetRegex = regexp.MustCompile(`^(\d+(-\d+)?)(,\d+(-\d+)?)*$`) // validateCPUSet validates a cpuset string. func validateCPUSet(s string) error { if !cpuSetRegex.MatchString(s) { return fmt.Errorf("invalid cpu_set %q: expected ranges like '0-3' or '0,1,2,3'", s) } // Verify ranges are valid (start <= end). for _, part := range strings.Split(s, ",") { if strings.Contains(part, "-") { bounds := strings.SplitN(part, "-", 2) start, _ := strconv.Atoi(bounds[0]) end, _ := strconv.Atoi(bounds[1]) if start > end { return fmt.Errorf("invalid cpu_set range %q: start (%d) > end (%d)", part, start, end) } } } return nil } // looksLikePath returns true if s looks like a filesystem path. func looksLikePath(s string) bool { return strings.Contains(s, "/") || strings.Contains(s, ".") } // knownCapabilities is the set of recognized Linux capabilities (without the // CAP_ prefix for convenience). var knownCapabilities = map[string]bool{ "AUDIT_CONTROL": true, "AUDIT_READ": true, "AUDIT_WRITE": true, "BLOCK_SUSPEND": true, "BPF": true, "CHECKPOINT_RESTORE": true, "CHOWN": true, "DAC_OVERRIDE": true, "DAC_READ_SEARCH": true, "FOWNER": true, "FSETID": true, "IPC_LOCK": true, "IPC_OWNER": true, "KILL": true, "LEASE": true, "LINUX_IMMUTABLE": true, "MAC_ADMIN": true, "MAC_OVERRIDE": true, "MKNOD": true, "NET_ADMIN": true, "NET_BIND_SERVICE": true, "NET_BROADCAST": true, "NET_RAW": true, "PERFMON": true, "SETFCAP": true, "SETGID": true, "SETPCAP": true, "SETUID": true, "SYSLOG": true, "SYS_ADMIN": true, "SYS_BOOT": true, "SYS_CHROOT": true, "SYS_MODULE": true, "SYS_NICE": true, "SYS_PACCT": true, "SYS_PTRACE": true, "SYS_RAWIO": true, "SYS_RESOURCE": true, "SYS_TIME": true, "SYS_TTY_CONFIG": true, "WAKE_ALARM": true, } // isValidCapability checks if a capability name is recognized. // Accepts with or without "CAP_" prefix. func isValidCapability(name string) bool { upper := strings.ToUpper(strings.TrimPrefix(name, "CAP_")) return knownCapabilities[upper] }