diff --git a/engine/resources/docker_container.go b/engine/resources/docker_container.go index 04dcfda71a..bbb7d29d77 100644 --- a/engine/resources/docker_container.go +++ b/engine/resources/docker_container.go @@ -21,20 +21,25 @@ package resources import ( "context" + "errors" "fmt" "io/ioutil" + "reflect" "regexp" - "strings" "time" + "github.com/docker/docker/api/types/network" + "github.com/purpleidea/mgmt/engine" "github.com/purpleidea/mgmt/engine/traits" "github.com/purpleidea/mgmt/util" "github.com/purpleidea/mgmt/util/errwrap" + dockeropts "github.com/docker/cli/opts" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/mount" "github.com/docker/docker/client" "github.com/docker/go-connections/nat" ) @@ -44,8 +49,8 @@ const ( ContainerRunning = "running" // ContainerStopped is the stopped container state. ContainerStopped = "stopped" - // ContainerRemoved is the removed container state. - ContainerRemoved = "removed" + // ContainerAbsent is the absent container state. + ContainerAbsent = "absent" // initCtxTimeout is the length of time, in seconds, before requests are // cancelled in Init. @@ -63,24 +68,56 @@ func init() { type DockerContainerRes struct { traits.Base // add the base methods without re-implementation traits.Edgeable + traits.Groupable - // State of the container must be running, stopped, or removed. - State string `yaml:"state"` - // Image is a docker image, or image:tag. - Image string `yaml:"image"` + // State of the container must be running, stopped, or absent. + State string `lang:"state"` + + // Networks + Networks []string `lang:"networks"` + + NetworkMode string `lang:"network_mode"` + + // Entrypoint + Entrypoint []string `lang:"entrypoint"` // Cmd is a command, or list of commands to run on the container. - Cmd []string `yaml:"cmd"` - // Env is a list of environment variables. E.g. ["VAR=val",]. - Env []string `yaml:"env"` - // Ports is a map of port bindings. E.g. {"tcp" => {80 => 8080},}. - Ports map[string]map[int64]int64 `yaml:"ports"` + Cmd []string `lang:"cmd"` + // DNS is a list of custom DNS servers + DNS []string `lang:"dns"` + // Devices is a list of device mappings + Devices []string `lang:"devices"` + // Domainname is the Domain name of the container + Domainname *string `lang:"domainname"` + // Env is a map of environment variables. E.g. {"VAR" => "val",}. + Env map[string]string `lang:"env"` + // Hostname is the hostname of the container + Hostname *string `lang:"hostname"` + // Image is a docker image, or image:tag. + Image string `lang:"image"` + // Labels is a list of metadata labels + Labels map[string]string `lang:"labels"` + // Ports is a map of port bindings. E.g. {"tcp" => {80 => "1.2.3.4:8080"},}. + Ports map[string]map[int64]string `lang:"ports"` + // portSet + portSet nat.PortSet + // portMap + portMap nat.PortMap + // Restart is the policy used to determine how to restart the container + Restart *string `lang:"restart"` + // parsed restart policy and assigned during Validate() + restartPolicy container.RestartPolicy + // User is the username/uid that will run the cmd inside the container + User *string `lang:"user"` + // WorkingDir is the working directory of the container init process + WorkingDir *string `lang:"workdir"` + // APIVersion allows you to override the host's default client API // version. - APIVersion string `yaml:"apiversion"` + APIVersion string `lang:"apiversion"` // Force, if true, this will destroy and redeploy the container if the // image is incorrect. - Force bool `yaml:"force"` + Force bool `lang:"force"` client *client.Client // docker api client @@ -95,10 +132,10 @@ func (obj *DockerContainerRes) Default() engine.Res { } // Validate if the params passed in are valid data. -func (obj *DockerContainerRes) Validate() error { +func (obj DockerContainerRes) Validate() error { // validate state - if obj.State != ContainerRunning && obj.State != ContainerStopped && obj.State != ContainerRemoved { - return fmt.Errorf("state must be running, stopped or removed") + if obj.State != ContainerRunning && obj.State != ContainerStopped && obj.State != ContainerAbsent { + return fmt.Errorf("state must be running, stopped or absent") } // make sure an image is specified @@ -107,23 +144,33 @@ func (obj *DockerContainerRes) Validate() error { } // validate env - for _, env := range obj.Env { - if !strings.Contains(env, "=") || strings.Contains(env, " ") { - return fmt.Errorf("invalid environment variable: %s", env) + for key := range obj.Env { + if key == "" { + return fmt.Errorf("environment variable name cannot be empty") } } // validate ports - for k, v := range obj.Ports { - if k != "tcp" && k != "udp" && k != "sctp" { + var portSpecs []string + for proto, mapping := range obj.Ports { + if proto != "tcp" && proto != "udp" && proto != "sctp" { return fmt.Errorf("ports primary key should be tcp, udp or sctp") } - for p, q := range v { - if (p < 1 || p > 65535) || (q < 1 || q > 65535) { + for ctr, host := range mapping { + if ctr < 1 || ctr > 65535 { return fmt.Errorf("ports must be between 1 and 65535") } + portSpecs = append(portSpecs, fmt.Sprintf("%s:%d/%s", host, ctr, proto)) } } + var err error + //obj.portSet, obj.portMap, err = nat.ParsePortSpecs(portSpecs) + _, _, err = nat.ParsePortSpecs(portSpecs) + // FIXME(frebib): populate portSet and portMap inside Init() + if err != nil { + // programming error; should be caught in validate + return errwrap.Wrapf(err, "port bindings invalid") + } // validate APIVersion if obj.APIVersion != "" { @@ -136,6 +183,36 @@ func (obj *DockerContainerRes) Validate() error { } } + // validate restart policy + if obj.Restart != nil { + policy, err := dockeropts.ParseRestartPolicy(*obj.Restart) + if err != nil { + return fmt.Errorf("invalid restart policy: %s", err) + } + if !(policy.IsAlways() || policy.IsNone() || + policy.IsOnFailure() || policy.IsUnlessStopped()) { + return fmt.Errorf("restart-policy must be always, on-failure, unless-stopped or no") + } + obj.restartPolicy = container.RestartPolicy(policy) + } + + // validate working directory + if obj.WorkingDir != nil { + workdir := *obj.WorkingDir + if workdir == "" { + return errors.New("working directory cannot be empty") + } + if workdir[0:1] != "/" { + return errors.New("working directory must be absolute") + } + } + + // validate network mode + if obj.NetworkMode != "" && obj.NetworkMode != "none" && + obj.NetworkMode != "bridge" && obj.NetworkMode != "host" { + //return errors.New("network mode must be one of none, bridge or host") + } + return nil } @@ -156,11 +233,14 @@ func (obj *DockerContainerRes) Init(init *engine.Init) error { // Validate the image. resp, err := obj.client.ImageSearch(ctx, obj.Image, types.ImageSearchOptions{Limit: 1}) if err != nil { - return errwrap.Wrapf(err, "error searching for image") + obj.init.Logf(errwrap.Wrapf(err, "error searching for image").Error()) + //return errwrap.Wrapf(err, "error searching for image") } if len(resp) == 0 { - return fmt.Errorf("image: %s not found", obj.Image) + obj.init.Logf("image: %s not found", obj.Image) + //return fmt.Errorf("image: %s not found", obj.Image) } + return nil } @@ -181,13 +261,10 @@ func (obj *DockerContainerRes) Watch() error { var send = false // send event? for { select { - case event, ok := <-eventChan: + case _, ok := <-eventChan: if !ok { // channel shutdown return nil } - if obj.init.Debug { - obj.init.Logf("%+v", event) - } send = true case err, ok := <-errChan: @@ -210,7 +287,7 @@ func (obj *DockerContainerRes) Watch() error { // CheckApply method for Docker resource. func (obj *DockerContainerRes) CheckApply(apply bool) (bool, error) { - var id string + var ctr types.ContainerJSON var destroy bool ctx, cancel := context.WithTimeout(context.Background(), checkApplyCtxTimeout*time.Second) @@ -219,57 +296,71 @@ func (obj *DockerContainerRes) CheckApply(apply bool) (bool, error) { // List any container whose name matches this resource. opts := types.ContainerListOptions{ All: true, - Filters: filters.NewArgs(filters.KeyValuePair{Key: "name", Value: obj.Name()}), + Filters: filters.NewArgs(filters.Arg("name", obj.Name())), } containerList, err := obj.client.ContainerList(ctx, opts) if err != nil { return false, errwrap.Wrapf(err, "error listing containers") } + // this should never happen if len(containerList) > 1 { return false, fmt.Errorf("more than one container named %s", obj.Name()) } - if len(containerList) == 0 && obj.State == ContainerRemoved { + // handle ContainerAbsent here else it'll cause containerStop to error + if len(containerList) == 0 && + (obj.State == ContainerStopped || obj.State == ContainerAbsent) { return true, nil } + if len(containerList) == 1 { - // If the state and image are correct, we're done. - if containerList[0].State == obj.State && containerList[0].Image == obj.Image { - return true, nil - } - id = containerList[0].ID // save the id for later - // If the image is wrong, and force is true, mark the container for - // destruction. - if containerList[0].Image != obj.Image && obj.Force { + // inspect the container for all the gory volume/network details + ctr, err = obj.client.ContainerInspect(ctx, containerList[0].ID) + if err != nil { + return false, errwrap.Wrapf(err, "error inspecting container: %s", containerList[0].ID) + } + + // Check first all properties that require the container to be recreated + // in case we pointlessly change some properties, but need to destroy + // the container afterwards and recreate it anyway. + if err := obj.CmpContainer(ctx, ctr); err != nil { + obj.init.Logf(err.Error()) destroy = true } - // Otherwise return an error. - if containerList[0].Image != obj.Image && !obj.Force { - return false, fmt.Errorf("%s exists but has the wrong image: %s", obj.Name(), containerList[0].Image) + // only update the container when it's in the correct state + if !destroy && obj.State == ctr.State.Status { + // Final checks are to ensure all updatable configurables are + // correct and don't need to be changed. + return obj.containerUpdate(ctx, ctr.ID, apply) + } + + // Return an error if the running state does not match and it cannot + // be updated without destroying the container. + if destroy && !obj.Force { + return false, fmt.Errorf("%s exists but the config does not match", obj.Name()) } } - if !apply { - return false, nil + if !apply { // do nothing and inform whether we would have done something + return !destroy, nil } if obj.State == ContainerStopped { // container exists and should be stopped - return false, obj.containerStop(ctx, id, nil) + return false, obj.containerStop(ctx, ctr.ID, nil) } - if obj.State == ContainerRemoved { // container exists and should be removed - if err := obj.containerStop(ctx, id, nil); err != nil { - return false, err + if obj.State == ContainerAbsent { // container exists and should be absent + if ctr.State.Status == "removing" { + // Already being removed by Docker. Rare but it can happen + return false, nil } - return false, obj.containerRemove(ctx, id, types.ContainerRemoveOptions{}) + return false, obj.containerRemove(ctx, ctr.ID, types.ContainerRemoveOptions{Force: true}) } if destroy { - if err := obj.containerStop(ctx, id, nil); err != nil { - return false, err - } - if err := obj.containerRemove(ctx, id, types.ContainerRemoveOptions{}); err != nil { + options := types.ContainerRemoveOptions{Force: true} + if err := obj.containerRemove(ctx, ctr.ID, options); err != nil { return false, err } containerList = []types.Container{} // zero the list @@ -288,43 +379,55 @@ func (obj *DockerContainerRes) CheckApply(apply bool) (bool, error) { // set up port bindings containerConfig := &container.Config{ - Image: obj.Image, Cmd: obj.Cmd, - Env: obj.Env, - ExposedPorts: make(map[nat.Port]struct{}), + Domainname: util.StrOrEmpty(obj.Domainname), + Entrypoint: obj.Entrypoint, + Env: util.StrMapKeyEqualValue(obj.Env), + ExposedPorts: obj.portSet, + Hostname: util.StrOrEmpty(obj.Hostname), + Image: obj.Image, + User: util.StrOrEmpty(obj.User), + WorkingDir: util.StrOrEmpty(obj.WorkingDir), } hostConfig := &container.HostConfig{ - PortBindings: make(map[nat.Port][]nat.PortBinding), - } - - for k, v := range obj.Ports { - for p, q := range v { - containerConfig.ExposedPorts[nat.Port(k)] = struct{}{} - hostConfig.PortBindings[nat.Port(fmt.Sprintf("%d/%s", p, k))] = []nat.PortBinding{ - { - HostIP: "0.0.0.0", - HostPort: fmt.Sprintf("%d", q), - }, - } - } + Mounts: obj.getMounts(), + PortBindings: obj.portMap, + RestartPolicy: obj.restartPolicy, + NetworkMode: container.NetworkMode(obj.NetworkMode), } - c, err := obj.client.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, obj.Name()) + networkConfig := &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{}, + } + for _, name := range obj.Networks { + networkConfig.EndpointsConfig[name] = &network.EndpointSettings{} + } + + c, err := obj.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, obj.Name()) if err != nil { return false, errwrap.Wrapf(err, "error creating container") } - id = c.ID + + ctr, err = obj.client.ContainerInspect(ctx, c.ID) + if err != nil { + return false, errwrap.Wrapf(err, "error inspecting container: %s", containerList[0].ID) + } + } + + if ctr.State.Status == "restarting" || ctr.State.Status == "removing" { + // Docker will restart the container on it's own, or remove it shortly + return false, nil } - return false, obj.containerStart(ctx, id, types.ContainerStartOptions{}) + return false, obj.containerStart(ctx, ctr.ID, types.ContainerStartOptions{}) } // containerStart starts the specified container, and waits for it to start. func (obj *DockerContainerRes) containerStart(ctx context.Context, id string, opts types.ContainerStartOptions) error { // Get an events channel for the container we're about to start. eventOpts := types.EventsOptions{ - Filters: filters.NewArgs(filters.KeyValuePair{Key: "container", Value: id}), + Filters: filters.NewArgs(filters.Arg("container", id)), } eventCh, errCh := obj.client.Events(ctx, eventOpts) // Start the container. @@ -368,6 +471,94 @@ func (obj *DockerContainerRes) containerRemove(ctx context.Context, id string, o return nil } +func (obj *DockerContainerRes) containerUpdate(ctx context.Context, id string, apply bool) (bool, error) { + changed := false + + ctr, err := obj.client.ContainerInspect(ctx, id) + if err != nil { + return false, errwrap.Wrapf(err, "error inspecting container: %s", id) + } + + // check properties that can be changed at runtime + if obj.restartPolicy.Name != "" && !ctr.HostConfig.RestartPolicy.IsSame(&obj.restartPolicy) { + if !apply { + return false, nil + } + obj.init.Logf("updating restart policy") + _, err = obj.client.ContainerUpdate(ctx, id, container.UpdateConfig{ + RestartPolicy: obj.restartPolicy, + }) + if err != nil { + return false, errwrap.Wrapf(err, "failed to update restart policy") + } + changed = true + } + + return !changed, nil +} + +func (obj *DockerContainerRes) getMounts() []mount.Mount { + var mounts []mount.Mount + + for _, res := range obj.GetGroup() { + mnt, ok := res.(*DockerContainerMountRes) // convert from GroupableRes + if !ok { + continue + } + + mounts = append(mounts, mnt.GetMount()) + } + + return mounts +} + +// compareEnv compares the environment of the running container with the obj.Env excluding the environment set in the container backing image returning true if the running container environment matches the obj.Env +func (obj *DockerContainerRes) compareEnv(imgEnv, env []string) bool { + // []string{key=value} representation of the map[string]string + objEnv := util.StrMapKeyEqualValue(obj.Env) + + var runningEnv []string + for _, envVar := range env { + // skip vars that are specified by the container and also not explicitly + // specified in the container config + if util.StrInList(envVar, imgEnv) && !util.StrInList(envVar, objEnv) { + continue + } + runningEnv = append(runningEnv, envVar) + } + + return util.StrSliceEqual(runningEnv, objEnv) +} + +/* +// compareLabels compares the Labels of the running container with the obj.Labels +// excluding the Labels set in the container backing image returning +// true if the running container Labels matches the obj.Labels +func (obj *DockerContainerRes) compareLabels(ctx context.Context, image string, Labels []string) (bool, error) { + // get the image Labels to remove the vars set in the image + img, _, err := obj.client.ImageInspectWithRaw(ctx, image) + if err != nil { + return false, err + } + + // []string{key=value} representation of the map[string]string + objLabels := util.StrMapKeyEqualValue(obj.Labels) + + var runningLabels []string + for _, LabelsVar := range Labels { + // skip vars that are specified by the container and also not explicitly + // specified in the container config + if util.StrInList(LabelsVar, img.ContainerConfig.Labels) && !util. + StrInList(LabelsVar, objLabels) { + continue + } + runningLabels = append(runningLabels, LabelsVar) + } + + return util.StrSliceEqual(runningLabels, objLabels), nil +} +*/ + // Cmp compares two resources and returns an error if they are not equivalent. func (obj *DockerContainerRes) Cmp(r engine.Res) error { // we can only compare DockerContainerRes to others of the same resource kind @@ -379,24 +570,26 @@ func (obj *DockerContainerRes) Cmp(r engine.Res) error { if obj.State != res.State { return fmt.Errorf("the State differs") } + if !util.StrSliceEqual(obj.Cmd, res.Cmd) { + return fmt.Errorf("the Image differs") + } if obj.Image != res.Image { return fmt.Errorf("the Image differs") } + if !reflect.DeepEqual(obj.User, res.User) { + return fmt.Errorf("the User differs") + } if err := util.SortedStrSliceCompare(obj.Cmd, res.Cmd); err != nil { - return errwrap.Wrapf(err, "the Cmd field differs") + return errwrap.Wrapf(err, "the Cmd differs") } - if err := util.SortedStrSliceCompare(obj.Env, res.Env); err != nil { - return errwrap.Wrapf(err, "tne Env field differs") + if err := util.SortedStrSliceCompare(obj.Entrypoint, res.Entrypoint); err != nil { + return errwrap.Wrapf(err, "the Entrypoint differs") } - if len(obj.Ports) != len(res.Ports) { - return fmt.Errorf("the Ports length differs") + if !reflect.DeepEqual(obj.Env, res.Env) { + return fmt.Errorf("the Env differs") } - for k, v := range obj.Ports { - for p, q := range v { - if w, ok := res.Ports[k][p]; !ok || q != w { - return fmt.Errorf("the Ports field differs") - } - } + if !reflect.DeepEqual(obj.Ports, res.Ports) { + return fmt.Errorf("the Ports differ") } if obj.APIVersion != res.APIVersion { return fmt.Errorf("the APIVersion differs") @@ -404,9 +597,106 @@ func (obj *DockerContainerRes) Cmp(r engine.Res) error { if obj.Force != res.Force { return fmt.Errorf("the Force field differs") } + if !reflect.DeepEqual(obj.WorkingDir, res.WorkingDir) { + return fmt.Errorf("the WorkingDir field differs") + } + + return nil +} + +// Cmp compares a DockerContainerRes to a types.ContainerJSON and returns an +// error if they're not equivalent +func (obj *DockerContainerRes) CmpContainer(ctx context.Context, ctr types.ContainerJSON) error { + // get the image Labels to remove the vars set in the image + img, _, err := obj.client.ImageInspectWithRaw(ctx, ctr.Image) + if err != nil { + return errwrap.Wrapf(err, "failed to inspect container image") + } + imgCfg := img.ContainerConfig + + // cmpStrResCtrImg returns true if the container is in the correct state + cmpStrResCtrImg := func(res *string, ctr, img string) bool { + return (res == nil && ctr == img) || (res != nil && *res == ctr) + } + // cmpStrSliceResCtrImg returns true if the container is in the correct state + cmpStrSliceResCtrImg := func(res, ctr, img []string) bool { + return (res == nil && util.StrSliceEqual(ctr, img)) || + (res != nil && util.StrSliceEqual(res, ctr)) + } + + if obj.Image != ctr.Config.Image { + return fmt.Errorf("the container Image differs") + } + + if !cmpStrSliceResCtrImg(obj.Cmd, ctr.Config.Cmd, img.Config.Cmd) { + return fmt.Errorf("the container Cmd differs") + } + + if !cmpStrSliceResCtrImg(obj.Entrypoint, ctr.Config.Entrypoint, img.Config.Entrypoint) { + return fmt.Errorf("the container Entrypoint differs") + } + + if obj.Hostname != nil && *obj.Hostname != ctr.Config.Hostname { + return fmt.Errorf("the container Hostname differs") + } + + if obj.Domainname != nil && *obj.Domainname != ctr.Config.Domainname { + return fmt.Errorf("the container Domainname differs") + } + + if !cmpStrResCtrImg(obj.User, ctr.Config.User, imgCfg.User) { + return fmt.Errorf("the container User differs") + } + + if !obj.compareEnv(img.Config.Env, ctr.Config.Env) { + return fmt.Errorf("the container Env differs") + } + + mounts := obj.getMounts() + if len(mounts) != len(ctr.HostConfig.Mounts) { + return fmt.Errorf("the Mount count differs") + } + + for i := range mounts { + var mnt *mount.Mount + + // find the matching Mount + for j := range ctr.HostConfig.Mounts { + if mounts[i].Target == ctr.HostConfig.Mounts[j].Target { + mnt = &ctr.HostConfig.Mounts[j] + break + } + } + // Not found, or found but doesn't match + if mnt == nil || !reflect.DeepEqual(&mounts[i], mnt) { + return fmt.Errorf("the container Mounts differ") + } + } + + if !cmpStrResCtrImg(obj.WorkingDir, ctr.Config.WorkingDir, img.Config.WorkingDir) { + return fmt.Errorf("the container WorkingDir differs") + } + return nil } +// GroupCmp returns whether two resources can be grouped together or not. Can +// these two resources be merged, aka, does this resource support doing so? Will +// resource allow itself to be grouped _into_ this obj? +func (obj *DockerContainerRes) GroupCmp(r engine.GroupableRes) error { + res, ok := r.(*DockerContainerMountRes) + if ok { + // group mounts matching this container name + if res.Container != obj.Name() { + return fmt.Errorf("resource groups with a different container name") + } + + return nil + } + + return fmt.Errorf("resource is not the right kind") +} + // DockerContainerUID is the UID struct for DockerContainerRes. type DockerContainerUID struct { engine.BaseUID @@ -425,7 +715,7 @@ type DockerContainerResAutoEdges struct { func (obj *DockerContainerRes) AutoEdges() (engine.AutoEdge, error) { var result []engine.ResUID var reversed bool - if obj.State != "removed" { + if obj.State != ContainerAbsent { reversed = true } result = append(result, &DockerImageUID{ @@ -434,13 +724,21 @@ func (obj *DockerContainerRes) AutoEdges() (engine.AutoEdge, error) { }, image: dockerImageNameTag(obj.Image), }) + for _, name := range obj.Networks { + result = append(result, &DockerNetworkUID{ + BaseUID: engine.BaseUID{ + Reversed: &reversed, + }, + network: name, + }) + } return &DockerContainerResAutoEdges{ UIDs: result, pointer: 0, }, nil } -// Next returnes the next automatic edge. +// Next returns the next automatic edge. func (obj *DockerContainerResAutoEdges) Next() []engine.ResUID { if len(obj.UIDs) == 0 { return nil diff --git a/engine/resources/docker_network.go b/engine/resources/docker_network.go new file mode 100644 index 0000000000..5485357c3e --- /dev/null +++ b/engine/resources/docker_network.go @@ -0,0 +1,724 @@ +// Mgmt +// Copyright (C) 2013-2021+ James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//go:build !nodocker +// +build !nodocker + +package resources + +import ( + "context" + "errors" + "fmt" + "net" + "reflect" + "regexp" + "strings" + "time" + + "github.com/apparentlymart/go-cidr/cidr" + "github.com/docker/docker/api/types/network" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/client" + + "github.com/purpleidea/mgmt/engine" + "github.com/purpleidea/mgmt/engine/traits" + "github.com/purpleidea/mgmt/util/errwrap" +) + +func init() { + engine.RegisterResource("docker:network", func() engine.Res { return &DockerNetworkRes{} }) + engine.RegisterResource("docker:network:address", func() engine.Res { return &DockerNetworkAddressRes{} }) +} + +// DockerNetworkRes is a docker network resource. The resource's name must be a +// docker network in any supported format (url, network, or network:tag). +type DockerNetworkRes struct { + traits.Base // add the base methods without re-implementation + traits.Edgeable + traits.Groupable + traits.GraphQueryable + + // State of the network must be exists or absent. + State string `lang:"state"` + // Force defines whether containers attached to a network should be stopped + // and removed before removing the network, else CheckApply will fail. + Force bool `lang:"force"` + + // Driver defines the network driver. Defaults to 'bridge' + Driver string `lang:"driver"` + // Enable IPv6 defines whether IPv6 should be enabled in the network + EnableIPv6 bool `lang:"enableipv6"` + // Labels are key=value pairs to label the network with + Labels map[string]string `lang:"labels"` + // Options are key=value pairs to pass to the network driver + Options map[string]string `lang:"options"` + + // TODO: Add basic address block to docker:network when lang allows anonymous struct members + // IPAM defines IP address blocks/gateways for the network. + // Embed a single IPAM block within the resource. Additional blocks can + // be provided by autogrouping a docker:network:address Res + // *IPAM + // IPAMDriver defines the IPAM driver. + IPAMDriver string `lang:"ipamdriver"` + // IPAMOptions are key=value pairs to pass to the IPAM driver + IPAMOptions map[string]string `lang:"ipamoptions"` + + // APIVersion allows you to override the host's default client API + // version. + APIVersion string `lang:"apiversion"` + + client *client.Client // docker api client + init *engine.Init +} + +// Default returns some sensible defaults for this resource. +func (obj *DockerNetworkRes) Default() engine.Res { + return &DockerNetworkRes{ + Driver: "bridge", + Force: false, + } +} + +// Validate if the params passed in are valid data. +func (obj *DockerNetworkRes) Validate() error { + // validate state + if obj.State != "exists" && obj.State != "absent" { + return fmt.Errorf("state must be exists or absent") + } + + /* + TODO: Add basic address block to docker:network when lang allows anonymous struct members + if obj.Subnet != "" { + ipam := DockerNetworkAddressRes{ + Network: obj.Name(), + Subnet: obj.Subnet, + IPRange: obj.IPRange, + Gateway: obj.Gateway, + AuxAddress: obj.AuxAddress, + } + if err := ipam.Validate(); err != nil { + return err + } + } + */ + for name := range obj.IPAMOptions { + if strings.TrimSpace(name) == "" { + return errors.New("ipam options must have a name") + } + } + + for name := range obj.Labels { + if strings.TrimSpace(name) == "" { + return errors.New("labels must have a name") + } + } + + for name := range obj.Options { + if strings.TrimSpace(name) == "" { + return errors.New("options must have a name") + } + } + + if obj.APIVersion != "" { + verOK, err := regexp.MatchString(`^(v)[1-9]\.[0-9]\d*$`, obj.APIVersion) + if err != nil { + return errwrap.Wrapf(err, "error matching apiversion string") + } + if !verOK { + return fmt.Errorf("invalid apiversion: %s", obj.APIVersion) + } + } + + return nil +} + +// Init runs some startup code for this resource. +func (obj *DockerNetworkRes) Init(init *engine.Init) error { + obj.init = init // save for later + + // Initialize the docker client. + var err error + obj.client, err = client.NewClientWithOpts(client.WithVersion(obj.APIVersion)) + if err != nil { + return errwrap.Wrapf(err, "error creating docker client") + } + + return nil +} + +// Close is run by the engine to clean up after the resource is done. +func (obj *DockerNetworkRes) Close() error { + return obj.client.Close() // close the docker client +} + +// Watch is the primary listener for this resource and it outputs events. +func (obj *DockerNetworkRes) Watch() error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + eventChan, errChan := obj.client.Events(ctx, types.EventsOptions{}) + + // notify engine that we're running + obj.init.Running() + + var send = false // send event? + for { + select { + case _, ok := <-eventChan: + if !ok { // channel shutdown + return nil + } + send = true + + case err, ok := <-errChan: + if !ok { + return nil + } + // TODO: attempt reconnecting briefly in case daemon was restarted + return err + + case <-obj.init.Done: // closed by the engine to signal shutdown + return nil + } + + // do all our event sending all together to avoid duplicate msgs + if send { + send = false + obj.init.Event() // notify engine of an event (this can block) + } + } +} + +// CheckApply method for Docker resource. +func (obj *DockerNetworkRes) CheckApply(apply bool) (checkOK bool, err error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + var id string + nets, err := obj.client.NetworkList(ctx, types.NetworkListOptions{ + Filters: filters.NewArgs(filters.Arg("name", obj.Name())), + }) + if err != nil { + return false, errwrap.Wrapf(err, "error listing networks") + } + + // Docker NetworkList call filters are partial string matches, so for + // example "ab" would match networks named "abc" and "abz". Find the network + // with the exact name match + for _, n := range nets { + if n.Name == obj.Name() { + if id != "" { // duplicate name found + return false, fmt.Errorf("duplicate networks found") + } + id = n.ID // found + } + } + + // exit early, we're in a good state + if obj.State == "absent" && id == "" { + return true, nil + } + + var destroy = false + if obj.State == "exists" && id != "" { + inspect, err := obj.client.NetworkInspect(ctx, id, types.NetworkInspectOptions{}) + if err != nil { + return false, errwrap.Wrapf(err, "error inspecting network %s", id) + } + + // compare obj to inspected Docker network + err = obj.cmpNetwork(ctx, inspect) + if err == nil { + return true, nil + } + + // needs to be recreated + destroy = true + obj.init.Logf("network must be destroyed: %s", err) + } + + if !apply { + return false, nil + } + + if obj.State == "absent" { + return obj.networkRemove(ctx, id) + } + + if destroy { + obj.init.Logf("destroying network") + _, err := obj.networkRemove(ctx, id) + if err != nil { + return false, err + } + } + + // TODO: Implement IPAM driver/options configurability + config := types.NetworkCreate{ + CheckDuplicate: true, + Driver: obj.Driver, + EnableIPv6: obj.EnableIPv6, + IPAM: &network.IPAM{ + Driver: obj.IPAMDriver, + Options: obj.Options, + Config: obj.ipamConfigs(), + }, + Labels: obj.Labels, + Options: obj.Options, + } + + obj.init.Logf("creating network") + _, err = obj.client.NetworkCreate(ctx, obj.Name(), config) + + return false, errwrap.Wrapf(err, "error creating network") +} + +func (obj *DockerNetworkRes) ipamConfigs() IPAMConfigs { + var configs []network.IPAMConfig + + for _, res := range obj.GetGroup() { + ipam, ok := res.(*DockerNetworkAddressRes) // convert from GroupableRes + if ok { + configs = append(configs, ipam.IPAMConfig()) + } + } + + return configs +} + +// Cmp compares two resources and returns an error if they are not equivalent. +func (obj *DockerNetworkRes) Cmp(r engine.Res) (err error) { + defer func() { + obj.init.Logf("Cmp returned %+v", err) + }() + // we can only compare DockerNetworkRes to others of the same resource kind + res, ok := r.(*DockerNetworkRes) + if !ok { + return fmt.Errorf("error casting r to *DockerNetworkRes") + } + if obj.State != res.State { + return fmt.Errorf("the State differs") + } + if obj.Force != res.Force { + return fmt.Errorf("the Force differs") + } + + if obj.Driver != res.Driver { + return fmt.Errorf("the Driver differs") + } + if obj.EnableIPv6 != res.EnableIPv6 { + return fmt.Errorf("the EnableIPv6 differs") + } + if !reflect.DeepEqual(obj.Labels, res.Labels) { + return fmt.Errorf("the Labels differ") + } + if !reflect.DeepEqual(obj.Options, res.Options) { + return fmt.Errorf("the Options differ") + } + + if obj.IPAMDriver != res.IPAMDriver { + return fmt.Errorf("the IPAMDriver differs") + } + if !reflect.DeepEqual(obj.IPAMOptions, res.IPAMOptions) { + return fmt.Errorf("the IPAMOptions differ") + } + + if obj.APIVersion != res.APIVersion { + return fmt.Errorf("the APIVersion differs") + } + return nil +} + +func (obj *DockerNetworkRes) networkRemove(ctx context.Context, id string) (bool, error) { + // Lookup the network with all details, specifically the list of attached + // containers. Removing a network with containers attached will fail, so we + // preempt that and remove them from the network. + inspect, err := obj.client.NetworkInspect(ctx, id, types.NetworkInspectOptions{}) + if err != nil { + return false, errwrap.Wrapf(err, "error inspecting network %s", id) + } + + // cannot remove networks with containers attached to them + if len(inspect.Containers) > 0 { + // don't accidentally all the containers without permission + if !obj.Force { + return false, fmt.Errorf("network has active endpoints. aborting removal") + } + + // remove all containers, then try again + for id := range inspect.Containers { + // TODO: Add an option to disconnect containers from the network + // instead of removing them.. maybe + opts := types.ContainerRemoveOptions{ + Force: true, + } + obj.init.Logf("removing container %s", inspect.Containers[id].Name) + if err := obj.client.ContainerRemove(ctx, id, opts); err != nil { + return false, errwrap.Wrapf(err, "error removing container %s", id) + } + } + } + + obj.init.Logf("removing network") + if err := obj.client.NetworkRemove(ctx, inspect.ID); err != nil { + return false, errwrap.Wrapf(err, "error removing network") + } + return false, nil +} + +func (obj *DockerNetworkRes) cmpNetwork(ctx context.Context, inspect types.NetworkResource) error { + if obj.Driver != inspect.Driver { + return fmt.Errorf("the network Driver differs") + } + if obj.EnableIPv6 != inspect.EnableIPv6 { + return fmt.Errorf("the network EnableIPv6 differs") + } + + // Identify and filter out all address versions not explicitly configured + // by this resource, and ignore them when comparing against the existing + // Docker network. + objCfg := obj.ipamConfigs() + netCfg := IPAMConfigs(inspect.IPAM.Config) + versions, err := objCfg.Families() + if err != nil { + return errwrap.Wrapf(err, "error parsing network IPAM versions") + } + filtered, err := netCfg.FilterVersion(versions) + if err != nil { + return errwrap.Wrapf(err, "error filtering network IPAM versions") + } + + // Compare excluding the IP versions that were added implicitly by Docker. + // This works in every case except for when the user wants the network to be + // recreated with a Docker assigned allocation for a version instead of the + // previously explicitly configured subnet. We can't know which is which, so + // we just ignore all unconfigured allocations and hope they're still fine. + if !objCfg.Equal(filtered) { + return errors.New("the network IPAM Config differs") + } + + if len(obj.Labels) > 0 && len(inspect.Labels) > 0 && + !reflect.DeepEqual(obj.Labels, inspect.Labels) { + return fmt.Errorf("the network Labels differ") + } + if len(obj.Options) > 0 && len(inspect.Options) > 0 && + !reflect.DeepEqual(obj.Options, inspect.Options) { + return fmt.Errorf("the network Options differ") + } + + return nil +} + +// GroupCmp returns whether two resources can be grouped together or not. Can +// these two resources be merged, aka, does this resource support doing so? Will +// resource allow itself to be grouped _into_ this obj? +func (obj *DockerNetworkRes) GroupCmp(r engine.GroupableRes) error { + res, ok := r.(*DockerNetworkAddressRes) + if ok { + // group networks matching this network name + if res.Network != obj.Name() { + return fmt.Errorf("resource groups with a different network name") + } + + return nil + } + + return fmt.Errorf("resource is not the right kind") +} + +// IPVersion represents the version of an Internet Protocol address +type IPVersion int + +const ( + IPv4 IPVersion = iota + IPv6 +) + +// VersionOfIP returns the IPVersion for a given net.IP address +func VersionOfIP(ip net.IP) IPVersion { + if ip.To4() != nil { + return IPv4 + } else { + return IPv6 + } +} + +// DockerNetworkUID is the UID struct for DockerNetworkRes. +type DockerNetworkUID struct { + engine.BaseUID + + network string +} + +// UIDs includes all params to make a unique identification of this object. Most +// resources only return one, although some resources can return multiple. +func (obj *DockerNetworkRes) UIDs() []engine.ResUID { + x := &DockerNetworkUID{ + BaseUID: engine.BaseUID{Name: obj.Name(), Kind: obj.Kind()}, + network: obj.Name(), + } + return []engine.ResUID{x} +} + +// AutoEdges returns the AutoEdge interface. +func (obj *DockerNetworkRes) AutoEdges() (engine.AutoEdge, error) { + return nil, nil +} + +// IFF aka if and only if they are equivalent, return true. If not, false. +func (obj *DockerNetworkUID) IFF(uid engine.ResUID) bool { + res, ok := uid.(*DockerNetworkUID) + if !ok { + return false + } + return obj.network == res.network +} + +// UnmarshalYAML is the custom unmarshal handler for this struct. It is +// primarily useful for setting the defaults. +func (obj *DockerNetworkRes) UnmarshalYAML(unmarshal func(interface{}) error) error { + type rawRes DockerNetworkRes // indirection to avoid infinite recursion + + def := obj.Default() // get the default + res, ok := def.(*DockerNetworkRes) // put in the right format + if !ok { + return fmt.Errorf("could not convert to DockerNetworkRes") + } + raw := rawRes(*res) // convert; the defaults go here + + if err := unmarshal(&raw); err != nil { + return err + } + + *obj = DockerNetworkRes(raw) // restore from indirection with type conversion! + return nil +} + +type IPAMConfigs []network.IPAMConfig + +func (obj IPAMConfigs) Families() ([]IPVersion, error) { + verCount := make(map[IPVersion]struct{}, 0) + + for _, config := range obj { + ip, _, err := net.ParseCIDR(config.Subnet) + if err != nil { + return nil, err + } + verCount[VersionOfIP(ip)] = struct{}{} + } + + versions := []IPVersion{} + for ver, _ := range verCount { + versions = append(versions, ver) + } + return versions, nil +} + +// FilterVersion filters a slice of IPAMConfig blocks retaining only the IP +// version(s) specified and discarding the rest, returning the filtered slice. +// The original slice is not mutated. +func (obj IPAMConfigs) FilterVersion(versions []IPVersion) (IPAMConfigs, error) { + filtered := make(IPAMConfigs, 0) + + for _, ipam := range obj { + ip, _, err := net.ParseCIDR(ipam.Subnet) + if err != nil { + return nil, err + } + ver := VersionOfIP(ip) + + for _, version := range versions { + // version should be kept, add it and move on + if ver == version { + filtered = append(filtered, ipam) + break + } + } + } + + return filtered, nil +} + +func (obj IPAMConfigs) Equal(other IPAMConfigs) bool { + if len(obj) != len(other) { + return false + } + + for _, cfg := range obj { + match := false + for _, otherCfg := range other { + if cfg.Subnet != otherCfg.Subnet { + continue + } + if cfg.Gateway != otherCfg.Gateway || + cfg.IPRange != otherCfg.IPRange || + !reflect.DeepEqual(cfg.AuxAddress, otherCfg.AuxAddress) { + return false + } + match = true + } + if !match { + return false + } + } + return true +} + +type DockerNetworkAddressRes struct { + traits.Base // add the base methods without re-implementation + traits.Edgeable + traits.Groupable + + init *engine.Init + + Network string `lang:"network"` + + Subnet string `lang:"subnet"` + IPRange string `lang:"iprange"` + Gateway string `lang:"gateway"` + AuxAddress map[string]string `lang:"auxaddress"` +} + +func (obj *DockerNetworkAddressRes) Validate() error { + if obj.Network == "" { + return fmt.Errorf("network must be specified") + } + + if obj.Subnet == "" { + return fmt.Errorf("subnet must be specified") + } + + _, subnet, err := net.ParseCIDR(obj.Subnet) + if err != nil { + return errwrap.Wrapf(err, "error parsing subnet") + } + + if obj.IPRange != "" { + _, ipr, err := net.ParseCIDR(obj.IPRange) + if err != nil { + return errwrap.Wrapf(err, "error parsing iprange") + } + if len(ipr.IP) != len(subnet.IP) { + return errors.New("subnet and iprange must be the same IP version") + } + + start, end := cidr.AddressRange(subnet) + if !subnet.Contains(start) || !subnet.Contains(end) { + return errors.New("iprange must reside within subnet") + } + } + + if obj.Gateway != "" { + gateway := net.ParseIP(obj.Gateway) + // net.ParseIP returns [16]byte even for IPv4 addresses. To4() returns + // the IPv4 address in a [4]byte if it's v4, else nil. This gives us + // [4]byte for IPv4 and [16]byte for IPv6 so we can compare lengths. + if v4 := gateway.To4(); v4 != nil { + gateway = v4 + } + if gateway == nil { + return errors.New("error parsing gateway") + } + if len(gateway) != len(subnet.IP) { + return errors.New("subnet and gateway must be the same IP version") + } + if !subnet.Contains(gateway) { + return errors.New("gateway must reside within subnet") + } + } + + if obj.AuxAddress != nil && len(obj.AuxAddress) > 0 { + for host, addr := range obj.AuxAddress { + ip := net.ParseIP(addr) + if v4 := ip.To4(); v4 != nil { + ip = v4 + } + if ip == nil { + return fmt.Errorf("error parsing address for host %s", host) + } + if len(ip) != len(subnet.IP) { + return fmt.Errorf("subnet and host \"%s\" address must be the same IP version", host) + } + if !subnet.Contains(ip) { + return fmt.Errorf("host \"%s\" address must reside within subnet %s", host, obj.Subnet) + } + } + } + return nil +} + +func (obj *DockerNetworkAddressRes) Default() engine.Res { + return &DockerNetworkAddressRes{} +} + +func (obj *DockerNetworkAddressRes) Init(init *engine.Init) error { + obj.init = init + if !obj.IsGrouped() { + return fmt.Errorf("must be grouped with a docker:container") + } + return nil +} + +// Close has no function for DockerContainerMount resources +func (obj *DockerNetworkAddressRes) Close() error { + return nil +} + +// Watch has no function for DockerContainerMount resources +func (obj *DockerNetworkAddressRes) Watch() error { + obj.init.Running() + <-obj.init.Done + return nil +} + +// CheckApply has no function for auto-grouped child resources +func (obj *DockerNetworkAddressRes) CheckApply(apply bool) (bool, error) { + return true, fmt.Errorf("resource %s cannot CheckApply", obj) +} + +func (obj *DockerNetworkAddressRes) Cmp(r engine.Res) error { + // we can only compare DockerNetworkAddressRes to others of the same resource kind + res, ok := r.(*DockerNetworkAddressRes) + if !ok { + return fmt.Errorf("error casting r to *DockerNetworkAddressRes") + } + if obj.Subnet != res.Subnet { + return fmt.Errorf("the State differs") + } + if obj.IPRange != res.IPRange { + return fmt.Errorf("the Force differs") + } + if obj.Gateway != res.Gateway { + return fmt.Errorf("the Force differs") + } + if !reflect.DeepEqual(obj.AuxAddress, res.AuxAddress) { + return fmt.Errorf("the AuxAddresses differ") + } + return nil +} + +func (obj *DockerNetworkAddressRes) IPAMConfig() network.IPAMConfig { + return network.IPAMConfig{ + Subnet: obj.Subnet, + IPRange: obj.IPRange, + Gateway: obj.Gateway, + AuxAddress: obj.AuxAddress, + } +} diff --git a/examples/lang/docker0.mcl b/examples/lang/docker0.mcl deleted file mode 100644 index e6bf7ae7d3..0000000000 --- a/examples/lang/docker0.mcl +++ /dev/null @@ -1,10 +0,0 @@ -docker:container "mgmt-nginx" { - state => "running", - image => "nginx", - cmd => ["nginx", "-g", "daemon off;",], - ports => {"tcp" => {80 => 8080,},}, -} - -docker:image "nginx" { - state => "exists", -} diff --git a/examples/lang/docker_container0.mcl b/examples/lang/docker_container0.mcl index 93eed38f2e..4b1924037d 100644 --- a/examples/lang/docker_container0.mcl +++ b/examples/lang/docker_container0.mcl @@ -2,5 +2,38 @@ docker:container "mgmt-nginx" { state => "running", image => "nginx", cmd => ["nginx", "-g", "daemon off;",], - ports => {"tcp" => {80 => 8080,},}, + ports => {"tcp" => {80 => "127.0.0.127:8080",},}, } + +docker:image "nginx" { + state => "exists", +} + +docker:container:mount "/tmp" { + container => "mgmt-nginx", + type => "tmpfs", + size => "2M", +} + +$network = "test" +docker:network $network { + state => "exists", + force => true, + enableipv6 => true, + labels => {"key" => "value",}, +} + +docker:network:address "${network}-ipv4" { + network => $network, + subnet => "10.10.10.0/24", + iprange => "10.10.10.10/25", + gateway => "10.10.10.10", +} + +docker:network:address "${network}-ipv6" { + network => $network, + subnet => "fd00:f7e:b1b:abcd::/64", + gateway => "fd00:f7e:b1b:abcd::1", +} + + diff --git a/go.mod b/go.mod index 4386886ba2..2bb08591f9 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.16 require ( cloud.google.com/go v0.54.0 // indirect github.com/Microsoft/go-winio v0.4.17 // indirect + github.com/apparentlymart/go-cidr v1.1.0 github.com/aws/aws-sdk-go v1.40.49 github.com/containerd/containerd v1.4.9 // indirect github.com/coredhcp/coredhcp v0.0.0-20210830115404-2176f33418f4 @@ -12,10 +13,11 @@ require ( github.com/cyphar/filepath-securejoin v0.2.3 github.com/davecgh/go-spew v1.1.1 github.com/deniswernert/go-fstab v0.0.0-20141204152952-eb4090f26517 + github.com/docker/cli v20.10.9+incompatible github.com/docker/distribution v2.7.1+incompatible // indirect github.com/docker/docker v20.10.8+incompatible github.com/docker/go-connections v0.4.0 - github.com/docker/go-units v0.4.0 // indirect + github.com/docker/go-units v0.4.0 github.com/fsnotify/fsnotify v1.5.1 github.com/godbus/dbus/v5 v5.0.4 github.com/google/uuid v1.2.0 // indirect diff --git a/go.sum b/go.sum index 2a8109332c..c6c9d84426 100644 --- a/go.sum +++ b/go.sum @@ -41,6 +41,8 @@ github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk5 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/apparentlymart/go-cidr v1.1.0 h1:2mAhrMoF+nhXqxTzSZMUzDHkLjmIHC+Zzn4tdgBZjnU= +github.com/apparentlymart/go-cidr v1.1.0/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da h1:8GUt8eRujhVEGZFFEjBj46YV4rDjvGrNxb0KMWYkL2I= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= @@ -107,6 +109,8 @@ github.com/deniswernert/go-fstab v0.0.0-20141204152952-eb4090f26517 h1:YMvaGdOIU github.com/deniswernert/go-fstab v0.0.0-20141204152952-eb4090f26517/go.mod h1:ixLGX4GUQg44igA/iJawr+KYZLyWOoAzAgTCQcJ/K9Y= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/docker/cli v20.10.9+incompatible h1:OJ7YkwQA+k2Oi51lmCojpjiygKpi76P7bg91b2eJxYU= +github.com/docker/cli v20.10.9+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v20.10.8+incompatible h1:RVqD337BgQicVCzYrrlhLDWhq6OAD2PJDUg2LsEUvKM= diff --git a/util/util.go b/util/util.go index 2e20ec5b7b..b2922b2d23 100644 --- a/util/util.go +++ b/util/util.go @@ -59,6 +59,39 @@ func StrInList(needle string, haystack []string) bool { return false } +// IntInList returns true if an int exists inside a list, otherwise false. +func IntInList(needle int, haystack []int) bool { + for _, x := range haystack { + if needle == x { + return true + } + } + return false +} + +// StrSliceEqual returns true if two slices have the same elements in the same +// order, compared with == equality. +// TODO: this _really_ should be a generic function, taking []interface{} +func StrSliceEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i, s := range a { + if s != b[i] { + return false + } + } + return true +} + +// StrOrEmpty returns a string from a *string, or "" if the *string is nil +func StrOrEmpty(s *string) string { + if s != nil { + return *s + } + return "" +} + // Uint64KeyFromStrInMap returns true if needle is found in haystack of keys // that have uint64 type. func Uint64KeyFromStrInMap(needle string, haystack map[uint64]string) (uint64, bool) { @@ -172,6 +205,18 @@ func StrMapValuesUint64(m map[uint64]string) []string { return result } +// StrMapKeyEqualValue returns a string slice in the form []string{"key=value"} +// for a given map[string]string{"key": "value"} +func StrMapKeyEqualValue(m map[string]string) []string { + result := make([]string, len(m)) + i := 0 + for key, value := range m { + result[i] = fmt.Sprintf("%s=%s", key, value) + i++ + } + return result +} + // BoolMapTrue returns true if everyone in the list is true. func BoolMapTrue(l []bool) bool { for _, b := range l {