Complete infrastructure platform CLI: - Container runtime (systemd-nspawn) - VoltVisor VMs (Neutron Stardust / QEMU) - Stellarium CAS (content-addressed storage) - ORAS Registry - GitOps integration - Landlock LSM security - Compose orchestration - Mesh networking Copyright (c) Armored Gates LLC. All rights reserved. Licensed under AGPSL v5.0
562 lines
18 KiB
Go
562 lines
18 KiB
Go
/*
|
|
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:<hex>")
|
|
- 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:<hex>" or "cas://sha512:<hex>".
|
|
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:<hex> or cas://sha512:<hex>", 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]
|
|
}
|