Volt CLI: source-available under AGPSL v5.0
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
This commit is contained in:
277
pkg/manifest/manifest.go
Normal file
277
pkg/manifest/manifest.go
Normal file
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
Manifest v2 — Workload manifest format for the Volt hybrid platform.
|
||||
|
||||
Defines the data structures and TOML parser for Volt workload manifests.
|
||||
A manifest describes everything needed to launch a workload: the execution
|
||||
mode (container, hybrid-native, hybrid-kvm, hybrid-emulated), kernel config,
|
||||
security policy, resource limits, networking, and storage layout.
|
||||
|
||||
The canonical serialization format is TOML. JSON round-tripping is supported
|
||||
via struct tags for API use.
|
||||
|
||||
Copyright (c) Armored Gates LLC. All rights reserved.
|
||||
*/
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// ── Execution Modes ──────────────────────────────────────────────────────────
|
||||
|
||||
// Mode selects the workload execution strategy.
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
// ModeContainer runs a standard systemd-nspawn container with no custom
|
||||
// kernel. Fastest to start, smallest footprint.
|
||||
ModeContainer Mode = "container"
|
||||
|
||||
// ModeHybridNative runs a systemd-nspawn container in boot mode with the
|
||||
// host kernel. Full namespace isolation with shared kernel. This is the
|
||||
// primary Volt mode.
|
||||
ModeHybridNative Mode = "hybrid-native"
|
||||
|
||||
// ModeHybridKVM runs the workload inside a lightweight KVM guest using a
|
||||
// custom kernel. Strongest isolation boundary.
|
||||
ModeHybridKVM Mode = "hybrid-kvm"
|
||||
|
||||
// ModeHybridEmulated runs the workload under user-mode emulation (e.g.
|
||||
// proot or QEMU user-mode) for cross-architecture support.
|
||||
ModeHybridEmulated Mode = "hybrid-emulated"
|
||||
)
|
||||
|
||||
// ValidModes is the set of recognized execution modes.
|
||||
var ValidModes = map[Mode]bool{
|
||||
ModeContainer: true,
|
||||
ModeHybridNative: true,
|
||||
ModeHybridKVM: true,
|
||||
ModeHybridEmulated: true,
|
||||
}
|
||||
|
||||
// ── Landlock Profile Names ───────────────────────────────────────────────────
|
||||
|
||||
// LandlockProfile selects a pre-built Landlock policy or a custom path.
|
||||
type LandlockProfile string
|
||||
|
||||
const (
|
||||
LandlockStrict LandlockProfile = "strict"
|
||||
LandlockDefault LandlockProfile = "default"
|
||||
LandlockPermissive LandlockProfile = "permissive"
|
||||
LandlockCustom LandlockProfile = "custom"
|
||||
)
|
||||
|
||||
// ValidLandlockProfiles is the set of recognized Landlock profile names.
|
||||
var ValidLandlockProfiles = map[LandlockProfile]bool{
|
||||
LandlockStrict: true,
|
||||
LandlockDefault: true,
|
||||
LandlockPermissive: true,
|
||||
LandlockCustom: true,
|
||||
}
|
||||
|
||||
// ── Network Mode Names ───────────────────────────────────────────────────────
|
||||
|
||||
// NetworkMode selects the container network topology.
|
||||
type NetworkMode string
|
||||
|
||||
const (
|
||||
NetworkBridge NetworkMode = "bridge"
|
||||
NetworkHost NetworkMode = "host"
|
||||
NetworkNone NetworkMode = "none"
|
||||
NetworkCustom NetworkMode = "custom"
|
||||
)
|
||||
|
||||
// ValidNetworkModes is the set of recognized network modes.
|
||||
var ValidNetworkModes = map[NetworkMode]bool{
|
||||
NetworkBridge: true,
|
||||
NetworkHost: true,
|
||||
NetworkNone: true,
|
||||
NetworkCustom: true,
|
||||
}
|
||||
|
||||
// ── Writable Layer Mode ──────────────────────────────────────────────────────
|
||||
|
||||
// WritableLayerMode selects how the writable layer on top of the CAS rootfs
|
||||
// is implemented.
|
||||
type WritableLayerMode string
|
||||
|
||||
const (
|
||||
WritableOverlay WritableLayerMode = "overlay"
|
||||
WritableTmpfs WritableLayerMode = "tmpfs"
|
||||
WritableNone WritableLayerMode = "none"
|
||||
)
|
||||
|
||||
// ValidWritableLayerModes is the set of recognized writable layer modes.
|
||||
var ValidWritableLayerModes = map[WritableLayerMode]bool{
|
||||
WritableOverlay: true,
|
||||
WritableTmpfs: true,
|
||||
WritableNone: true,
|
||||
}
|
||||
|
||||
// ── Manifest v2 ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Manifest is the top-level workload manifest. Every field maps to a TOML
|
||||
// section or key. The zero value is not valid — at minimum [workload].name
|
||||
// and [workload].mode must be set.
|
||||
type Manifest struct {
|
||||
Workload WorkloadSection `toml:"workload" json:"workload"`
|
||||
Kernel KernelSection `toml:"kernel" json:"kernel"`
|
||||
Security SecuritySection `toml:"security" json:"security"`
|
||||
Resources ResourceSection `toml:"resources" json:"resources"`
|
||||
Network NetworkSection `toml:"network" json:"network"`
|
||||
Storage StorageSection `toml:"storage" json:"storage"`
|
||||
|
||||
// Extends allows inheriting from a base manifest. The value is a path
|
||||
// (relative to the current manifest) or a CAS reference.
|
||||
Extends string `toml:"extends,omitempty" json:"extends,omitempty"`
|
||||
|
||||
// SourcePath records where this manifest was loaded from (not serialized
|
||||
// to TOML). Empty for manifests built programmatically.
|
||||
SourcePath string `toml:"-" json:"-"`
|
||||
}
|
||||
|
||||
// WorkloadSection identifies the workload and its execution mode.
|
||||
type WorkloadSection struct {
|
||||
Name string `toml:"name" json:"name"`
|
||||
Mode Mode `toml:"mode" json:"mode"`
|
||||
Image string `toml:"image,omitempty" json:"image,omitempty"`
|
||||
Description string `toml:"description,omitempty" json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// KernelSection configures the kernel for hybrid modes. Ignored in container
|
||||
// mode.
|
||||
type KernelSection struct {
|
||||
Version string `toml:"version,omitempty" json:"version,omitempty"`
|
||||
Path string `toml:"path,omitempty" json:"path,omitempty"`
|
||||
Modules []string `toml:"modules,omitempty" json:"modules,omitempty"`
|
||||
Cmdline string `toml:"cmdline,omitempty" json:"cmdline,omitempty"`
|
||||
}
|
||||
|
||||
// SecuritySection configures the security policy.
|
||||
type SecuritySection struct {
|
||||
LandlockProfile string `toml:"landlock_profile,omitempty" json:"landlock_profile,omitempty"`
|
||||
SeccompProfile string `toml:"seccomp_profile,omitempty" json:"seccomp_profile,omitempty"`
|
||||
Capabilities []string `toml:"capabilities,omitempty" json:"capabilities,omitempty"`
|
||||
ReadOnlyRootfs bool `toml:"read_only_rootfs,omitempty" json:"read_only_rootfs,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceSection configures cgroups v2 resource limits. All values use
|
||||
// human-readable strings (e.g. "512M", "2G") that are parsed at validation
|
||||
// time.
|
||||
type ResourceSection struct {
|
||||
MemoryLimit string `toml:"memory_limit,omitempty" json:"memory_limit,omitempty"`
|
||||
MemorySoft string `toml:"memory_soft,omitempty" json:"memory_soft,omitempty"`
|
||||
CPUWeight int `toml:"cpu_weight,omitempty" json:"cpu_weight,omitempty"`
|
||||
CPUSet string `toml:"cpu_set,omitempty" json:"cpu_set,omitempty"`
|
||||
IOWeight int `toml:"io_weight,omitempty" json:"io_weight,omitempty"`
|
||||
PidsMax int `toml:"pids_max,omitempty" json:"pids_max,omitempty"`
|
||||
}
|
||||
|
||||
// NetworkSection configures the container network.
|
||||
type NetworkSection struct {
|
||||
Mode NetworkMode `toml:"mode,omitempty" json:"mode,omitempty"`
|
||||
Address string `toml:"address,omitempty" json:"address,omitempty"`
|
||||
DNS []string `toml:"dns,omitempty" json:"dns,omitempty"`
|
||||
Ports []string `toml:"ports,omitempty" json:"ports,omitempty"`
|
||||
}
|
||||
|
||||
// StorageSection configures the rootfs and volumes.
|
||||
type StorageSection struct {
|
||||
Rootfs string `toml:"rootfs,omitempty" json:"rootfs,omitempty"`
|
||||
Volumes []VolumeMount `toml:"volumes,omitempty" json:"volumes,omitempty"`
|
||||
WritableLayer WritableLayerMode `toml:"writable_layer,omitempty" json:"writable_layer,omitempty"`
|
||||
}
|
||||
|
||||
// VolumeMount describes a bind mount from host to container.
|
||||
type VolumeMount struct {
|
||||
Host string `toml:"host" json:"host"`
|
||||
Container string `toml:"container" json:"container"`
|
||||
ReadOnly bool `toml:"readonly,omitempty" json:"readonly,omitempty"`
|
||||
}
|
||||
|
||||
// ── Parser ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// LoadFile reads a TOML manifest from disk and returns the parsed Manifest.
|
||||
// No validation or resolution is performed — call Validate() and Resolve()
|
||||
// separately.
|
||||
func LoadFile(path string) (*Manifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read manifest: %w", err)
|
||||
}
|
||||
|
||||
m, err := Parse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
m.SourcePath = path
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Parse decodes a TOML document into a Manifest.
|
||||
func Parse(data []byte) (*Manifest, error) {
|
||||
var m Manifest
|
||||
if err := toml.Unmarshal(data, &m); err != nil {
|
||||
return nil, fmt.Errorf("toml decode: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// Encode serializes a Manifest to TOML bytes.
|
||||
func Encode(m *Manifest) ([]byte, error) {
|
||||
buf := new(tomlBuffer)
|
||||
enc := toml.NewEncoder(buf)
|
||||
if err := enc.Encode(m); err != nil {
|
||||
return nil, fmt.Errorf("toml encode: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// tomlBuffer wraps a byte slice to satisfy io.Writer for the TOML encoder.
|
||||
type tomlBuffer struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (b *tomlBuffer) Write(p []byte) (int, error) {
|
||||
b.data = append(b.data, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (b *tomlBuffer) Bytes() []byte {
|
||||
return b.data
|
||||
}
|
||||
|
||||
// ── Convenience ──────────────────────────────────────────────────────────────
|
||||
|
||||
// IsHybrid returns true if the workload mode requires kernel isolation.
|
||||
func (m *Manifest) IsHybrid() bool {
|
||||
switch m.Workload.Mode {
|
||||
case ModeHybridNative, ModeHybridKVM, ModeHybridEmulated:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// NeedsKernel returns true if the workload mode requires a kernel path.
|
||||
func (m *Manifest) NeedsKernel() bool {
|
||||
return m.Workload.Mode == ModeHybridNative || m.Workload.Mode == ModeHybridKVM
|
||||
}
|
||||
|
||||
// HasCASRootfs returns true if the storage rootfs references the CAS store.
|
||||
func (m *Manifest) HasCASRootfs() bool {
|
||||
return len(m.Storage.Rootfs) > 6 && m.Storage.Rootfs[:6] == "cas://"
|
||||
}
|
||||
|
||||
// CASDigest extracts the digest from a cas:// reference, e.g.
|
||||
// "cas://sha256:abc123" → "sha256:abc123". Returns empty string if the
|
||||
// rootfs is not a CAS reference.
|
||||
func (m *Manifest) CASDigest() string {
|
||||
if !m.HasCASRootfs() {
|
||||
return ""
|
||||
}
|
||||
return m.Storage.Rootfs[6:]
|
||||
}
|
||||
337
pkg/manifest/resolve.go
Normal file
337
pkg/manifest/resolve.go
Normal file
@@ -0,0 +1,337 @@
|
||||
/*
|
||||
Manifest Resolution — Resolves variable substitutions, inheritance, and
|
||||
defaults for Volt v2 manifests.
|
||||
|
||||
Resolution pipeline:
|
||||
1. Load base manifest (if `extends` is set)
|
||||
2. Merge current manifest on top of base (current wins)
|
||||
3. Substitute ${VAR} references from environment and built-in vars
|
||||
4. Apply mode-specific defaults
|
||||
5. Fill missing optional fields with sensible defaults
|
||||
|
||||
Copyright (c) Armored Gates LLC. All rights reserved.
|
||||
*/
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ── Built-in Variables ───────────────────────────────────────────────────────
|
||||
|
||||
// builtinVars returns the set of variables that are always available for
|
||||
// substitution, regardless of the environment.
|
||||
func builtinVars() map[string]string {
|
||||
hostname, _ := os.Hostname()
|
||||
return map[string]string{
|
||||
"HOSTNAME": hostname,
|
||||
"VOLT_BASE": "/var/lib/volt",
|
||||
"VOLT_CAS_DIR": "/var/lib/volt/cas",
|
||||
"VOLT_RUN_DIR": "/var/run/volt",
|
||||
}
|
||||
}
|
||||
|
||||
// varRegex matches ${VAR_NAME} patterns. Supports alphanumeric, underscores,
|
||||
// and dots.
|
||||
var varRegex = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_.]*)\}`)
|
||||
|
||||
// ── Resolve ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Resolve performs the full resolution pipeline on a manifest:
|
||||
// 1. Extends (inheritance)
|
||||
// 2. Variable substitution
|
||||
// 3. Default values
|
||||
//
|
||||
// The manifest is modified in place and also returned for convenience.
|
||||
// envOverrides provides additional variables that take precedence over both
|
||||
// built-in vars and the OS environment.
|
||||
func Resolve(m *Manifest, envOverrides map[string]string) (*Manifest, error) {
|
||||
// Step 1: Handle extends (inheritance).
|
||||
if m.Extends != "" {
|
||||
base, err := resolveExtends(m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve extends: %w", err)
|
||||
}
|
||||
mergeManifest(base, m)
|
||||
*m = *base
|
||||
}
|
||||
|
||||
// Step 2: Variable substitution.
|
||||
substituteVars(m, envOverrides)
|
||||
|
||||
// Step 3: Apply defaults.
|
||||
applyDefaults(m)
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ── Extends / Inheritance ────────────────────────────────────────────────────
|
||||
|
||||
// resolveExtends loads the base manifest referenced by m.Extends. The path
|
||||
// is resolved relative to the current manifest's SourcePath directory, or as
|
||||
// an absolute path.
|
||||
func resolveExtends(m *Manifest) (*Manifest, error) {
|
||||
ref := m.Extends
|
||||
|
||||
// Resolve relative to the current manifest file.
|
||||
basePath := ref
|
||||
if !filepath.IsAbs(ref) && m.SourcePath != "" {
|
||||
basePath = filepath.Join(filepath.Dir(m.SourcePath), ref)
|
||||
}
|
||||
|
||||
// Check if it's a CAS reference.
|
||||
if strings.HasPrefix(ref, "cas://") {
|
||||
return nil, fmt.Errorf("CAS-based extends not yet implemented: %s", ref)
|
||||
}
|
||||
|
||||
base, err := LoadFile(basePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load base manifest %s: %w", basePath, err)
|
||||
}
|
||||
|
||||
// Recursively resolve the base manifest (supports chained extends).
|
||||
if base.Extends != "" {
|
||||
if _, err := resolveExtends(base); err != nil {
|
||||
return nil, fmt.Errorf("resolve parent %s: %w", basePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// mergeManifest overlays child values onto base. Non-zero child values
|
||||
// overwrite base values. Slices are replaced (not appended) when non-nil.
|
||||
func mergeManifest(base, child *Manifest) {
|
||||
// Workload — child always wins for non-empty fields.
|
||||
if child.Workload.Name != "" {
|
||||
base.Workload.Name = child.Workload.Name
|
||||
}
|
||||
if child.Workload.Mode != "" {
|
||||
base.Workload.Mode = child.Workload.Mode
|
||||
}
|
||||
if child.Workload.Image != "" {
|
||||
base.Workload.Image = child.Workload.Image
|
||||
}
|
||||
if child.Workload.Description != "" {
|
||||
base.Workload.Description = child.Workload.Description
|
||||
}
|
||||
|
||||
// Kernel.
|
||||
if child.Kernel.Version != "" {
|
||||
base.Kernel.Version = child.Kernel.Version
|
||||
}
|
||||
if child.Kernel.Path != "" {
|
||||
base.Kernel.Path = child.Kernel.Path
|
||||
}
|
||||
if child.Kernel.Modules != nil {
|
||||
base.Kernel.Modules = child.Kernel.Modules
|
||||
}
|
||||
if child.Kernel.Cmdline != "" {
|
||||
base.Kernel.Cmdline = child.Kernel.Cmdline
|
||||
}
|
||||
|
||||
// Security.
|
||||
if child.Security.LandlockProfile != "" {
|
||||
base.Security.LandlockProfile = child.Security.LandlockProfile
|
||||
}
|
||||
if child.Security.SeccompProfile != "" {
|
||||
base.Security.SeccompProfile = child.Security.SeccompProfile
|
||||
}
|
||||
if child.Security.Capabilities != nil {
|
||||
base.Security.Capabilities = child.Security.Capabilities
|
||||
}
|
||||
if child.Security.ReadOnlyRootfs {
|
||||
base.Security.ReadOnlyRootfs = child.Security.ReadOnlyRootfs
|
||||
}
|
||||
|
||||
// Resources.
|
||||
if child.Resources.MemoryLimit != "" {
|
||||
base.Resources.MemoryLimit = child.Resources.MemoryLimit
|
||||
}
|
||||
if child.Resources.MemorySoft != "" {
|
||||
base.Resources.MemorySoft = child.Resources.MemorySoft
|
||||
}
|
||||
if child.Resources.CPUWeight != 0 {
|
||||
base.Resources.CPUWeight = child.Resources.CPUWeight
|
||||
}
|
||||
if child.Resources.CPUSet != "" {
|
||||
base.Resources.CPUSet = child.Resources.CPUSet
|
||||
}
|
||||
if child.Resources.IOWeight != 0 {
|
||||
base.Resources.IOWeight = child.Resources.IOWeight
|
||||
}
|
||||
if child.Resources.PidsMax != 0 {
|
||||
base.Resources.PidsMax = child.Resources.PidsMax
|
||||
}
|
||||
|
||||
// Network.
|
||||
if child.Network.Mode != "" {
|
||||
base.Network.Mode = child.Network.Mode
|
||||
}
|
||||
if child.Network.Address != "" {
|
||||
base.Network.Address = child.Network.Address
|
||||
}
|
||||
if child.Network.DNS != nil {
|
||||
base.Network.DNS = child.Network.DNS
|
||||
}
|
||||
if child.Network.Ports != nil {
|
||||
base.Network.Ports = child.Network.Ports
|
||||
}
|
||||
|
||||
// Storage.
|
||||
if child.Storage.Rootfs != "" {
|
||||
base.Storage.Rootfs = child.Storage.Rootfs
|
||||
}
|
||||
if child.Storage.Volumes != nil {
|
||||
base.Storage.Volumes = child.Storage.Volumes
|
||||
}
|
||||
if child.Storage.WritableLayer != "" {
|
||||
base.Storage.WritableLayer = child.Storage.WritableLayer
|
||||
}
|
||||
|
||||
// Clear extends — the chain has been resolved.
|
||||
base.Extends = ""
|
||||
}
|
||||
|
||||
// ── Variable Substitution ────────────────────────────────────────────────────
|
||||
|
||||
// substituteVars replaces ${VAR} patterns throughout all string fields of the
|
||||
// manifest. Resolution order: envOverrides > OS environment > built-in vars.
|
||||
func substituteVars(m *Manifest, envOverrides map[string]string) {
|
||||
vars := builtinVars()
|
||||
|
||||
// Layer OS environment on top.
|
||||
for _, kv := range os.Environ() {
|
||||
parts := strings.SplitN(kv, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
vars[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Layer explicit overrides on top (highest priority).
|
||||
for k, v := range envOverrides {
|
||||
vars[k] = v
|
||||
}
|
||||
|
||||
resolve := func(s string) string {
|
||||
return varRegex.ReplaceAllStringFunc(s, func(match string) string {
|
||||
// Extract variable name from ${NAME}.
|
||||
varName := match[2 : len(match)-1]
|
||||
if val, ok := vars[varName]; ok {
|
||||
return val
|
||||
}
|
||||
// Leave unresolved variables in place.
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
// Walk all string fields.
|
||||
m.Workload.Name = resolve(m.Workload.Name)
|
||||
m.Workload.Image = resolve(m.Workload.Image)
|
||||
m.Workload.Description = resolve(m.Workload.Description)
|
||||
|
||||
m.Kernel.Version = resolve(m.Kernel.Version)
|
||||
m.Kernel.Path = resolve(m.Kernel.Path)
|
||||
m.Kernel.Cmdline = resolve(m.Kernel.Cmdline)
|
||||
for i := range m.Kernel.Modules {
|
||||
m.Kernel.Modules[i] = resolve(m.Kernel.Modules[i])
|
||||
}
|
||||
|
||||
m.Security.LandlockProfile = resolve(m.Security.LandlockProfile)
|
||||
m.Security.SeccompProfile = resolve(m.Security.SeccompProfile)
|
||||
for i := range m.Security.Capabilities {
|
||||
m.Security.Capabilities[i] = resolve(m.Security.Capabilities[i])
|
||||
}
|
||||
|
||||
m.Resources.MemoryLimit = resolve(m.Resources.MemoryLimit)
|
||||
m.Resources.MemorySoft = resolve(m.Resources.MemorySoft)
|
||||
m.Resources.CPUSet = resolve(m.Resources.CPUSet)
|
||||
|
||||
m.Network.Address = resolve(m.Network.Address)
|
||||
for i := range m.Network.DNS {
|
||||
m.Network.DNS[i] = resolve(m.Network.DNS[i])
|
||||
}
|
||||
for i := range m.Network.Ports {
|
||||
m.Network.Ports[i] = resolve(m.Network.Ports[i])
|
||||
}
|
||||
|
||||
m.Storage.Rootfs = resolve(m.Storage.Rootfs)
|
||||
for i := range m.Storage.Volumes {
|
||||
m.Storage.Volumes[i].Host = resolve(m.Storage.Volumes[i].Host)
|
||||
m.Storage.Volumes[i].Container = resolve(m.Storage.Volumes[i].Container)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Default Values ───────────────────────────────────────────────────────────
|
||||
|
||||
// applyDefaults fills missing optional fields with sensible default values.
|
||||
// Mode-specific logic is applied — e.g. container mode clears kernel section.
|
||||
func applyDefaults(m *Manifest) {
|
||||
// ── Security defaults ────────────────────────────────────────────────
|
||||
if m.Security.LandlockProfile == "" {
|
||||
m.Security.LandlockProfile = string(LandlockDefault)
|
||||
}
|
||||
if m.Security.SeccompProfile == "" {
|
||||
m.Security.SeccompProfile = "default"
|
||||
}
|
||||
|
||||
// ── Resource defaults ────────────────────────────────────────────────
|
||||
if m.Resources.CPUWeight == 0 {
|
||||
m.Resources.CPUWeight = 100
|
||||
}
|
||||
if m.Resources.IOWeight == 0 {
|
||||
m.Resources.IOWeight = 100
|
||||
}
|
||||
if m.Resources.PidsMax == 0 {
|
||||
m.Resources.PidsMax = 4096
|
||||
}
|
||||
|
||||
// ── Network defaults ─────────────────────────────────────────────────
|
||||
if m.Network.Mode == "" {
|
||||
m.Network.Mode = NetworkBridge
|
||||
}
|
||||
if len(m.Network.DNS) == 0 {
|
||||
m.Network.DNS = []string{"1.1.1.1", "1.0.0.1"}
|
||||
}
|
||||
|
||||
// ── Storage defaults ─────────────────────────────────────────────────
|
||||
if m.Storage.WritableLayer == "" {
|
||||
m.Storage.WritableLayer = WritableOverlay
|
||||
}
|
||||
|
||||
// ── Mode-specific adjustments ────────────────────────────────────────
|
||||
switch m.Workload.Mode {
|
||||
case ModeContainer:
|
||||
// Container mode does not use a custom kernel. Clear the kernel
|
||||
// section to avoid confusion.
|
||||
m.Kernel = KernelSection{}
|
||||
|
||||
case ModeHybridNative:
|
||||
// Ensure sensible kernel module defaults for hybrid-native.
|
||||
if len(m.Kernel.Modules) == 0 {
|
||||
m.Kernel.Modules = []string{"overlay", "br_netfilter", "veth"}
|
||||
}
|
||||
if m.Kernel.Cmdline == "" {
|
||||
m.Kernel.Cmdline = "console=ttyS0 quiet"
|
||||
}
|
||||
|
||||
case ModeHybridKVM:
|
||||
// KVM mode benefits from slightly more memory by default.
|
||||
if m.Resources.MemoryLimit == "" {
|
||||
m.Resources.MemoryLimit = "1G"
|
||||
}
|
||||
if m.Kernel.Cmdline == "" {
|
||||
m.Kernel.Cmdline = "console=ttyS0 quiet"
|
||||
}
|
||||
|
||||
case ModeHybridEmulated:
|
||||
// Emulated mode is CPU-heavy; give it a larger PID space.
|
||||
if m.Resources.PidsMax == 4096 {
|
||||
m.Resources.PidsMax = 8192
|
||||
}
|
||||
}
|
||||
}
|
||||
561
pkg/manifest/validate.go
Normal file
561
pkg/manifest/validate.go
Normal file
@@ -0,0 +1,561 @@
|
||||
/*
|
||||
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]
|
||||
}
|
||||
Reference in New Issue
Block a user