diff --git a/core/go.mod b/core/go.mod index c3349abda..691e978b6 100644 --- a/core/go.mod +++ b/core/go.mod @@ -77,7 +77,6 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-containerregistry v0.21.6 // indirect github.com/google/go-intervals v0.0.2 // indirect @@ -127,9 +126,6 @@ require ( github.com/vbauerster/cupwriter v0.0.4 // indirect github.com/vbauerster/mpb/v8 v8.14.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/zitadel/oidc/v3 v3.49.4 // indirect github.com/zitadel/schema v1.3.2 // indirect diff --git a/core/go.sum b/core/go.sum index 12791d6ab..ab131797f 100644 --- a/core/go.sum +++ b/core/go.sum @@ -105,8 +105,6 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -312,13 +310,6 @@ github.com/vbauerster/mpb/v8 v8.14.0 h1:55SR80dptMfASxIG/oCEkBXgBhxeSu4GrVsjl16o github.com/vbauerster/mpb/v8 v8.14.0/go.mod h1:HgpQPKfcWe3kbuGGPmi+jatHreMase5C3Fp5dpdAy0Q= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/core/pkg/container/cli/parse.go b/core/pkg/container/cli/parse.go index 481caec54..74a0083b1 100644 --- a/core/pkg/container/cli/parse.go +++ b/core/pkg/container/cli/parse.go @@ -7,9 +7,7 @@ package cli import ( - "fmt" - "strings" - + "drassi.run/core/pkg/container/parser" "drassi.run/core/pkg/container/types" "github.com/google/shlex" "github.com/spf13/pflag" @@ -69,8 +67,8 @@ func (fm *flagMapper) mapContainerSpec(flags *pflag.FlagSet, copts *containerOpt Name: copts.name, PullPolicy: copts.pull, WorkingDir: copts.workingDir, - Environment: ConvertKVStringsToMap(copts.env.GetAllOrEmpty()), - Labels: ConvertKVStringsToMap(copts.labels.GetAllOrEmpty()), + Environment: parser.ConvertKVStringsToMap(copts.env.GetAllOrEmpty()), + Labels: parser.ConvertKVStringsToMap(copts.labels.GetAllOrEmpty()), Annotations: copts.annotations.GetAll(), } spec := fm.Spec @@ -104,27 +102,3 @@ func (fm *flagMapper) mapContainerSpec(flags *pflag.FlagSet, copts *containerOpt return nil } - -// ConvertMapToKVString converts {"key":"value"} to ["key=value"] -func ConvertMapToKVString(m map[string]string) []string { - if len(m) == 0 { - return nil - } - - r := make([]string, 0, len(m)) - for k, v := range m { - r = append(r, fmt.Sprintf("%s=%s", k, v)) - } - return r -} - -// ConvertKVStringsToMap converts ["key=value"] to {"key":"value"} -func ConvertKVStringsToMap(values []string) map[string]string { - result := make(map[string]string, len(values)) - for _, value := range values { - k, v, _ := strings.Cut(value, "=") - result[k] = v - } - - return result -} diff --git a/core/pkg/container/cli/parse_network.go b/core/pkg/container/cli/parse_network.go index 878eee372..8d1dab33b 100644 --- a/core/pkg/container/cli/parse_network.go +++ b/core/pkg/container/cli/parse_network.go @@ -7,13 +7,11 @@ package cli import ( - "errors" "fmt" "net" "net/netip" - "strconv" - "strings" + "drassi.run/core/pkg/container/parser" "drassi.run/core/pkg/container/types" ) @@ -39,7 +37,7 @@ func (fm *flagMapper) mapNetwork(copts *containerOptions) error { func (fm *flagMapper) mapExposes(copts *containerOptions) error { for _, opt := range copts.expose.GetAllOrEmpty() { - if p, length, err := ParseExpose(opt); err != nil { + if p, length, err := parser.ParseExpose(opt); err != nil { return err } else { for i := range length { @@ -53,7 +51,7 @@ func (fm *flagMapper) mapExposes(copts *containerOptions) error { func (fm *flagMapper) mapPublish(copts *containerOptions) error { for _, opt := range copts.publish.GetAllOrEmpty() { - if p, length, err := ParsePublish(opt); err != nil { + if p, length, err := parser.ParsePublish(opt); err != nil { return err } else { for i := range length { @@ -122,7 +120,7 @@ func (fm *flagMapper) mapEndpoints(copts *containerOptions) error { } func (fm *flagMapper) mapDNS(copts *containerOptions) error { - dns := &fm.Spec.DNS + dns := fm.Spec.DNS if copts.dns.Len() > 0 { servers := make([]netip.Addr, 0, copts.dns.Len()) for _, s := range copts.dns.GetAllOrEmpty() { @@ -143,7 +141,7 @@ func (fm *flagMapper) mapDNS(copts *containerOptions) error { dns.HostAdd = make(map[string][]string) } for _, h := range copts.extraHosts.GetAllOrEmpty() { - if host, ips, err := ParseHost(h); err != nil { + if host, ips, err := parser.ParseHost(h); err != nil { return err } else if exist, ok := dns.HostAdd[host]; ok { dns.HostAdd[host] = append(exist, ips...) @@ -154,163 +152,3 @@ func (fm *flagMapper) mapDNS(copts *containerOptions) error { return nil } - -// ParseExpose parses user-provided exposed port definitions into types.Port format -// - [github.com/containers/podman/v5/pkg/specgenutil.CreateExpose] -func ParseExpose(str string) (*types.Port, uint16, error) { - remains, expose := str, new(types.Port) - - if r, p, err := SplitProto(remains); err != nil { - return nil, 0, err - } else { - remains, expose.Protocol = r, p - } - - if port, length, err := ParsePortRange(remains); err != nil { - return nil, 0, err - } else { - expose.Number = port - return expose, length, nil - } -} - -// ParsePublish parses user-provided publish definitions into types.PortBinding format -// - [github.com/compose-spec/compose-go/v2/types.ParsePortConfig] -// - [github.com/docker/go-connections/nat.ParsePortSpec] -// - [github.com/containers/podman/v5/pkg/specgenutil.CreatePortBindings] -func ParsePublish(str string) (*types.PortBinding, uint16, error) { - remains := str - var hostIP, hostPort, containerPort, proto string - - if r, p, err := SplitProto(remains); err != nil { - return nil, 0, err - } else { - remains, proto = r, p - } - - if idx := strings.LastIndexByte(remains, ':'); idx != -1 { - remains, containerPort = remains[:idx], remains[idx+1:] - } else { - remains, containerPort = "", remains - } - - if remains != "" { - if !strings.ContainsRune(remains, ':') { - remains, hostPort = "", remains - } else if host, port, err := net.SplitHostPort(remains); err != nil { - return nil, 0, fmt.Errorf("invalid publish: %s - %s", str, err) - } else { - remains, hostIP, hostPort = "", host, port - } - } - - length := uint16(0) - publish := &types.PortBinding{ - HostIP: hostIP, - Protocol: proto, - } - - if port, portRange, err := ParsePortRange(containerPort); err != nil { - return nil, 0, err - } else { - publish.ContainerPort = port - length = portRange - } - - if hostPort == "" { - return publish, length, nil - } - - if port, portRange, err := ParsePortRange(hostPort); err != nil { - return nil, 0, err - } else { - publish.HostPort = port - if portRange > 1 { - if length > 1 && length != portRange { - return nil, 0, fmt.Errorf("invalid publish %q : port-range mismatch", str) - } - length = portRange - } - } - - return publish, length, nil -} - -var hostListSeparators = []string{"=", ":"} - -// ParseHost parses user-provided additional host into hostname and list of IPs -// - [github.com/compose-spec/compose-go/v2/types.NewHostsList] -func ParseHost(s string) (string, []string, error) { - for _, sep := range hostListSeparators { - host, ip, ok := strings.Cut(s, sep) - if ok { - return host, strings.Split(ip, ","), nil - } - } - - return "", nil, fmt.Errorf("invalid additional host, missing IP: %s", s) -} - -func SplitProto(s string) (string, string, error) { - splits := strings.SplitN(s, "/", 3) - if len(splits) > 2 { - return "", "", fmt.Errorf("invalid protocol: %s - multiple protocols", s) - } else if len(splits) == 2 { - remains, proto := splits[0], splits[1] - if proto == "" { - proto = "tcp" - } - return remains, proto, nil - } - return s, "tcp", nil -} - -// ParsePortRange parses specified string as a port-range -// - https://github.com/containers/podman/blob/v5.2.5/pkg/specgenutil/util.go#L216 -// - [github.com/docker/go-connections/nat.ParsePortRange] -func ParsePortRange(portRange string) (uint16, uint16, error) { - var ( - port string = portRange - endPort *string = nil - ) - - if splits := strings.SplitN(portRange, "-", 3); len(splits) > 2 { - return 0, 0, fmt.Errorf("invalid portRange: %s - too many parts", portRange) - } else if len(splits) == 2 { - port, endPort = splits[0], &splits[1] - } - - var portNum uint16 - if num, err := ParsePort(port); err != nil { - return 0, 0, err - } else { - portNum = num - } - - if endPort != nil { - if num, err := ParsePort(*endPort); err != nil { - return 0, 0, err - } else if portNum >= num { - return 0, 0, fmt.Errorf("invalid portRange: %s - startPort >= endPort", portRange) - } else { - length := num - portNum + 1 - return portNum, length, nil - } - } - - return portNum, 1, nil -} - -// ParsePort turn a single string into a valid U16 port. -// - https://github.com/containers/podman/blob/v5.2.5/pkg/specgenutil/util.go#L253-L262 -// - [github.com/docker/go-connections/nat.ParsePort] -func ParsePort(port string) (uint16, error) { - num, err := strconv.ParseUint(port, 10, 16) - if err != nil { - if errors.Is(err, strconv.ErrRange) { - return 0, fmt.Errorf("invalid port: %s - must be in range [1, 65535]", port) - } - return 0, fmt.Errorf("invalid port: %s - %w", port, err) - } - return uint16(num), nil -} diff --git a/core/pkg/container/cli/parse_resource.go b/core/pkg/container/cli/parse_resource.go index 0ae0e9549..d8090153e 100644 --- a/core/pkg/container/cli/parse_resource.go +++ b/core/pkg/container/cli/parse_resource.go @@ -18,7 +18,7 @@ func (fm *flagMapper) mapResource(copts *containerOptions) error { //// Applicable to all platforms res.CPUShares = copts.cpuShares res.CPUS = copts.cpus.String() - res.Memory = int64(copts.memory) + res.Memory = types.UnitBytes(copts.memory) //// Applicable to Windows res.CPUCount = copts.cpuCount res.CPUPercent = float32(copts.cpuPercent) / 100.0 @@ -29,10 +29,10 @@ func (fm *flagMapper) mapResource(copts *containerOptions) error { res.CPURTRuntime = copts.cpuRealtimeRuntime res.CpusetCpus = copts.cpusetCpus res.CpusetMems = copts.cpusetMems - res.MemReservation = int64(copts.memoryReservation) - res.MemSwapLimit = int64(copts.memorySwap) + res.MemReservation = types.UnitBytes(copts.memoryReservation) + res.MemSwapLimit = types.UnitBytes(copts.memorySwap) res.MemSwappiness = copts.swappiness - res.ShmSize = int64(copts.shmSize) + res.ShmSize = types.UnitBytes(copts.shmSize) res.OomKillDisable = copts.oomKillDisable res.OomScoreAdj = int64(copts.oomScoreAdj) res.PidsLimit = copts.pidsLimit diff --git a/core/pkg/container/cli/parse_runtime.go b/core/pkg/container/cli/parse_runtime.go index bdb307e4e..d3631bcd3 100644 --- a/core/pkg/container/cli/parse_runtime.go +++ b/core/pkg/container/cli/parse_runtime.go @@ -10,6 +10,7 @@ import ( "fmt" "time" + "drassi.run/core/pkg/container/parser" "drassi.run/core/pkg/container/types" "github.com/spf13/pflag" ) @@ -40,7 +41,7 @@ func (fm *flagMapper) mapLogging(copts *containerOptions) error { // https://github.com/docker/cli/blob/v29.7.2/cli/command/container/opts.go#L920-L926 driver := copts.loggingDriver - options := ConvertKVStringsToMap(copts.loggingOpts.GetAllOrEmpty()) + options := parser.ConvertKVStringsToMap(copts.loggingOpts.GetAllOrEmpty()) if driver == "none" && len(options) > 0 { return fmt.Errorf("invalid logging opts for driver %s", driver) } diff --git a/core/pkg/container/cli/parse_storage.go b/core/pkg/container/cli/parse_storage.go index 38233ae61..66ce401bf 100644 --- a/core/pkg/container/cli/parse_storage.go +++ b/core/pkg/container/cli/parse_storage.go @@ -7,16 +7,11 @@ package cli import ( - "errors" "fmt" - "io/fs" - "path/filepath" - "strconv" "strings" + "drassi.run/core/pkg/container/parser" "drassi.run/core/pkg/container/types" - "github.com/docker/cli/cli/compose/loader" - "github.com/docker/go-units" dockermount "github.com/moby/moby/api/types/mount" ) @@ -26,14 +21,14 @@ func (fm *flagMapper) mapStorage(copts *containerOptions) error { fm.Spec.Mounts = append(fm.Spec.Mounts, mount) } for _, v := range copts.volumes.GetAllOrEmpty() { - if mount, err := ParseVolume(v); err != nil { + if mount, err := parser.ParseVolume(v); err != nil { return err } else { fm.Spec.Mounts = append(fm.Spec.Mounts, mount) } } for _, t := range copts.tmpfs.GetAllOrEmpty() { - if mount, err := ParseTmpfs(t); err != nil { + if mount, err := parser.ParseTmpfs(t); err != nil { return err } else { fm.Spec.Mounts = append(fm.Spec.Mounts, mount) @@ -63,38 +58,6 @@ func (fm *flagMapper) mapStorage(copts *containerOptions) error { return nil } -// ParseVolume parses user-provided volume definitions into types.Mount format -// - [github.com/containers/podman/v5/pkg/specgen.GenVolumeMounts] -func ParseVolume(v string) (*types.Mount, error) { - parsed, err := loader.ParseVolume(v) - if err != nil { - return nil, err - } - mount := &types.Mount{ - Type: parsed.Type, - Source: parsed.Source, - Target: parsed.Target, - ReadOnly: parsed.ReadOnly, - } - if bind := parsed.Bind; bind != nil { - mount.BindOptions = &types.BindOptions{ - Propagation: bind.Propagation, - Consistency: parsed.Consistency, - } - } - if volume := parsed.Volume; volume != nil { - mount.VolumeOptions = &types.VolumeOptions{ - NoCopy: volume.NoCopy, - } - } - if tmp := parsed.Tmpfs; tmp != nil { - mount.TmpfsOptions = &types.TmpfsOptions{ - Size: tmp.Size, - } - } - return mount, nil -} - func parseMount(m dockermount.Mount) *types.Mount { mount := &types.Mount{ Type: string(m.Type), @@ -134,54 +97,6 @@ func parseMount(m dockermount.Mount) *types.Mount { return mount } -// ParseTmpfs parses user-provided tmpfs definitions into types.Mount format -// - https://github.com/containers/podman/blob/v5.2.5/pkg/specgenutil/volumes.go#L645 -func ParseTmpfs(t string) (*types.Mount, error) { - split := strings.Split(t, ":") - target := split[0] - if err := validateVolumeContainerDir(target); err != nil { - return nil, err - } - mount := &types.Mount{ - Type: "tmpfs", - Target: target, - } - - if len(split) > 1 { - options := strings.Split(split[1], ",") - mount.TmpfsOptions = &types.TmpfsOptions{} - for _, opt := range options { - k, v, _ := strings.Cut(opt, "=") - k = strings.ToLower(k) - switch k { - case "size": - if size, err := units.RAMInBytes(v); err != nil { - return nil, err - } else { - mount.TmpfsOptions.Size = size - } - case "readonly", "ro": - mount.ReadOnly = true - case "readwrite", "rw": - mount.ReadOnly = false - case "mode": - if ui64, err := strconv.ParseUint(v, 8, 32); err != nil { - return nil, err - } else { - mount.TmpfsOptions.Mode = fs.FileMode(ui64) - } - default: - o := []string{k} - if v != "" { - o = append(o, v) - } - mount.TmpfsOptions.Options = append(mount.TmpfsOptions.Options, o) - } - } - } - return mount, nil -} - // parses storage options per container into a map // https://github.com/docker/cli/blob/v29.7.2/cli/command/container/opts.go#L981-L992 func parseStorageOpts(storageOpts []string) (map[string]string, error) { @@ -195,14 +110,3 @@ func parseStorageOpts(storageOpts []string) (map[string]string, error) { } return m, nil } - -// ValidateVolumeCtrDir validates a volume mount's destination directory. -func validateVolumeContainerDir(path string) error { - if path == "" { - return errors.New("container directory cannot be empty") - } - if !filepath.IsAbs(path) { - return fmt.Errorf("invalid container path %q, must be an absolute path", path) - } - return nil -} diff --git a/core/pkg/container/docker/config.go b/core/pkg/container/docker/config.go index 552f8786e..62683980f 100644 --- a/core/pkg/container/docker/config.go +++ b/core/pkg/container/docker/config.go @@ -7,7 +7,7 @@ package docker import ( - "drassi.run/core/pkg/container/cli" + "drassi.run/core/pkg/container/parser" "drassi.run/core/pkg/container/types" dockercontainer "github.com/moby/moby/api/types/container" dockernetwork "github.com/moby/moby/api/types/network" @@ -32,7 +32,7 @@ func (cc *containerConfig) From(spec *types.ContainerSpec, stdio *types.Stdio) e Entrypoint: spec.Entrypoint, Cmd: spec.Command, WorkingDir: spec.WorkingDir, - Env: cli.ConvertMapToKVString(spec.Environment), + Env: parser.ConvertMapToKVString(spec.Environment), Labels: spec.Labels, } @@ -79,7 +79,7 @@ func (cs *containerSpec) From(info dockercontainer.InspectResponse) error { Command: c.Cmd, Entrypoint: c.Entrypoint, WorkingDir: c.WorkingDir, - Environment: cli.ConvertKVStringsToMap(c.Env), + Environment: parser.ConvertKVStringsToMap(c.Env), Labels: c.Labels, Annotations: hc.Annotations, } diff --git a/core/pkg/container/docker/config_network.go b/core/pkg/container/docker/config_network.go index b29e6acf9..657f27670 100644 --- a/core/pkg/container/docker/config_network.go +++ b/core/pkg/container/docker/config_network.go @@ -12,7 +12,7 @@ import ( "strconv" "strings" - "drassi.run/core/pkg/container/cli" + "drassi.run/core/pkg/container/parser" "drassi.run/core/pkg/container/types" dockercontainer "github.com/moby/moby/api/types/container" dockernetwork "github.com/moby/moby/api/types/network" @@ -24,7 +24,7 @@ func (cc *containerConfig) setNetwork(conf *types.ContainerNetwork) { cc.setExpose(conf.Exposes) cc.setPublish(conf.Publish) cc.HostConfig.PublishAllPorts = conf.PublishAll - cc.setDNS(&conf.DNS) + cc.setDNS(conf.DNS) cc.setNetworkEndpoints(conf.Endpoints) } @@ -176,7 +176,7 @@ func (cs *containerSpec) setPublish(publishes dockernetwork.PortMap) error { } func (cs *containerSpec) setDNS(c *dockercontainer.Config, hc *dockercontainer.HostConfig) error { - cs.Spec.DNS = types.DNS{ + cs.Spec.DNS = &types.DNS{ Servers: hc.DNS, Options: hc.DNSOptions, Search: hc.DNSSearch, @@ -189,7 +189,7 @@ func (cs *containerSpec) setDNS(c *dockercontainer.Config, hc *dockercontainer.H extraHost := make(map[string][]string) for _, h := range hc.ExtraHosts { - if host, ips, err := cli.ParseHost(h); err != nil { + if host, ips, err := parser.ParseHost(h); err != nil { return err } else if exist, ok := extraHost[host]; ok { extraHost[host] = append(exist, ips...) diff --git a/core/pkg/container/docker/config_resource.go b/core/pkg/container/docker/config_resource.go index 03744967f..a1b070c9b 100644 --- a/core/pkg/container/docker/config_resource.go +++ b/core/pkg/container/docker/config_resource.go @@ -17,7 +17,7 @@ func (cc *containerConfig) setResources(conf *types.ContainerResource) error { res := dockercontainer.Resources{ //// Applicable to all platforms CPUShares: conf.CPUShares, - Memory: conf.Memory, + Memory: int64(conf.Memory), //// Applicable to Windows CPUCount: conf.CPUCount, @@ -32,8 +32,8 @@ func (cc *containerConfig) setResources(conf *types.ContainerResource) error { CPURealtimeRuntime: conf.CPURTRuntime, CpusetCpus: conf.CpusetCpus, CpusetMems: conf.CpusetMems, - MemoryReservation: conf.MemReservation, - MemorySwap: conf.MemSwapLimit, + MemoryReservation: int64(conf.MemReservation), + MemorySwap: int64(conf.MemSwapLimit), MemorySwappiness: &conf.MemSwappiness, OomKillDisable: &conf.OomKillDisable, PidsLimit: &conf.PidsLimit, @@ -58,7 +58,7 @@ func (cc *containerConfig) setResources(conf *types.ContainerResource) error { hc := cc.HostConfig hc.Resources = res - hc.ShmSize = conf.ShmSize + hc.ShmSize = int64(conf.ShmSize) hc.OomScoreAdj = int(conf.OomScoreAdj) return nil @@ -99,7 +99,7 @@ func (cs *containerSpec) setResources(hc *dockercontainer.HostConfig) { //// Applicable to all platforms CPUShares: res.CPUShares, CPUS: cpu.String(), - Memory: res.Memory, + Memory: types.UnitBytes(res.Memory), //// Applicable to Windows CPUCount: res.CPUCount, @@ -114,8 +114,8 @@ func (cs *containerSpec) setResources(hc *dockercontainer.HostConfig) { CPURTRuntime: res.CPURealtimeRuntime, CpusetCpus: res.CpusetCpus, CpusetMems: res.CpusetMems, - MemReservation: res.MemoryReservation, - MemSwapLimit: res.MemorySwap, + MemReservation: types.UnitBytes(res.MemoryReservation), + MemSwapLimit: types.UnitBytes(res.MemorySwap), } r := &cs.Spec.ContainerResource if res.MemorySwappiness != nil { @@ -127,7 +127,7 @@ func (cs *containerSpec) setResources(hc *dockercontainer.HostConfig) { if res.PidsLimit != nil { r.PidsLimit = *res.PidsLimit } - r.ShmSize = hc.ShmSize + r.ShmSize = types.UnitBytes(hc.ShmSize) r.OomScoreAdj = int64(hc.OomScoreAdj) r.Ulimits = res.Ulimits diff --git a/core/pkg/container/docker/config_storage.go b/core/pkg/container/docker/config_storage.go index 2c2d27c1a..224987cd7 100644 --- a/core/pkg/container/docker/config_storage.go +++ b/core/pkg/container/docker/config_storage.go @@ -7,7 +7,7 @@ package docker import ( - "drassi.run/core/pkg/container/cli" + "drassi.run/core/pkg/container/parser" "drassi.run/core/pkg/container/types" dockercontainer "github.com/moby/moby/api/types/container" dockermount "github.com/moby/moby/api/types/mount" @@ -91,7 +91,7 @@ func (cs *containerSpec) setStorage(info dockercontainer.InspectResponse) error func (cs *containerSpec) setTmpfs(tmpfs map[string]string) error { for k, v := range tmpfs { - if mount, err := cli.ParseTmpfs(k + ":" + v); err != nil { + if mount, err := parser.ParseTmpfs(k + ":" + v); err != nil { return err } else { cs.Spec.Mounts = append(cs.Spec.Mounts, mount) @@ -102,7 +102,7 @@ func (cs *containerSpec) setTmpfs(tmpfs map[string]string) error { func (cs *containerSpec) setVolumes(volumes []string) error { for _, v := range volumes { - if mount, err := cli.ParseVolume(v); err != nil { + if mount, err := parser.ParseVolume(v); err != nil { return err } else { cs.Spec.Mounts = append(cs.Spec.Mounts, mount) diff --git a/core/pkg/container/docker/engine.go b/core/pkg/container/docker/engine.go index 00c0c99ab..e8a92e551 100644 --- a/core/pkg/container/docker/engine.go +++ b/core/pkg/container/docker/engine.go @@ -17,7 +17,7 @@ import ( "time" "drassi.run/core/pkg/container" - "drassi.run/core/pkg/container/cli" + "drassi.run/core/pkg/container/parser" "drassi.run/core/pkg/container/types" "drassi.run/core/pkg/stream" xcontext "drassi.run/core/util/context" @@ -146,7 +146,7 @@ func (e *engine) ContainerExec(ctx context.Context, id string, opts *container.E idResp, err := e.client.ExecCreate(ctx, id, dockerclient.ExecCreateOptions{ Cmd: opts.Cmd, WorkingDir: opts.Workdir, - Env: cli.ConvertMapToKVString(opts.Env), + Env: parser.ConvertMapToKVString(opts.Env), TTY: stdio.Tty, AttachStdin: stdio.AttachStdin(), AttachStdout: stdio.AttachStdout(), diff --git a/core/pkg/container/parser/init.go b/core/pkg/container/parser/init.go new file mode 100644 index 000000000..e5428b68f --- /dev/null +++ b/core/pkg/container/parser/init.go @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: (c) 2024 The Drassi Authors + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package parser + +import "drassi.run/core/pkg/container/types" + +func init() { + types.ParseVolume = ParseVolume + types.ParsePublish = ParsePublish + types.ParseExpose = ParseExpose +} diff --git a/core/pkg/container/parser/kv.go b/core/pkg/container/parser/kv.go new file mode 100644 index 000000000..4f1733eec --- /dev/null +++ b/core/pkg/container/parser/kv.go @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: (c) 2024 The Drassi Authors + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package parser + +import ( + "fmt" + "strings" +) + +// ConvertMapToKVString converts {"key":"value"} to ["key=value"] +func ConvertMapToKVString(m map[string]string) []string { + if len(m) == 0 { + return nil + } + + r := make([]string, 0, len(m)) + for k, v := range m { + r = append(r, fmt.Sprintf("%s=%s", k, v)) + } + return r +} + +// ConvertKVStringsToMap converts ["key=value"] to {"key":"value"} +func ConvertKVStringsToMap(values []string) map[string]string { + result := make(map[string]string, len(values)) + for _, value := range values { + k, v, _ := strings.Cut(value, "=") + result[k] = v + } + + return result +} diff --git a/core/pkg/container/parser/network.go b/core/pkg/container/parser/network.go new file mode 100644 index 000000000..ffabb2861 --- /dev/null +++ b/core/pkg/container/parser/network.go @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: (c) 2024 The Drassi Authors + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package parser + +import ( + "errors" + "fmt" + "net" + "strconv" + "strings" + + "drassi.run/core/pkg/container/types" +) + +// ParsePublish parses user-provided publish definitions into types.PortBinding format +// - [github.com/compose-spec/compose-go/v2/types.ParsePortConfig] +// - [github.com/docker/go-connections/nat.ParsePortSpec] +// - [github.com/containers/podman/v5/pkg/specgenutil.CreatePortBindings] +func ParsePublish(str string) (*types.PortBinding, uint16, error) { + remains := str + var hostIP, hostPort, containerPort, proto string + + if r, p, err := SplitProto(remains); err != nil { + return nil, 0, err + } else { + remains, proto = r, p + } + + if idx := strings.LastIndexByte(remains, ':'); idx != -1 { + remains, containerPort = remains[:idx], remains[idx+1:] + } else { + remains, containerPort = "", remains + } + + if remains != "" { + if !strings.ContainsRune(remains, ':') { + remains, hostPort = "", remains + } else if host, port, err := net.SplitHostPort(remains); err != nil { + return nil, 0, fmt.Errorf("invalid publish: %s - %s", str, err) + } else { + remains, hostIP, hostPort = "", host, port + } + } + + length := uint16(0) + publish := &types.PortBinding{ + HostIP: hostIP, + Protocol: proto, + } + + if port, portRange, err := ParsePortRange(containerPort); err != nil { + return nil, 0, err + } else { + publish.ContainerPort = port + length = portRange + } + + if hostPort == "" { + return publish, length, nil + } + + if port, portRange, err := ParsePortRange(hostPort); err != nil { + return nil, 0, err + } else { + publish.HostPort = port + if portRange > 1 { + if length > 1 && length != portRange { + return nil, 0, fmt.Errorf("invalid publish %q : port-range mismatch", str) + } + length = portRange + } + } + + return publish, length, nil +} + +// ParseExpose parses user-provided exposed port definitions into types.Port format +// - [github.com/containers/podman/v5/pkg/specgenutil.CreateExpose] +func ParseExpose(str string) (*types.Port, uint16, error) { + remains, expose := str, new(types.Port) + + if r, p, err := SplitProto(remains); err != nil { + return nil, 0, err + } else { + remains, expose.Protocol = r, p + } + + if port, length, err := ParsePortRange(remains); err != nil { + return nil, 0, err + } else { + expose.Number = port + return expose, length, nil + } +} + +var hostListSeparators = []string{"=", ":"} + +// ParseHost parses user-provided additional host into hostname and list of IPs +// - [github.com/compose-spec/compose-go/v2/types.NewHostsList] +func ParseHost(s string) (string, []string, error) { + for _, sep := range hostListSeparators { + host, ip, ok := strings.Cut(s, sep) + if ok { + return host, strings.Split(ip, ","), nil + } + } + + return "", nil, fmt.Errorf("invalid additional host, missing IP: %s", s) +} + +func SplitProto(s string) (string, string, error) { + splits := strings.SplitN(s, "/", 3) + if len(splits) > 2 { + return "", "", fmt.Errorf("invalid protocol: %s - multiple protocols", s) + } else if len(splits) == 2 { + remains, proto := splits[0], splits[1] + if proto == "" { + proto = "tcp" + } + return remains, proto, nil + } + return s, "tcp", nil +} + +// ParsePortRange parses specified string as a port-range +// - https://github.com/containers/podman/blob/v5.2.5/pkg/specgenutil/util.go#L216 +// - [github.com/docker/go-connections/nat.ParsePortRange] +func ParsePortRange(portRange string) (uint16, uint16, error) { + var ( + port = portRange + endPort = (*string)(nil) + ) + + if splits := strings.SplitN(portRange, "-", 3); len(splits) > 2 { + return 0, 0, fmt.Errorf("invalid portRange: %s - too many parts", portRange) + } else if len(splits) == 2 { + port, endPort = splits[0], &splits[1] + } + + var portNum uint16 + if num, err := ParsePort(port); err != nil { + return 0, 0, err + } else { + portNum = num + } + + if endPort != nil { + if num, err := ParsePort(*endPort); err != nil { + return 0, 0, err + } else if portNum >= num { + return 0, 0, fmt.Errorf("invalid portRange: %s - startPort >= endPort", portRange) + } else { + length := num - portNum + 1 + return portNum, length, nil + } + } + + return portNum, 1, nil +} + +// ParsePort turn a single string into a valid U16 port. +// - https://github.com/containers/podman/blob/v5.2.5/pkg/specgenutil/util.go#L253-L262 +// - [github.com/docker/go-connections/nat.ParsePort] +func ParsePort(port string) (uint16, error) { + num, err := strconv.ParseUint(port, 10, 16) + if err != nil { + if errors.Is(err, strconv.ErrRange) { + return 0, fmt.Errorf("invalid port: %s - must be in range [1, 65535]", port) + } + return 0, fmt.Errorf("invalid port: %s - %w", port, err) + } + return uint16(num), nil +} diff --git a/core/pkg/container/cli/parse_network_test.go b/core/pkg/container/parser/network_test.go similarity index 88% rename from core/pkg/container/cli/parse_network_test.go rename to core/pkg/container/parser/network_test.go index abf18b475..f2002f7fb 100644 --- a/core/pkg/container/cli/parse_network_test.go +++ b/core/pkg/container/parser/network_test.go @@ -4,14 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -package cli +package parser import ( - "drassi.run/core/pkg/container/types" - "github.com/stretchr/testify/assert" "iter" "strings" "testing" + + "drassi.run/core/pkg/container/types" + "github.com/stretchr/testify/assert" ) // https://github.com/containers/podman/blob/v5.2.5/pkg/specgenutil/util_test.go#L8 @@ -194,3 +195,24 @@ func TestParsePortRange(t *testing.T) { } }) } + +func TestParseHost(t *testing.T) { + t.Run("success colon", func(t *testing.T) { + host, ips, err := ParseHost("example.com:192.168.1.1,192.168.1.2") + assert.NoError(t, err) + assert.Equal(t, "example.com", host) + assert.Equal(t, []string{"192.168.1.1", "192.168.1.2"}, ips) + }) + + t.Run("success equals", func(t *testing.T) { + host, ips, err := ParseHost("example.com=10.0.0.1") + assert.NoError(t, err) + assert.Equal(t, "example.com", host) + assert.Equal(t, []string{"10.0.0.1"}, ips) + }) + + t.Run("failure missing ip", func(t *testing.T) { + _, _, err := ParseHost("invalidhost") + assert.ErrorContains(t, err, "missing IP") + }) +} diff --git a/core/pkg/container/parser/volume.go b/core/pkg/container/parser/volume.go new file mode 100644 index 000000000..987113307 --- /dev/null +++ b/core/pkg/container/parser/volume.go @@ -0,0 +1,191 @@ +/* + * SPDX-FileCopyrightText: (c) 2024 The Drassi Authors + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package parser + +import ( + "errors" + "fmt" + "io/fs" + "path/filepath" + "strconv" + "strings" + + "unicode" + + "drassi.run/core/pkg/container/types" + "github.com/docker/go-units" +) + +const endOfSpec = rune(0) + +// ParseVolume parses user-provided volume definitions into types.Mount format +// - [github.com/containers/podman/v5/pkg/specgen.GenVolumeMounts] +// - [github.com/compose-spec/compose-go/v2/loader.ParseVolume] +func ParseVolume(spec string) (*types.Mount, error) { + if len(spec) == 0 { + return nil, errors.New("invalid empty volume spec") + } + + mount := new(types.Mount) + buffer := make([]rune, 0, len(spec)) + for _, char := range spec + string(endOfSpec) { + switch { + case isWindowsDrive(buffer, char): + buffer = append(buffer, char) + case char == ':' || char == endOfSpec: + if err := populateFieldFromBuffer(char, buffer, mount); err != nil { + populateType(mount) + return nil, fmt.Errorf("invalid spec: %s: %w", spec, err) + } + buffer = buffer[:0] // reset, but reuse capacity + default: + buffer = append(buffer, char) + } + } + + populateType(mount) + return mount, nil +} + +func isWindowsDrive(buffer []rune, char rune) bool { + return char == ':' && len(buffer) == 1 && unicode.IsLetter(buffer[0]) +} + +func populateFieldFromBuffer(char rune, buffer []rune, mount *types.Mount) error { + strBuffer := string(buffer) + switch { + case len(buffer) == 0: + return errors.New("empty section between colons") + // Anonymous volume + case mount.Source == "" && char == endOfSpec: + mount.Target = strBuffer + return nil + case mount.Source == "": + mount.Source = strBuffer + return nil + case mount.Target == "": + mount.Target = strBuffer + return nil + case char == ':': + return errors.New("too many colons") + } + for option := range strings.SplitSeq(strBuffer, ",") { + switch option { + case "ro": + mount.ReadOnly = true + case "rw": + mount.ReadOnly = false + case "nocopy": + if mount.VolumeOptions == nil { + mount.VolumeOptions = new(types.VolumeOptions) + } + mount.VolumeOptions.NoCopy = true + case "rprivate", "private", "rshared", "shared", "rslave", "slave": + if mount.BindOptions == nil { + mount.BindOptions = new(types.BindOptions) + } + mount.BindOptions.Propagation = option + case "consistent", "cached", "delegated": + if mount.BindOptions == nil { + mount.BindOptions = new(types.BindOptions) + } + mount.BindOptions.Consistency = option + // ignore unknown options + } + } + return nil +} + +func populateType(mount *types.Mount) { + switch { + // Anonymous volume + case mount.Source == "": + mount.Type = "volume" + case isFilePath(mount.Source): + mount.Type = "bind" + default: + mount.Type = "volume" + } +} + +func isFilePath(source string) bool { + if len(source) == 0 { + return false + } + switch source[0] { + case '.', '/', '~': + return true + } + // windows named pipes + if strings.HasPrefix(source, `\\`) { + return true + } + runes := []rune(source) + if len(runes) < 2 { + return false + } + return isWindowsDrive(runes[:1], runes[1]) +} + +// ParseTmpfs parses user-provided tmpfs definitions into types.Mount format +// - https://github.com/containers/podman/blob/v5.2.5/pkg/specgenutil/volumes.go#L645 +func ParseTmpfs(t string) (*types.Mount, error) { + split := strings.Split(t, ":") + target := split[0] + if err := validateVolumeContainerDir(target); err != nil { + return nil, err + } + mount := &types.Mount{ + Type: "tmpfs", + Target: target, + } + + if len(split) > 1 { + options := strings.Split(split[1], ",") + mount.TmpfsOptions = &types.TmpfsOptions{} + for _, opt := range options { + k, v, _ := strings.Cut(opt, "=") + k = strings.ToLower(k) + switch k { + case "size": + if size, err := units.RAMInBytes(v); err != nil { + return nil, err + } else { + mount.TmpfsOptions.Size = size + } + case "readonly", "ro": + mount.ReadOnly = true + case "readwrite", "rw": + mount.ReadOnly = false + case "mode": + if ui64, err := strconv.ParseUint(v, 8, 32); err != nil { + return nil, err + } else { + mount.TmpfsOptions.Mode = fs.FileMode(ui64) + } + default: + o := []string{k} + if v != "" { + o = append(o, v) + } + mount.TmpfsOptions.Options = append(mount.TmpfsOptions.Options, o) + } + } + } + return mount, nil +} + +// validateVolumeContainerDir validates a volume mount's destination directory. +func validateVolumeContainerDir(path string) error { + if path == "" { + return errors.New("container directory cannot be empty") + } + if !filepath.IsAbs(path) { + return fmt.Errorf("invalid container path %q, must be an absolute path", path) + } + return nil +} diff --git a/core/pkg/container/parser/volume_test.go b/core/pkg/container/parser/volume_test.go new file mode 100644 index 000000000..bd6a1d9a6 --- /dev/null +++ b/core/pkg/container/parser/volume_test.go @@ -0,0 +1,133 @@ +/* + * SPDX-FileCopyrightText: (c) 2024 The Drassi Authors + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package parser + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseVolume(t *testing.T) { + t.Run("bind mount ro", func(t *testing.T) { + m, err := ParseVolume("/source:/target:ro") + require.NoError(t, err) + assert.Equal(t, "bind", m.Type) + assert.Equal(t, "/source", m.Source) + assert.Equal(t, "/target", m.Target) + assert.True(t, m.ReadOnly) + }) + + t.Run("volume rw", func(t *testing.T) { + m, err := ParseVolume("my-volume:/target:rw") + require.NoError(t, err) + assert.Equal(t, "volume", m.Type) + assert.Equal(t, "my-volume", m.Source) + assert.Equal(t, "/target", m.Target) + assert.False(t, m.ReadOnly) + }) + + t.Run("volume nocopy", func(t *testing.T) { + m, err := ParseVolume("my-volume:/target:nocopy") + require.NoError(t, err) + assert.Equal(t, "volume", m.Type) + assert.Equal(t, "my-volume", m.Source) + assert.Equal(t, "/target", m.Target) + require.NotNil(t, m.VolumeOptions) + assert.True(t, m.VolumeOptions.NoCopy) + }) + + t.Run("bind mount with propagation and consistency", func(t *testing.T) { + m, err := ParseVolume("/source:/target:rslave,cached") + require.NoError(t, err) + assert.Equal(t, "bind", m.Type) + assert.Equal(t, "/source", m.Source) + assert.Equal(t, "/target", m.Target) + require.NotNil(t, m.BindOptions) + assert.Equal(t, "rslave", m.BindOptions.Propagation) + assert.Equal(t, "cached", m.BindOptions.Consistency) + }) + + t.Run("anonymous volume unix", func(t *testing.T) { + m, err := ParseVolume("/data") + require.NoError(t, err) + assert.Equal(t, "volume", m.Type) + assert.Empty(t, m.Source) + assert.Equal(t, "/data", m.Target) + }) + + t.Run("anonymous volume windows", func(t *testing.T) { + m, err := ParseVolume(`C:\data`) + require.NoError(t, err) + assert.Equal(t, "volume", m.Type) + assert.Empty(t, m.Source) + assert.Equal(t, `C:\data`, m.Target) + }) + + t.Run("windows bind mount with drive letters", func(t *testing.T) { + m, err := ParseVolume(`C:\source\foo:D:\target:ro,rprivate`) + require.NoError(t, err) + assert.Equal(t, "bind", m.Type) + assert.Equal(t, `C:\source\foo`, m.Source) + assert.Equal(t, `D:\target`, m.Target) + assert.True(t, m.ReadOnly) + require.NotNil(t, m.BindOptions) + assert.Equal(t, "rprivate", m.BindOptions.Propagation) + }) + + t.Run("windows named pipe", func(t *testing.T) { + m, err := ParseVolume(`\\.\pipe\docker_engine:\\.\pipe\inside`) + require.NoError(t, err) + assert.Equal(t, "bind", m.Type) + assert.Equal(t, `\\.\pipe\docker_engine`, m.Source) + assert.Equal(t, `\\.\pipe\inside`, m.Target) + }) + + t.Run("invalid empty", func(t *testing.T) { + _, err := ParseVolume("") + assert.Error(t, err) + }) + + t.Run("invalid too many colons", func(t *testing.T) { + _, err := ParseVolume("/foo:/bar:ro:extra") + assert.ErrorContains(t, err, "too many colons") + }) + + t.Run("invalid empty section", func(t *testing.T) { + for _, spec := range []string{":foo", "/foo::ro", "/foo:"} { + _, err := ParseVolume(spec) + assert.ErrorContains(t, err, "empty section between colons") + } + }) +} + +func TestParseTmpfs(t *testing.T) { + t.Run("simple tmpfs", func(t *testing.T) { + m, err := ParseTmpfs("/tmp") + require.NoError(t, err) + assert.Equal(t, "tmpfs", m.Type) + assert.Equal(t, "/tmp", m.Target) + assert.Nil(t, m.TmpfsOptions) + }) + + t.Run("tmpfs with options", func(t *testing.T) { + m, err := ParseTmpfs("/tmp:size=64m,ro,mode=1777") + require.NoError(t, err) + assert.Equal(t, "tmpfs", m.Type) + assert.Equal(t, "/tmp", m.Target) + assert.True(t, m.ReadOnly) + require.NotNil(t, m.TmpfsOptions) + assert.Equal(t, int64(64*1024*1024), m.TmpfsOptions.Size) + assert.Equal(t, uint32(01777), uint32(m.TmpfsOptions.Mode)) + }) + + t.Run("invalid relative target", func(t *testing.T) { + _, err := ParseTmpfs("relative/path") + assert.Error(t, err) + }) +} diff --git a/core/pkg/container/types/container.go b/core/pkg/container/types/container.go index f819c101a..fcd1158cf 100644 --- a/core/pkg/container/types/container.go +++ b/core/pkg/container/types/container.go @@ -14,25 +14,25 @@ import ( ) type ContainerSpec struct { - Name string - Image string - PullPolicy string + Name string `json:"name,omitempty"` + Image string `json:"image,omitempty"` + PullPolicy string `json:"pull_policy,omitempty"` - Command []string - Entrypoint []string - WorkingDir string - Environment map[string]string - Labels map[string]string - Annotations map[string]string + Command []string `json:"command,omitempty"` + Entrypoint []string `json:"entrypoint,omitempty"` + WorkingDir string `json:"working_dir,omitempty"` + Environment Mapping `json:"environment,omitempty"` + Labels Mapping `json:"labels,omitempty"` + Annotations Mapping `json:"annotations,omitempty"` - ContainerNetwork - ContainerStorage - Devices []string - DeviceCgroupRules []string + ContainerNetwork `json:",embed"` + ContainerStorage `json:",embed"` + Devices []string `json:"devices,omitempty"` + DeviceCgroupRules []string `json:"device_cgroup_rules,omitempty"` - ContainerRuntime - ContainerResource - ContainerSecurity + ContainerRuntime `json:",embed"` + ContainerResource `json:",embed"` + ContainerSecurity `json:",embed"` } const ( @@ -44,7 +44,7 @@ const ( LabelRun = "run.drassi.run" ) -func LabelsFor(forge *records.Forge) map[string]string { +func LabelsFor(forge *records.Forge) Mapping { repo := forge.Repository if u, err := url.Parse(forge.ServerUrl); err == nil { if server := u.Host; server != "" { @@ -54,7 +54,7 @@ func LabelsFor(forge *records.Forge) map[string]string { } } - labels := map[string]string{ + labels := Mapping{ LabelRepository: repo, // e.g: github.com/drassi-run/drassi LabelReference: forge.Ref, // e.g: refs/heads/main LabelWorkflow: forge.Workflow, // e.g: test diff --git a/core/pkg/container/types/network.go b/core/pkg/container/types/network.go index f1b6dda97..86839d2d9 100644 --- a/core/pkg/container/types/network.go +++ b/core/pkg/container/types/network.go @@ -7,18 +7,22 @@ package types import ( + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "fmt" "net" "net/netip" "strconv" ) type ContainerNetwork struct { - Exposes []*Port // informs Docker that the container listens on the specified network ports at runtime - Publish []*PortBinding // Publish a container's port, or range of ports, to the host. - PublishAll bool // Publish all exposed ports to random ports on the host interfaces. + Exposes []*Port `json:"expose,omitempty"` // informs Docker that the container listens on the specified network ports at runtime + Publish []*PortBinding `json:"ports,omitempty"` // Publish a container's port, or range of ports, to the host. + PublishAll bool `json:"publish_all,omitempty"` // Publish all exposed ports to random ports on the host interfaces. - DNS DNS - Endpoints []*Endpoint + DNS *DNS `json:"dns,omitempty"` + Endpoints []*Endpoint `json:"endpoints,omitempty"` } // PortBinding define the port mappings between the host machine and the container. @@ -26,10 +30,36 @@ type ContainerNetwork struct { // - [github.com/containers/common/libnetwork/types.PortMapping] // - [github.com/compose-spec/compose-go/v2/types.ServicePortConfig] type PortBinding struct { - HostIP string - HostPort uint16 - ContainerPort uint16 - Protocol string + HostIP string `json:"host_ip,omitempty"` + HostPort uint16 `json:"host_port,omitempty"` + ContainerPort uint16 `json:"container_port,omitempty"` + Protocol string `json:"protocol,omitempty"` +} + +var ParsePublish func(str string) (*PortBinding, uint16, error) + +func (pb *PortBinding) UnmarshalJSONFrom(d *jsontext.Decoder) error { + switch kind := d.PeekKind(); kind { + case jsontext.KindString: + var s string + if err := json.UnmarshalDecode(d, &s); err != nil { + return err + } + if ParsePublish == nil { + return errors.New("types: publish parser not registered (import _ \"drassi.run/core/pkg/container/parser\")") + } + if parsed, _, err := ParsePublish(s); err != nil { + return err + } else { + *pb = *parsed + return nil + } + case jsontext.KindBeginObject: + type alias PortBinding + return json.UnmarshalDecode(d, (*alias)(pb)) + default: + return fmt.Errorf("expected string or object for PortBinding, got %v", kind) + } } func (pb *PortBinding) String() string { @@ -47,21 +77,54 @@ func (pb *PortBinding) String() string { } if hostPart != "" { return hostPart + ":" + containerPart - } else { - return containerPart } + + return containerPart } // Port defines the (incoming) port and protocol type Port struct { - Number uint16 - Protocol string + Number uint16 `json:"number,omitempty"` + Protocol string `json:"protocol,omitempty"` } -func (e *Port) String() string { - s := strconv.Itoa(int(e.Number)) - if e.Protocol != "" { - s += "/" + e.Protocol +var ParseExpose func(str string) (*Port, uint16, error) + +func (p *Port) UnmarshalJSONFrom(d *jsontext.Decoder) error { + switch kind := d.PeekKind(); kind { + case jsontext.KindString: + var s string + if err := json.UnmarshalDecode(d, &s); err != nil { + return err + } + if ParseExpose == nil { + return errors.New("types: expose parser not registered (import _ \"drassi.run/core/pkg/container/parser\")") + } + if parsed, _, err := ParseExpose(s); err != nil { + return err + } else { + *p = *parsed + return nil + } + case jsontext.KindNumber: + var num uint16 + if err := json.UnmarshalDecode(d, &num); err != nil { + return err + } + *p = Port{Number: num, Protocol: "tcp"} + return nil + case jsontext.KindBeginObject: + type alias Port + return json.UnmarshalDecode(d, (*alias)(p)) + default: + return fmt.Errorf("expected string, number, or object for Port, got %v", kind) + } +} + +func (p *Port) String() string { + s := strconv.Itoa(int(p.Number)) + if p.Protocol != "" { + s += "/" + p.Protocol } return s } @@ -72,35 +135,35 @@ func (e *Port) String() string { // - [github.com/containers/common/libnetwork/types.PerNetworkOptions] // - [github.com/compose-spec/compose-go/v2/types.ServiceNetworkConfig] type Endpoint struct { - Target string - Options map[string]string // driver options - - IPv4Address netip.Addr - IPv6Address netip.Addr - MacAddress net.HardwareAddr - LinkLocalIPs []netip.Addr - Aliases []string - Links []string + Target string `json:"target,omitempty"` + Options map[string]string `json:"options,omitempty"` // driver options + + IPv4Address netip.Addr `json:"ipv4_address,omitzero"` + IPv6Address netip.Addr `json:"ipv6_address,omitzero"` + MacAddress net.HardwareAddr `json:"mac_address,omitempty"` + LinkLocalIPs []netip.Addr `json:"link_local_ips,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Links []string `json:"links,omitempty"` } type DNS struct { - Servers []netip.Addr - Options []string - Search []string - HostName string - DomainName string - HostAdd map[string][]string + Servers []netip.Addr `json:"servers,omitempty"` + Options []string `json:"options,omitempty"` + Search []string `json:"search,omitempty"` + HostName string `json:"hostname,omitempty"` + DomainName string `json:"domainname,omitempty"` + HostAdd map[string][]string `json:"extra_hosts,omitempty"` } // https://github.com/moby/moby/blob/docker-v29.7.2/api/types/network/network.go // https://github.com/containers/common/blob/v0.60.4/libnetwork/types/network.go#L53-L88 type NetworkSpec struct { - Name string - Labels map[string]string + Name string `json:"name,omitempty"` + Labels map[string]string `json:"labels,omitempty"` - Driver string - Options map[string]string + Driver string `json:"driver,omitempty"` + Options map[string]string `json:"options,omitempty"` - IPAMDriver string - IPAMOptions map[string]string + IPAMDriver string `json:"ipam_driver,omitempty"` + IPAMOptions map[string]string `json:"ipam_options,omitempty"` } diff --git a/core/pkg/container/types/resource.go b/core/pkg/container/types/resource.go index 84cb81438..5c4f96444 100644 --- a/core/pkg/container/types/resource.go +++ b/core/pkg/container/types/resource.go @@ -14,52 +14,52 @@ import ( type ContainerResource struct { //// Applicable to all platforms - CPUShares int64 - CPUS string - Memory int64 + CPUShares int64 `json:"cpu_shares,omitempty"` + CPUS string `json:"cpus,omitempty"` + Memory UnitBytes `json:"memory,omitempty"` //// Applicable to Windows - CPUCount int64 - CPUPercent float32 - IOMaximumIOps uint64 - IOMaximumBandwidth uint64 + CPUCount int64 `json:"cpu_count,omitempty"` + CPUPercent float32 `json:"cpu_percent,omitempty"` + IOMaximumIOps uint64 `json:"io_max_iops,omitempty"` + IOMaximumBandwidth uint64 `json:"io_max_bandwidth,omitempty"` //// Applicable to UNIX - CPUPeriod int64 - CPUQuota int64 - CPURTPeriod int64 - CPURTRuntime int64 - CpusetCpus string - CpusetMems string - MemReservation int64 - MemSwapLimit int64 - MemSwappiness int64 - ShmSize int64 - OomKillDisable bool - OomScoreAdj int64 - PidsLimit int64 + CPUPeriod int64 `json:"cpu_period,omitempty"` + CPUQuota int64 `json:"cpu_quota,omitempty"` + CPURTPeriod int64 `json:"cpu_rt_period,omitempty"` + CPURTRuntime int64 `json:"cpu_rt_runtime,omitempty"` + CpusetCpus string `json:"cpuset_cpus,omitempty"` + CpusetMems string `json:"cpuset_mems,omitempty"` + MemReservation UnitBytes `json:"mem_reservation,omitempty"` + MemSwapLimit UnitBytes `json:"mem_swap_limit,omitempty"` + MemSwappiness int64 `json:"mem_swappiness,omitempty"` + ShmSize UnitBytes `json:"shm_size,omitempty"` + OomKillDisable bool `json:"oom_kill_disable,omitempty"` + OomScoreAdj int64 `json:"oom_score_adj,omitempty"` + PidsLimit int64 `json:"pids_limit,omitempty"` - BlkioConfig *BlkioConfig - Ulimits []*units.Ulimit + BlkioConfig *BlkioConfig `json:"blkio_config,omitempty"` + Ulimits []*units.Ulimit `json:"ulimits,omitempty"` } // BlkioConfig define blkio config // - [github.com/compose-spec/compose-go/v2/types.BlkioConfig] type BlkioConfig struct { - Weight uint16 - WeightDevice []WeightDevice - DeviceReadBps []ThrottleDevice - DeviceReadIOps []ThrottleDevice - DeviceWriteBps []ThrottleDevice - DeviceWriteIOps []ThrottleDevice + Weight uint16 `json:"weight,omitempty"` + WeightDevice []WeightDevice `json:"weight_device,omitempty"` + DeviceReadBps []ThrottleDevice `json:"device_read_bps,omitempty"` + DeviceReadIOps []ThrottleDevice `json:"device_read_iops,omitempty"` + DeviceWriteBps []ThrottleDevice `json:"device_write_bps,omitempty"` + DeviceWriteIOps []ThrottleDevice `json:"device_write_iops,omitempty"` } // WeightDevice is a structure that holds device:weight pair // - [github.com/compose-spec/compose-go/v2/types.WeightDevice] // - [github.com/moby/moby/api/types/blkiodev.WeightDevice] type WeightDevice struct { - Path string - Weight uint16 + Path string `json:"path,omitempty"` + Weight uint16 `json:"weight,omitempty"` } func (w *WeightDevice) String() string { @@ -70,8 +70,8 @@ func (w *WeightDevice) String() string { // - [github.com/compose-spec/compose-go/v2/types.ThrottleDevice] // - [github.com/moby/moby/api/types/blkiodev.ThrottleDevice] type ThrottleDevice struct { - Path string - Rate uint64 + Path string `json:"path,omitempty"` + Rate uint64 `json:"rate,omitempty"` } func (t *ThrottleDevice) String() string { diff --git a/core/pkg/container/types/runtime.go b/core/pkg/container/types/runtime.go index 13568c632..589f1f00a 100644 --- a/core/pkg/container/types/runtime.go +++ b/core/pkg/container/types/runtime.go @@ -9,15 +9,15 @@ package types import "time" type ContainerRuntime struct { - Runtime string - Platform string - Isolation string - RestartPolicy *RestartPolicy - AutoRemove bool - StopSignal string - StopTimeout *time.Duration - Logging *LoggingConfig - HealthCheck *HealthCheckConfig + Runtime string `json:"runtime,omitempty"` + Platform string `json:"platform,omitempty"` + Isolation string `json:"isolation,omitempty"` + RestartPolicy *RestartPolicy `json:"restart,omitempty"` + AutoRemove bool `json:"auto_remove,omitempty"` + StopSignal string `json:"stop_signal,omitempty"` + StopTimeout *time.Duration `json:"stop_timeout,omitempty"` + Logging *LoggingConfig `json:"logging,omitempty"` + HealthCheck *HealthCheckConfig `json:"healthcheck,omitempty"` } // RestartPolicy represents the restart policies of the container. @@ -26,16 +26,16 @@ type ContainerRuntime struct { // - [github.com/docker/cli/cli/compose/types.RestartPolicy] // - [github.com/compose-spec/compose-go/v2/types.RestartPolicy] type RestartPolicy struct { - Name string // "no", "on-failure", "always", "unless-stopped" - MaxRetry int + Name string `json:"name,omitempty"` // "no", "on-failure", "always", "unless-stopped" + MaxRetry int `json:"max_retry,omitempty"` } // LoggingConfig is identical with compose LoggingConfig // - [github.com/moby/moby/api/types/container.LogConfig] // - [github.com/compose-spec/compose-go/v2/types.LoggingConfig] type LoggingConfig struct { - Driver string - Options map[string]string + Driver string `json:"driver,omitempty"` + Options map[string]string `json:"options,omitempty"` } // HealthCheckConfig is identical with docker's HealthConfig @@ -43,10 +43,10 @@ type LoggingConfig struct { // - [github.com/compose-spec/compose-go/v2/types.HealthCheckConfig] // - [github.com/containers/image/v5/manifest.Schema2HealthConfig] type HealthCheckConfig struct { - Test []string - Timeout time.Duration - Interval time.Duration - Retries int - StartPeriod time.Duration - StartInterval time.Duration + Test []string `json:"test,omitempty"` + Timeout time.Duration `json:"timeout,omitempty"` + Interval time.Duration `json:"interval,omitempty"` + Retries int `json:"retries,omitempty"` + StartPeriod time.Duration `json:"start_period,omitempty"` + StartInterval time.Duration `json:"start_interval,omitempty"` } diff --git a/core/pkg/container/types/security.go b/core/pkg/container/types/security.go index 907050aa7..c397befa7 100644 --- a/core/pkg/container/types/security.go +++ b/core/pkg/container/types/security.go @@ -8,20 +8,20 @@ package types type ContainerSecurity struct { // Namespace & CGroup - NetworkMode string - IpcMode string - PidMode string - UTSMode string - UserMode string - CgroupMode string - CgroupParent string + NetworkMode string `json:"network_mode,omitempty"` + IpcMode string `json:"ipc_mode,omitempty"` + PidMode string `json:"pid_mode,omitempty"` + UTSMode string `json:"uts_mode,omitempty"` + UserMode string `json:"user_mode,omitempty"` + CgroupMode string `json:"cgroup_mode,omitempty"` + CgroupParent string `json:"cgroup_parent,omitempty"` // Security - User string - GroupAdd []string - CapAdd []string - CapDrop []string - Privileged bool - SecurityOpt []string - Sysctls map[string]string + User string `json:"user,omitempty"` + GroupAdd []string `json:"group_add,omitempty"` + CapAdd []string `json:"cap_add,omitempty"` + CapDrop []string `json:"cap_drop,omitempty"` + Privileged bool `json:"privileged,omitempty"` + SecurityOpt []string `json:"security_opt,omitempty"` + Sysctls Mapping `json:"sysctls,omitempty"` } diff --git a/core/pkg/container/types/serde_test.go b/core/pkg/container/types/serde_test.go new file mode 100644 index 000000000..2c3e1b390 --- /dev/null +++ b/core/pkg/container/types/serde_test.go @@ -0,0 +1,276 @@ +/* + * SPDX-FileCopyrightText: (c) 2024 The Drassi Authors + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package types_test + +import ( + "encoding/json/v2" + "testing" + + _ "drassi.run/core/pkg/container/parser" + "drassi.run/core/pkg/container/types" + "github.com/stretchr/testify/require" +) + +func unmarshalJSON[T any](t *testing.T, s string) T { + t.Helper() + var v T + err := json.Unmarshal([]byte(s), &v) + require.NoError(t, err) + return v +} + +func unmarshalJSONErr[T any](t *testing.T, s string) error { + t.Helper() + var v T + err := json.Unmarshal([]byte(s), &v) + require.Error(t, err) + return err +} + +func TestMountUnmarshalJSON(t *testing.T) { + t.Run("shorthand string volume", func(t *testing.T) { + m := unmarshalJSON[types.Mount](t, `"/var/run/docker.sock:/var/run/docker.sock:ro"`) + require.Equal(t, "bind", m.Type) + require.Equal(t, "/var/run/docker.sock", m.Source) + require.Equal(t, "/var/run/docker.sock", m.Target) + require.True(t, m.ReadOnly) + }) + + t.Run("object volume", func(t *testing.T) { + m := unmarshalJSON[types.Mount](t, `{"type": "volume", "source": "my-vol", "target": "/data", "read_only": false}`) + require.Equal(t, "volume", m.Type) + require.Equal(t, "my-vol", m.Source) + require.Equal(t, "/data", m.Target) + require.False(t, m.ReadOnly) + }) +} + +func TestPortBindingUnmarshalJSON(t *testing.T) { + t.Run("shorthand string port without host ip", func(t *testing.T) { + pb := unmarshalJSON[types.PortBinding](t, `"8080:80"`) + require.Equal(t, uint16(8080), pb.HostPort) + require.Equal(t, uint16(80), pb.ContainerPort) + require.Equal(t, "tcp", pb.Protocol) + }) + + t.Run("shorthand string port with host ip and protocol", func(t *testing.T) { + pb := unmarshalJSON[types.PortBinding](t, `"127.0.0.1:3000:3000/udp"`) + require.Equal(t, "127.0.0.1", pb.HostIP) + require.Equal(t, uint16(3000), pb.HostPort) + require.Equal(t, uint16(3000), pb.ContainerPort) + require.Equal(t, "udp", pb.Protocol) + }) + + t.Run("object port binding", func(t *testing.T) { + pb := unmarshalJSON[types.PortBinding](t, `{"host_ip": "0.0.0.0", "host_port": 9000, "container_port": 9000, "protocol": "tcp"}`) + require.Equal(t, "0.0.0.0", pb.HostIP) + require.Equal(t, uint16(9000), pb.HostPort) + require.Equal(t, uint16(9000), pb.ContainerPort) + require.Equal(t, "tcp", pb.Protocol) + }) +} + +func TestContainerSpecUnmarshalJSON(t *testing.T) { + raw := `{ + "image": "ghcr.io/drassi-run/ubuntu:26.04", + "working_dir": "/workspace", + "environment": {"FOO": "bar", "DEBUG": "true"}, + "volumes": [ + "/var/run/docker.sock:/var/run/docker.sock", + "/data:/data:ro" + ], + "ports": [ + "8080:80" + ], + "network_mode": "host", + "privileged": true, + "user": "1000:1000", + "cap_add": ["SYS_ADMIN"], + "cap_drop": ["NET_RAW"], + "devices": ["/dev/kvm"], + "cpus": "2", + "memory": 2147483648, + "shm_size": 1073741824 + }` + + spec := unmarshalJSON[types.ContainerSpec](t, raw) + + require.Equal(t, "ghcr.io/drassi-run/ubuntu:26.04", spec.Image) + require.Equal(t, "/workspace", spec.WorkingDir) + require.Equal(t, "bar", spec.Environment["FOO"]) + require.Equal(t, "true", spec.Environment["DEBUG"]) + + // Volumes + require.Len(t, spec.Mounts, 2) + require.Equal(t, "/var/run/docker.sock", spec.Mounts[0].Source) + require.Equal(t, "/var/run/docker.sock", spec.Mounts[0].Target) + require.False(t, spec.Mounts[0].ReadOnly) + require.Equal(t, "/data", spec.Mounts[1].Source) + require.Equal(t, "/data", spec.Mounts[1].Target) + require.True(t, spec.Mounts[1].ReadOnly) + + // Ports + require.Len(t, spec.Publish, 1) + require.Equal(t, uint16(8080), spec.Publish[0].HostPort) + require.Equal(t, uint16(80), spec.Publish[0].ContainerPort) + + // Security + require.Equal(t, "host", spec.NetworkMode) + require.True(t, spec.Privileged) + require.Equal(t, "1000:1000", spec.User) + require.Equal(t, []string{"SYS_ADMIN"}, spec.CapAdd) + require.Equal(t, []string{"NET_RAW"}, spec.CapDrop) + + // Devices & Resources + require.Equal(t, []string{"/dev/kvm"}, spec.Devices) + require.Equal(t, "2", spec.CPUS) + require.Equal(t, types.UnitBytes(2147483648), spec.Memory) + require.Equal(t, types.UnitBytes(1073741824), spec.ShmSize) +} + +func TestMappingUnmarshalJSON(t *testing.T) { + t.Run("object mapping", func(t *testing.T) { + raw := `{"FOO": "bar", "PORT": "8080", "BOOL": "true"}` + m := unmarshalJSON[types.Mapping](t, raw) + require.Equal(t, "bar", m["FOO"]) + require.Equal(t, "8080", m["PORT"]) + require.Equal(t, "true", m["BOOL"]) + }) + + t.Run("list of key-value and key-only strings", func(t *testing.T) { + raw := `["FOO=bar", "BAZ=qux=extra", "FLAG", "EMPTY="]` + m := unmarshalJSON[types.Mapping](t, raw) + require.Equal(t, "bar", m["FOO"]) + require.Equal(t, "qux=extra", m["BAZ"]) + require.Equal(t, "", m["FLAG"]) + require.Equal(t, "", m["EMPTY"]) + }) + + t.Run("null mapping", func(t *testing.T) { + m := unmarshalJSON[types.Mapping](t, "null") + require.Nil(t, m) + }) +} + +func TestPortUnmarshalJSON(t *testing.T) { + t.Run("port from string number", func(t *testing.T) { + p := unmarshalJSON[types.Port](t, `"80"`) + require.Equal(t, uint16(80), p.Number) + require.Equal(t, "tcp", p.Protocol) + require.Equal(t, "80/tcp", p.String()) + }) + + t.Run("port from string with protocol", func(t *testing.T) { + p := unmarshalJSON[types.Port](t, `"53/udp"`) + require.Equal(t, uint16(53), p.Number) + require.Equal(t, "udp", p.Protocol) + require.Equal(t, "53/udp", p.String()) + }) + + t.Run("port from integer number", func(t *testing.T) { + p := unmarshalJSON[types.Port](t, `443`) + require.Equal(t, uint16(443), p.Number) + require.Equal(t, "tcp", p.Protocol) + }) + + t.Run("port from object", func(t *testing.T) { + p := unmarshalJSON[types.Port](t, `{"number": 8080, "protocol": "tcp"}`) + require.Equal(t, uint16(8080), p.Number) + require.Equal(t, "tcp", p.Protocol) + }) +} + +func TestUnitBytesUnmarshalJSON(t *testing.T) { + t.Run("from integer number", func(t *testing.T) { + u := unmarshalJSON[types.UnitBytes](t, `2147483648`) + require.Equal(t, types.UnitBytes(2147483648), u) + require.Equal(t, int64(2147483648), int64(u)) + }) + + t.Run("from numeric string", func(t *testing.T) { + u := unmarshalJSON[types.UnitBytes](t, `"2147483648"`) + require.Equal(t, types.UnitBytes(2147483648), u) + }) + + t.Run("from human-readable unit strings", func(t *testing.T) { + cases := []struct { + input string + expected types.UnitBytes + }{ + {`"2g"`, 2 * 1024 * 1024 * 1024}, + {`"512m"`, 512 * 1024 * 1024}, + {`"64mb"`, 64 * 1024 * 1024}, + {`"1k"`, 1024}, + } + + for _, tc := range cases { + u := unmarshalJSON[types.UnitBytes](t, tc.input) + require.Equal(t, tc.expected, u) + } + }) + + t.Run("invalid string returns error", func(t *testing.T) { + err := unmarshalJSONErr[types.UnitBytes](t, `"invalid-size"`) + require.Error(t, err) + }) +} + +func TestContainerSpecShortFormUnmarshalJSON(t *testing.T) { + raw := `{ + "image": "ghcr.io/drassi-run/ubuntu:26.04", + "environment": [ + "FOO=bar", + "DEBUG=true", + "FLAG" + ], + "labels": [ + "app=drassi", + "env=prod" + ], + "annotations": [ + "note=ready" + ], + "sysctls": [ + "net.ipv4.ip_forward=1" + ], + "storage_opt": [ + "size=20G" + ], + "expose": [ + "80", + "53/udp", + 443 + ], + "memory": "2g", + "mem_reservation": "1g", + "shm_size": "64m" + }` + + spec := unmarshalJSON[types.ContainerSpec](t, raw) + + require.Equal(t, "bar", spec.Environment["FOO"]) + require.Equal(t, "true", spec.Environment["DEBUG"]) + require.Equal(t, "", spec.Environment["FLAG"]) + + require.Equal(t, "drassi", spec.Labels["app"]) + require.Equal(t, "prod", spec.Labels["env"]) + require.Equal(t, "ready", spec.Annotations["note"]) + require.Equal(t, "1", spec.Sysctls["net.ipv4.ip_forward"]) + require.Equal(t, "20G", spec.StorageOpt["size"]) + + require.Len(t, spec.Exposes, 3) + require.Equal(t, uint16(80), spec.Exposes[0].Number) + require.Equal(t, "tcp", spec.Exposes[0].Protocol) + require.Equal(t, uint16(53), spec.Exposes[1].Number) + require.Equal(t, "udp", spec.Exposes[1].Protocol) + require.Equal(t, uint16(443), spec.Exposes[2].Number) + require.Equal(t, "tcp", spec.Exposes[2].Protocol) + + require.Equal(t, types.UnitBytes(2*1024*1024*1024), spec.Memory) + require.Equal(t, types.UnitBytes(1024*1024*1024), spec.MemReservation) + require.Equal(t, types.UnitBytes(64*1024*1024), spec.ShmSize) +} diff --git a/core/pkg/container/types/storage.go b/core/pkg/container/types/storage.go index 7e9583a32..6fc89430f 100644 --- a/core/pkg/container/types/storage.go +++ b/core/pkg/container/types/storage.go @@ -6,13 +6,19 @@ package types -import "io/fs" +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "fmt" + "io/fs" +) type ContainerStorage struct { - Mounts []*Mount - VolumesFrom []string - StorageOpt map[string]string - ReadonlyRootfs bool + Mounts []*Mount `json:"volumes,omitempty"` + VolumesFrom []string `json:"volumes_from,omitempty"` + StorageOpt Mapping `json:"storage_opt,omitempty"` + ReadonlyRootfs bool `json:"readonly_rootfs,omitempty"` } // Mount represents a mount (volume). @@ -20,63 +26,89 @@ type ContainerStorage struct { // - [github.com/moby/moby/api/types/container.MountPoint] // - [github.com/compose-spec/compose-go/v2/types.ServiceVolumeConfig] type Mount struct { - Type string // "bind", "volume", "image", "tmpfs" - Source string - Target string - ReadOnly bool + Type string `json:"type,omitempty"` // "bind", "volume", "image", "tmpfs" + Source string `json:"source,omitempty"` + Target string `json:"target,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` - BindOptions *BindOptions - VolumeOptions *VolumeOptions - ImageOptions *ImageOptions - TmpfsOptions *TmpfsOptions + BindOptions *BindOptions `json:"bind,omitempty"` + VolumeOptions *VolumeOptions `json:"volume,omitempty"` + ImageOptions *ImageOptions `json:"image,omitempty"` + TmpfsOptions *TmpfsOptions `json:"tmpfs,omitempty"` +} + +var ParseVolume func(v string) (*Mount, error) + +func (m *Mount) UnmarshalJSONFrom(d *jsontext.Decoder) error { + switch kind := d.PeekKind(); kind { + case jsontext.KindString: + var s string + if err := json.UnmarshalDecode(d, &s); err != nil { + return err + } + if ParseVolume == nil { + return errors.New("types: volume parser not registered (import _ \"drassi.run/core/pkg/container/parser\")") + } + parsed, err := ParseVolume(s) + if err != nil { + return err + } + *m = *parsed + return nil + case jsontext.KindBeginObject: + type alias Mount + return json.UnmarshalDecode(d, (*alias)(m)) + default: + return fmt.Errorf("expected string or object for Mount, got %v", kind) + } } // BindOptions defines options specific to mounts of type "bind". // - [github.com/moby/moby/api/types/mount.BindOptions] // - [github.com/compose-spec/compose-go/v2/types.ServiceVolumeBind] type BindOptions struct { - Propagation string // [r]shared | [r]slave | [r]private (default=rprivate) - Consistency string // consistent | delegated | cached (default=consistent) - Recursive string // enabled | disabled | writable | readonly (default=enabled) - CreateHostPath bool + Propagation string `json:"propagation,omitempty"` // [r]shared | [r]slave | [r]private (default=rprivate) + Consistency string `json:"consistency,omitempty"` // consistent | delegated | cached (default=consistent) + Recursive string `json:"recursive,omitempty"` // enabled | disabled | writable | readonly (default=enabled) + CreateHostPath bool `json:"create_host_path,omitempty"` } // VolumeOptions represents the options for a mount of type "volume". // - [github.com/moby/moby/api/types/mount.VolumeOptions] // - [github.com/compose-spec/compose-go/v2/types.ServiceVolumeVolume] type VolumeOptions struct { - NoCopy bool - Labels map[string]string - SubPath string + NoCopy bool `json:"no_copy,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + SubPath string `json:"subpath,omitempty"` // Driver config for volume mount. // [github.com/moby/moby/api/types/mount.Driver] - Driver string - Options map[string]string + Driver string `json:"driver,omitempty"` + Options map[string]string `json:"options,omitempty"` } // ImageOptions represents the options for a mount of type "image". // - [github.com/moby/moby/api/types/mount.ImageOptions] // - [github.com/compose-spec/compose-go/v2/types.ServiceVolumeImage] type ImageOptions struct { - Subpath string + Subpath string `json:"subpath,omitempty"` } // TmpfsOptions defines options specific to mounts of type "tmpfs". // - [github.com/moby/moby/api/types/mount.TmpfsOptions] // - [github.com/compose-spec/compose-go/v2/types.ServiceVolumeTmpfs] type TmpfsOptions struct { - Size int64 - Mode fs.FileMode - Options [][]string + Size int64 `json:"size,omitempty"` + Mode fs.FileMode `json:"mode,omitempty"` + Options [][]string `json:"options,omitempty"` } // https://github.com/moby/moby/blob/docker-v29.7.2/api/types/volume/create_request.go // https://github.com/containers/podman/blob/v5.2.4/pkg/domain/entities/types/volumes.go#L8-L21 type VolumeSpec struct { - Name string - Labels map[string]string + Name string `json:"name,omitempty"` + Labels map[string]string `json:"labels,omitempty"` - Driver string - Options map[string]string + Driver string `json:"driver,omitempty"` + Options map[string]string `json:"options,omitempty"` } diff --git a/core/pkg/container/types/types.go b/core/pkg/container/types/types.go new file mode 100644 index 000000000..12c669670 --- /dev/null +++ b/core/pkg/container/types/types.go @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: (c) 2024 The Drassi Authors + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package types + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "strings" + + "github.com/docker/go-units" +) + +// Mapping is a mapping type that can be converted from a list of key[=value] strings. +// For the key with an empty value (`key=`), or key without value (`key`), the +// mapped value is set to an empty string `""`. +// - [github.com/compose-spec/compose-go/v2/types.Mapping] +type Mapping map[string]string + +func (m *Mapping) UnmarshalJSONFrom(d *jsontext.Decoder) error { + switch kind := d.PeekKind(); kind { + case jsontext.KindBeginObject: + type alias Mapping + return json.UnmarshalDecode(d, (*alias)(m)) + case jsontext.KindBeginArray: + var list []string + if err := json.UnmarshalDecode(d, &list); err != nil { + return err + } + res := make(Mapping, len(list)) + for _, item := range list { + k, v, _ := strings.Cut(item, "=") + res[k] = v + } + *m = res + return nil + case jsontext.KindNull: + if _, err := d.ReadToken(); err != nil { + return err + } + *m = nil + return nil + default: + return fmt.Errorf("expected object, array, or null for Mapping, got %v", kind) + } +} + +// UnitBytes represents byte sizes, supporting unmarshaling from both +// integer numbers and human-readable unit strings (e.g. "2g", "512m", "64mb"). +type UnitBytes int64 + +func (u *UnitBytes) UnmarshalJSONFrom(d *jsontext.Decoder) error { + switch kind := d.PeekKind(); kind { + case jsontext.KindNumber: + return json.UnmarshalDecode(d, (*int64)(u)) + case jsontext.KindString: + var s string + if err := json.UnmarshalDecode(d, &s); err != nil { + return err + } + if b, err := units.RAMInBytes(s); err != nil { + return fmt.Errorf("invalid byte size %q: %w", s, err) + } else { + *u = UnitBytes(b) + return nil + } + case jsontext.KindNull: + if _, err := d.ReadToken(); err != nil { + return err + } + *u = 0 + return nil + default: + return fmt.Errorf("expected number, string, or null for UnitBytes, got %v", kind) + } +} diff --git a/core/pkg/runtime/container_test.go b/core/pkg/runtime/container_test.go index 787bd16e6..5d2cf5e72 100644 --- a/core/pkg/runtime/container_test.go +++ b/core/pkg/runtime/container_test.go @@ -151,7 +151,7 @@ func TestContainerRun(t *testing.T) { cmd := []string{"--flag", "with", "some", "arg"} engine.EXPECT().ContainerRun(ctx, gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, spec *types.ContainerSpec, _ *container.RunOptions) (string, error) { - assert.Equal(t, labels, spec.Labels) + assert.EqualValues(t, labels, spec.Labels) assert.Equal(t, workdir, spec.WorkingDir) assert.Equal(t, network, spec.Endpoints[0].Target) assert.Equal(t, image, spec.Image) @@ -163,7 +163,7 @@ func TestContainerRun(t *testing.T) { "A_NORMAL_ENV": "hello-world", "A_SANDBOX_PATH": "/mnt/third/foobar", } - assert.Equal(t, e, spec.Environment) + assert.EqualValues(t, e, spec.Environment) expectedMounts := make(map[string]*types.Mount) for _, m := range mounts { diff --git a/core/pkg/sandboxer/container/engine.go b/core/pkg/sandboxer/container/engine.go index f4f20473b..c8c6ba201 100644 --- a/core/pkg/sandboxer/container/engine.go +++ b/core/pkg/sandboxer/container/engine.go @@ -17,6 +17,7 @@ import ( "drassi.run/core/config" "drassi.run/core/pkg/container" "drassi.run/core/pkg/container/cli" + "drassi.run/core/pkg/container/parser" "drassi.run/core/pkg/container/types" "drassi.run/core/pkg/model/records" "drassi.run/core/pkg/model/workflows" @@ -35,19 +36,22 @@ type Bootstrapper interface { } type engine struct { - client container.Engine - defaultImage string - provisioner *provision.Provisioner[*types.ContainerSpec] + client container.Engine + template *Template + provisioner *provision.Provisioner[*types.ContainerSpec] } -func New(client container.Engine, defaultImage string, prov *provision.Provisioner[*types.ContainerSpec]) sandboxer.Engine { - if defaultImage == "" { - defaultImage = DefaultImage +func New(client container.Engine, template *Template, prov *provision.Provisioner[*types.ContainerSpec]) sandboxer.Engine { + if template == nil { + template = &Template{Image: DefaultImage} + } else if template.Image == "" { + template = template.Copy() + template.Image = DefaultImage } return &engine{ - client: client, - defaultImage: defaultImage, - provisioner: prov, + client: client, + template: template, + provisioner: prov, } } @@ -77,12 +81,22 @@ func (e *engine) Launch(ctx context.Context, req *sandboxer.LaunchRequest) (*san ) if req.JobContainer == nil { - spec := &types.ContainerSpec{ - Image: e.defaultImage, - Entrypoint: []string{"sleep"}, - Command: []string{"infinity"}, - NetworkMode: "host", + tmpl := e.template.Copy() + if tmpl == nil { + tmpl = &Template{Image: DefaultImage} } + if len(tmpl.Entrypoint) == 0 && len(tmpl.Command) == 0 { + tmpl.Entrypoint = []string{"sleep"} + tmpl.Command = []string{"infinity"} + } else if len(tmpl.Entrypoint) == 0 { + tmpl.Entrypoint = []string{"sleep"} + } else if len(tmpl.Command) == 0 { + tmpl.Command = []string{"infinity"} + } + if tmpl.NetworkMode == "" { + tmpl.NetworkMode = "host" + } + spec := (*types.ContainerSpec)(tmpl) var err error launcher := e.launch @@ -194,7 +208,6 @@ func (e *engine) Bootstrap(ctx context.Context, sb sandboxer.Sandbox, req *sandb g, ctx := errgroup.WithContext(ctx) g.SetLimit(8) for name, def := range req.ServiceContainers { - name, def := name, def g.Go(func() error { if containerId, err := e.runContainer(ctx, def, refiners); err != nil { return err @@ -235,14 +248,14 @@ func (e *engine) parseContainer(def *workflows.Container, refiners []refiner) (s maps.Copy(spec.Environment, env) } for _, v := range def.Volumes { - if vol, err := cli.ParseVolume(v); err != nil { + if vol, err := parser.ParseVolume(v); err != nil { return nil, err } else { spec.Mounts = append(spec.Mounts, vol) } } for _, p := range def.Ports { - if pb, length, err := cli.ParsePublish(p); err != nil { + if pb, length, err := parser.ParsePublish(p); err != nil { return nil, err } else { for i := range length { diff --git a/core/pkg/sandboxer/container/engine_test.go b/core/pkg/sandboxer/container/engine_test.go index 7c57340b9..211775dc0 100644 --- a/core/pkg/sandboxer/container/engine_test.go +++ b/core/pkg/sandboxer/container/engine_test.go @@ -83,7 +83,7 @@ func (s *ContainerEngineTestSuite) TestLaunch() { s.Require().Equal("/opt/drassi/runtimes/node", spec.Mounts[0].Target) }) - eng := New(s.mockClient, "default:image", p) + eng := New(s.mockClient, &Template{Image: "default:image"}, p) sb := s.assertLaunch(eng, "c-123") s.Require().NoError(sb.Terminate(s.T().Context())) }) @@ -93,10 +93,41 @@ func (s *ContainerEngineTestSuite) TestLaunch() { s.Require().Empty(spec.Mounts) }) - eng := New(s.mockClient, "default:image", nil) + eng := New(s.mockClient, &Template{Image: "default:image"}, nil) sb := s.assertLaunch(eng, "c-456") s.Require().NoError(sb.Terminate(s.T().Context())) }) + + s.Run("with template options", func() { + tmpl := &Template{ + Image: "custom:image", + NetworkMode: "host", + Privileged: true, + User: "1000:1000", + Environment: map[string]string{"FOO": "BAR"}, + ContainerStorage: types.ContainerStorage{ + Mounts: []*types.Mount{ + {Type: "bind", Source: "/host", Target: "/container"}, + }, + }, + } + + s.mockContainerLifecycle("c-789", func(spec *types.ContainerSpec) { + s.Require().Equal("custom:image", spec.Image) + s.Require().Equal("host", spec.NetworkMode) + s.Require().True(spec.Privileged) + s.Require().Equal("1000:1000", spec.User) + s.Require().Equal("BAR", spec.Environment["FOO"]) + s.Require().Equal([]string{"sleep"}, spec.Entrypoint) + s.Require().Equal([]string{"infinity"}, spec.Command) + s.Require().Len(spec.Mounts, 1) + s.Require().Equal("/host", spec.Mounts[0].Source) + }) + + eng := New(s.mockClient, tmpl, nil) + sb := s.assertLaunch(eng, "c-789") + s.Require().NoError(sb.Terminate(s.T().Context())) + }) } func (s *ContainerEngineTestSuite) TestNewProvisioner() { diff --git a/core/pkg/sandboxer/container/template.go b/core/pkg/sandboxer/container/template.go new file mode 100644 index 000000000..b91f5c21f --- /dev/null +++ b/core/pkg/sandboxer/container/template.go @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: (c) 2024 The Drassi Authors + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package container + +import ( + "maps" + "slices" + + "drassi.run/core/pkg/container/types" + "drassi.run/core/pkg/model" + "github.com/pelletier/go-toml/v2" + "github.com/pelletier/go-toml/v2/unstable" +) + +var _ unstable.Unmarshaler = (*Template)(nil) + +type Template types.ContainerSpec + +func (t *Template) UnmarshalTOML(data []byte) error { + var rawMap any + if err := toml.Unmarshal(data, &rawMap); err != nil { + return err + } + return model.Decode(rawMap, (*types.ContainerSpec)(t)) +} + +func (t *Template) Copy() *Template { + if t == nil { + return nil + } + spec := (*types.ContainerSpec)(t) + clone := *spec + if spec.Command != nil { + clone.Command = slices.Clone(spec.Command) + } + if spec.Entrypoint != nil { + clone.Entrypoint = slices.Clone(spec.Entrypoint) + } + if spec.Environment != nil { + clone.Environment = maps.Clone(spec.Environment) + } + if spec.Labels != nil { + clone.Labels = maps.Clone(spec.Labels) + } + if spec.Annotations != nil { + clone.Annotations = maps.Clone(spec.Annotations) + } + if spec.Devices != nil { + clone.Devices = slices.Clone(spec.Devices) + } + if spec.DeviceCgroupRules != nil { + clone.DeviceCgroupRules = slices.Clone(spec.DeviceCgroupRules) + } + if spec.Exposes != nil { + clone.Exposes = make([]*types.Port, len(spec.Exposes)) + for i, p := range spec.Exposes { + if p != nil { + cp := *p + clone.Exposes[i] = &cp + } + } + } + if spec.Publish != nil { + clone.Publish = make([]*types.PortBinding, len(spec.Publish)) + for i, pb := range spec.Publish { + if pb != nil { + cpb := *pb + clone.Publish[i] = &cpb + } + } + } + if spec.Mounts != nil { + clone.Mounts = make([]*types.Mount, len(spec.Mounts)) + for i, m := range spec.Mounts { + if m != nil { + cm := *m + clone.Mounts[i] = &cm + } + } + } + if spec.VolumesFrom != nil { + clone.VolumesFrom = slices.Clone(spec.VolumesFrom) + } + if spec.StorageOpt != nil { + clone.StorageOpt = maps.Clone(spec.StorageOpt) + } + if spec.GroupAdd != nil { + clone.GroupAdd = slices.Clone(spec.GroupAdd) + } + if spec.CapAdd != nil { + clone.CapAdd = slices.Clone(spec.CapAdd) + } + if spec.CapDrop != nil { + clone.CapDrop = slices.Clone(spec.CapDrop) + } + if spec.SecurityOpt != nil { + clone.SecurityOpt = slices.Clone(spec.SecurityOpt) + } + if spec.Sysctls != nil { + clone.Sysctls = maps.Clone(spec.Sysctls) + } + return (*Template)(&clone) +} + +func (t *Template) Clone() *Template { + return t.Copy() +} + +func (t *Template) ContainerSpec() *types.ContainerSpec { + if t == nil { + return nil + } + return (*types.ContainerSpec)(t.Copy()) +} diff --git a/core/pkg/sandboxer/container/template_test.go b/core/pkg/sandboxer/container/template_test.go new file mode 100644 index 000000000..45208ecd6 --- /dev/null +++ b/core/pkg/sandboxer/container/template_test.go @@ -0,0 +1,261 @@ +/* + * SPDX-FileCopyrightText: (c) 2024 The Drassi Authors + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package container + +import ( + "bytes" + "testing" + + "drassi.run/core/pkg/container/types" + "github.com/pelletier/go-toml/v2" + "github.com/stretchr/testify/require" +) + +func TestTemplateUnmarshalTOML(t *testing.T) { + t.Run("unmarshals template correctly", func(t *testing.T) { + tomlData := []byte(` +image = "ghcr.io/drassi-run/ubuntu:26.04" +network_mode = "bridge" +privileged = true +user = "1000:1000" +working_dir = "/app" +command = ["run", "app"] +entrypoint = ["/entrypoint.sh"] +environment = { FOO = "bar", BAZ = "qux" } +labels = { "org.drassi.env" = "test" } +annotations = { "note" = "template" } +volumes = [ + "/host/path:/container/path:ro", + "/data:/data" +] +ports = [ + "8080:80/tcp", + "127.0.0.1:9090:90" +] +devices = ["/dev/kvm"] +device_cgroup_rules = ["c 10:200 rwm"] +cap_add = ["SYS_ADMIN"] +cap_drop = ["NET_RAW"] +security_opt = ["no-new-privileges:true"] +sysctls = { "net.ipv4.ip_forward" = "1" } +volumes_from = ["other-container:ro"] +storage_opt = { "size" = "20G" } +group_add = ["wheel"] +cpus = "1.5" +memory = 1073741824 +`) + + var tmpl Template + err := tmpl.UnmarshalTOML(tomlData) + require.NoError(t, err) + + require.Equal(t, "ghcr.io/drassi-run/ubuntu:26.04", tmpl.Image) + require.Equal(t, "bridge", tmpl.NetworkMode) + require.True(t, tmpl.Privileged) + require.Equal(t, "1000:1000", tmpl.User) + require.Equal(t, "/app", tmpl.WorkingDir) + require.Equal(t, []string{"run", "app"}, tmpl.Command) + require.Equal(t, []string{"/entrypoint.sh"}, tmpl.Entrypoint) + require.Equal(t, types.Mapping{"FOO": "bar", "BAZ": "qux"}, tmpl.Environment) + require.Equal(t, types.Mapping{"org.drassi.env": "test"}, tmpl.Labels) + require.Equal(t, types.Mapping{"note": "template"}, tmpl.Annotations) + require.Len(t, tmpl.Mounts, 2) + require.Equal(t, "/host/path", tmpl.Mounts[0].Source) + require.Equal(t, "/container/path", tmpl.Mounts[0].Target) + require.True(t, tmpl.Mounts[0].ReadOnly) + require.Equal(t, "/data", tmpl.Mounts[1].Source) + require.Equal(t, "/data", tmpl.Mounts[1].Target) + require.False(t, tmpl.Mounts[1].ReadOnly) + + require.Len(t, tmpl.Publish, 2) + require.Equal(t, uint16(8080), tmpl.Publish[0].HostPort) + require.Equal(t, uint16(80), tmpl.Publish[0].ContainerPort) + require.Equal(t, "tcp", tmpl.Publish[0].Protocol) + require.Equal(t, "127.0.0.1", tmpl.Publish[1].HostIP) + require.Equal(t, uint16(9090), tmpl.Publish[1].HostPort) + require.Equal(t, uint16(90), tmpl.Publish[1].ContainerPort) + + require.Equal(t, []string{"/dev/kvm"}, tmpl.Devices) + require.Equal(t, []string{"c 10:200 rwm"}, tmpl.DeviceCgroupRules) + require.Equal(t, []string{"SYS_ADMIN"}, tmpl.CapAdd) + require.Equal(t, []string{"NET_RAW"}, tmpl.CapDrop) + require.Equal(t, []string{"no-new-privileges:true"}, tmpl.SecurityOpt) + require.Equal(t, types.Mapping{"net.ipv4.ip_forward": "1"}, tmpl.Sysctls) + require.Equal(t, []string{"other-container:ro"}, tmpl.VolumesFrom) + require.Equal(t, types.Mapping{"size": "20G"}, tmpl.StorageOpt) + require.Equal(t, []string{"wheel"}, tmpl.GroupAdd) + require.Equal(t, "1.5", tmpl.CPUS) + require.Equal(t, types.UnitBytes(1073741824), tmpl.Memory) + }) + + t.Run("unmarshals template with short-form compose syntax", func(t *testing.T) { + tomlData := []byte(` +image = "ghcr.io/drassi-run/ubuntu:26.04" +environment = [ + "FOO=bar", + "BAZ=qux", + "ENABLE_FLAG" +] +labels = [ + "org.drassi.env=test", + "tier=backend" +] +annotations = [ + "note=template" +] +sysctls = [ + "net.ipv4.ip_forward=1" +] +storage_opt = [ + "size=20G" +] +expose = [ + "80", + "53/udp", + 443 +] +memory = "2g" +mem_reservation = "1g" +shm_size = "64m" +`) + var tmpl Template + err := tmpl.UnmarshalTOML(tomlData) + require.NoError(t, err) + + require.Equal(t, types.Mapping{"FOO": "bar", "BAZ": "qux", "ENABLE_FLAG": ""}, tmpl.Environment) + require.Equal(t, types.Mapping{"org.drassi.env": "test", "tier": "backend"}, tmpl.Labels) + require.Equal(t, types.Mapping{"note": "template"}, tmpl.Annotations) + require.Equal(t, types.Mapping{"net.ipv4.ip_forward": "1"}, tmpl.Sysctls) + require.Equal(t, types.Mapping{"size": "20G"}, tmpl.StorageOpt) + + require.Len(t, tmpl.Exposes, 3) + require.Equal(t, uint16(80), tmpl.Exposes[0].Number) + require.Equal(t, "tcp", tmpl.Exposes[0].Protocol) + require.Equal(t, uint16(53), tmpl.Exposes[1].Number) + require.Equal(t, "udp", tmpl.Exposes[1].Protocol) + require.Equal(t, uint16(443), tmpl.Exposes[2].Number) + require.Equal(t, "tcp", tmpl.Exposes[2].Protocol) + + require.Equal(t, types.UnitBytes(2*1024*1024*1024), tmpl.Memory) + require.Equal(t, types.UnitBytes(1024*1024*1024), tmpl.MemReservation) + require.Equal(t, types.UnitBytes(64*1024*1024), tmpl.ShmSize) + }) + + t.Run("decodes via toml.Decoder with EnableUnmarshalerInterface", func(t *testing.T) { + type Wrapper struct { + Template *Template `toml:"template"` + } + raw := []byte(` +[template] +image = "alpine:3.20" +network_mode = "none" +volumes = ["/tmp:/tmp:ro"] +`) + var w Wrapper + err := toml.NewDecoder(bytes.NewReader(raw)).EnableUnmarshalerInterface().Decode(&w) + require.NoError(t, err) + require.NotNil(t, w.Template) + require.Equal(t, "alpine:3.20", w.Template.Image) + require.Equal(t, "none", w.Template.NetworkMode) + require.Len(t, w.Template.Mounts, 1) + require.Equal(t, "/tmp", w.Template.Mounts[0].Source) + require.Equal(t, "/tmp", w.Template.Mounts[0].Target) + require.True(t, w.Template.Mounts[0].ReadOnly) + }) + + t.Run("invalid toml returns error", func(t *testing.T) { + var tmpl Template + err := tmpl.UnmarshalTOML([]byte("invalid toml = =")) + require.Error(t, err) + }) +} + +func TestTemplateCopy(t *testing.T) { + t.Run("nil receiver returns nil", func(t *testing.T) { + var tmpl *Template + require.Nil(t, tmpl.Copy()) + require.Nil(t, tmpl.Clone()) + require.Nil(t, tmpl.ContainerSpec()) + }) + + t.Run("deep copy preserves all fields and isolates mutations", func(t *testing.T) { + orig := &Template{ + Image: "ubuntu:latest", + NetworkMode: "bridge", + Command: []string{"echo", "hello"}, + Entrypoint: []string{"/bin/sh"}, + Environment: map[string]string{"A": "1"}, + Labels: map[string]string{"L": "2"}, + Annotations: map[string]string{"N": "3"}, + Devices: []string{"/dev/null"}, + DeviceCgroupRules: []string{"rwm"}, + Exposes: []*types.Port{ + {Number: 80, Protocol: "tcp"}, + }, + Publish: []*types.PortBinding{ + {HostPort: 8080, ContainerPort: 80, Protocol: "tcp"}, + }, + Mounts: []*types.Mount{ + {Type: "bind", Source: "/host", Target: "/container", ReadOnly: true}, + }, + VolumesFrom: []string{"base"}, + StorageOpt: map[string]string{"size": "10G"}, + GroupAdd: []string{"docker"}, + CapAdd: []string{"NET_ADMIN"}, + CapDrop: []string{"SYS_CHROOT"}, + SecurityOpt: []string{"apparmor=unconfined"}, + Sysctls: map[string]string{"net.ipv4.tcp_syncookies": "1"}, + } + + cp := orig.Copy() + require.NotNil(t, cp) + require.NotSame(t, orig, cp) + + // Mutate original slices and maps + orig.Command[0] = "sleep" + orig.Entrypoint[0] = "/bin/bash" + orig.Environment["A"] = "modified" + orig.Labels["L"] = "modified" + orig.Annotations["N"] = "modified" + orig.Devices[0] = "/dev/zero" + orig.DeviceCgroupRules[0] = "none" + orig.Exposes[0].Number = 443 + orig.Publish[0].HostPort = 8443 + orig.Mounts[0].Source = "/other" + orig.VolumesFrom[0] = "modified" + orig.StorageOpt["size"] = "50G" + orig.GroupAdd[0] = "root" + orig.CapAdd[0] = "ALL" + orig.CapDrop[0] = "ALL" + orig.SecurityOpt[0] = "seccomp=unconfined" + orig.Sysctls["net.ipv4.tcp_syncookies"] = "0" + + // Verify copied values were isolated + require.Equal(t, "echo", cp.Command[0]) + require.Equal(t, "/bin/sh", cp.Entrypoint[0]) + require.Equal(t, "1", cp.Environment["A"]) + require.Equal(t, "2", cp.Labels["L"]) + require.Equal(t, "3", cp.Annotations["N"]) + require.Equal(t, "/dev/null", cp.Devices[0]) + require.Equal(t, "rwm", cp.DeviceCgroupRules[0]) + require.Equal(t, uint16(80), cp.Exposes[0].Number) + require.Equal(t, uint16(8080), cp.Publish[0].HostPort) + require.Equal(t, "/host", cp.Mounts[0].Source) + require.Equal(t, "base", cp.VolumesFrom[0]) + require.Equal(t, "10G", cp.StorageOpt["size"]) + require.Equal(t, "docker", cp.GroupAdd[0]) + require.Equal(t, "NET_ADMIN", cp.CapAdd[0]) + require.Equal(t, "SYS_CHROOT", cp.CapDrop[0]) + require.Equal(t, "apparmor=unconfined", cp.SecurityOpt[0]) + require.Equal(t, "1", cp.Sysctls["net.ipv4.tcp_syncookies"]) + + // ContainerSpec() returns *types.ContainerSpec + spec := orig.ContainerSpec() + require.NotNil(t, spec) + require.Equal(t, orig.Image, spec.Image) + }) +} diff --git a/core/pkg/sandboxer/docker/engine.go b/core/pkg/sandboxer/docker/engine.go index 56a8dd2b9..2802a5a68 100644 --- a/core/pkg/sandboxer/docker/engine.go +++ b/core/pkg/sandboxer/docker/engine.go @@ -25,13 +25,15 @@ func init() { } type Config struct { - Endpoint string `toml:"endpoint" json:"endpoint,omitempty"` - Image string `toml:"image" json:"image,omitempty"` + Endpoint string `toml:"endpoint" json:"endpoint,omitempty"` + Template *container.Template `toml:"template" json:"template,omitempty"` } func DefaultConfig() *Config { return &Config{ - Image: container.DefaultImage, + Template: &container.Template{ + Image: container.DefaultImage, + }, } } @@ -84,5 +86,5 @@ func New(cfg *Config, prov *provision.Provisioner[*types.ContainerSpec]) (sandbo } client = c.WithTelemetry(client) - return container.New(client, cfg.Image, prov), nil + return container.New(client, cfg.Template, prov), nil } diff --git a/core/pkg/sandboxer/docker/engine_test.go b/core/pkg/sandboxer/docker/engine_test.go index bab366fc9..269f48f91 100644 --- a/core/pkg/sandboxer/docker/engine_test.go +++ b/core/pkg/sandboxer/docker/engine_test.go @@ -11,6 +11,7 @@ import ( "drassi.run/core/config" mock_store "drassi.run/core/mock/store/oci" + "drassi.run/core/pkg/container/types" "drassi.run/core/pkg/sandboxer" "drassi.run/core/pkg/sandboxer/container" "github.com/stretchr/testify/require" @@ -20,7 +21,8 @@ import ( func TestDefaultConfig(t *testing.T) { cfg := DefaultConfig() require.NotNil(t, cfg) - require.Equal(t, container.DefaultImage, cfg.Image) + require.NotNil(t, cfg.Template) + require.Equal(t, container.DefaultImage, cfg.Template.Image) require.Empty(t, cfg.Endpoint) } @@ -33,6 +35,110 @@ func TestFactoryRegistration(t *testing.T) { require.NotNil(t, f) } +func TestFactoryWithTemplateConfig(t *testing.T) { + rawToml := ` +endpoint = "unix:///var/run/docker.sock" +[template] +image = "ghcr.io/drassi-run/ubuntu:26.04" +network_mode = "host" +privileged = true +user = "1000:1000" +volumes = [ + "/var/run/docker.sock:/var/run/docker.sock", + "/data:/data:ro" +] +ports = [ + "8080:80" +] +cap_add = ["SYS_ADMIN"] +devices = ["/dev/kvm"] +cpus = "2" +memory = 2147483648 +` + sbConfig := &config.Sandboxer{ + Provider: config.ProviderDocker, + Config: []byte(rawToml), + } + f, err := sandboxer.NewFactory(sbConfig) + require.NoError(t, err) + require.NotNil(t, f) + + fact, ok := f.(*factory) + require.True(t, ok) + require.Equal(t, "unix:///var/run/docker.sock", fact.cfg.Endpoint) + require.NotNil(t, fact.cfg.Template) + tmpl := fact.cfg.Template + require.Equal(t, "ghcr.io/drassi-run/ubuntu:26.04", tmpl.Image) + require.Equal(t, "host", tmpl.NetworkMode) + require.True(t, tmpl.Privileged) + require.Equal(t, "1000:1000", tmpl.User) + require.Len(t, tmpl.Mounts, 2) + require.Equal(t, "/var/run/docker.sock", tmpl.Mounts[0].Source) + require.Equal(t, "/var/run/docker.sock", tmpl.Mounts[0].Target) + require.False(t, tmpl.Mounts[0].ReadOnly) + require.Equal(t, "/data", tmpl.Mounts[1].Source) + require.Equal(t, "/data", tmpl.Mounts[1].Target) + require.True(t, tmpl.Mounts[1].ReadOnly) + require.Len(t, tmpl.Publish, 1) + require.Equal(t, uint16(8080), tmpl.Publish[0].HostPort) + require.Equal(t, uint16(80), tmpl.Publish[0].ContainerPort) + require.Equal(t, []string{"SYS_ADMIN"}, tmpl.CapAdd) + require.Equal(t, []string{"/dev/kvm"}, tmpl.Devices) + require.Equal(t, "2", tmpl.CPUS) + require.Equal(t, types.UnitBytes(2147483648), tmpl.Memory) +} + +func TestFactoryWithShortFormTemplateConfig(t *testing.T) { + rawToml := ` +endpoint = "unix:///var/run/docker.sock" +[template] +image = "ghcr.io/drassi-run/ubuntu:26.04" +environment = [ + "APP_ENV=production", + "DEBUG=false" +] +labels = [ + "org.drassi.env=prod" +] +annotations = [ + "note=short-form" +] +sysctls = [ + "net.ipv4.ip_forward=1" +] +expose = [ + "80/tcp", + 53 +] +memory = "4g" +shm_size = "256m" +` + sbConfig := &config.Sandboxer{ + Provider: config.ProviderDocker, + Config: []byte(rawToml), + } + f, err := sandboxer.NewFactory(sbConfig) + require.NoError(t, err) + require.NotNil(t, f) + + fact, ok := f.(*factory) + require.True(t, ok) + require.NotNil(t, fact.cfg.Template) + tmpl := fact.cfg.Template + require.Equal(t, "production", tmpl.Environment["APP_ENV"]) + require.Equal(t, "false", tmpl.Environment["DEBUG"]) + require.Equal(t, "prod", tmpl.Labels["org.drassi.env"]) + require.Equal(t, "short-form", tmpl.Annotations["note"]) + require.Equal(t, "1", tmpl.Sysctls["net.ipv4.ip_forward"]) + require.Len(t, tmpl.Exposes, 2) + require.Equal(t, uint16(80), tmpl.Exposes[0].Number) + require.Equal(t, "tcp", tmpl.Exposes[0].Protocol) + require.Equal(t, uint16(53), tmpl.Exposes[1].Number) + require.Equal(t, "tcp", tmpl.Exposes[1].Protocol) + require.Equal(t, types.UnitBytes(4*1024*1024*1024), tmpl.Memory) + require.Equal(t, types.UnitBytes(256*1024*1024), tmpl.ShmSize) +} + func TestFactoryCreate(t *testing.T) { ctrl := gomock.NewController(t) store := mock_store.NewMockManager(ctrl) diff --git a/core/pkg/sandboxer/factory.go b/core/pkg/sandboxer/factory.go index cb6a8f328..6fcb7b9ee 100644 --- a/core/pkg/sandboxer/factory.go +++ b/core/pkg/sandboxer/factory.go @@ -7,6 +7,7 @@ package sandboxer import ( + "bytes" "fmt" "strings" "sync" @@ -42,7 +43,7 @@ func Register[T any](provider string, d func() T, fn func(cfg T) Factory) { factories[provider] = func(raw unstable.RawMessage) (Factory, error) { cfg := d() if len(raw) > 0 { - if err := toml.Unmarshal(raw, cfg); err != nil { + if err := toml.NewDecoder(bytes.NewReader(raw)).EnableUnmarshalerInterface().Decode(cfg); err != nil { return nil, fmt.Errorf("unmarshal provider %q config: %v", provider, err) } } diff --git a/core/pkg/sandboxer/sandbox_layer.go b/core/pkg/sandboxer/sandbox_layer.go index f3ef5fa1b..828994b40 100644 --- a/core/pkg/sandboxer/sandbox_layer.go +++ b/core/pkg/sandboxer/sandbox_layer.go @@ -13,24 +13,24 @@ import ( type layeredSandbox struct { Sandbox - underlay Sandbox + Base Sandbox } -func NewLayeredSandbox(main Sandbox, underlay Sandbox) Sandbox { +func NewLayeredSandbox(main Sandbox, base Sandbox) Sandbox { return &layeredSandbox{ - Sandbox: main, - underlay: underlay, + Sandbox: main, + Base: base, } } func (sb *layeredSandbox) Terminate(ctx context.Context) error { errs := make([]error, 2) errs[0] = sb.Sandbox.Terminate(ctx) - errs[1] = sb.underlay.Terminate(ctx) + errs[1] = sb.Base.Terminate(ctx) return errors.Join(errs...) } func (sb *layeredSandbox) Underlay() Sandbox { - return sb.underlay + return sb.Base }