// Mgmt // Copyright (C) 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 . // // Additional permission under GNU GPL version 3 section 7 // // If you modify this program, or any covered work, by linking or combining it // with embedded mcl code and modules (and that the embedded mcl code and // modules which link with this program, contain a copy of their source code in // the authoritative form) containing parts covered by the terms of any other // license, the licensors of this program grant you additional permission to // convey the resulting work. Furthermore, the licensors of this program grant // the original author, James Shubin, additional permission to update this // additional permission if he deems it necessary to achieve the goals of this // additional permission. //go:build !nodocker package resources import ( "context" "fmt" "io" "regexp" "strings" "sync" "github.com/purpleidea/mgmt/engine" "github.com/purpleidea/mgmt/engine/traits" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" dockerImage "github.com/docker/docker/api/types/image" dockerClient "github.com/docker/docker/client" errwrap "github.com/pkg/errors" ) func init() { engine.RegisterResource("docker:image", func() engine.Res { return &DockerImageRes{} }) } // DockerImageRes is a docker image resource. The resource's name must be a // docker image in any supported format (url, image, or image:tag). type DockerImageRes struct { traits.Base // add the base methods without re-implementation traits.Edgeable // State of the image must be exists or absent. State string `lang:"state" yaml:"state"` // APIVersion allows you to override the host's default client API // version. APIVersion string `lang:"apiversion" yaml:"apiversion"` init *engine.Init once *sync.Once start chan struct{} // closes by once sflag bool // first time happened? ready chan struct{} // closes by once } // Default returns some sensible defaults for this resource. func (obj *DockerImageRes) Default() engine.Res { return &DockerImageRes{ // TODO: eventually if image supports other properties, this can // be left out and we could have the state be "unmanaged". State: "exists", } } // Validate if the params passed in are valid data. func (obj *DockerImageRes) Validate() error { // validate state if obj.State != "exists" && obj.State != "absent" { return fmt.Errorf("state must be exists or absent") } // validate APIVersion 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 *DockerImageRes) Init(init *engine.Init) error { obj.init = init // save for later obj.once = &sync.Once{} obj.start = make(chan struct{}) obj.ready = make(chan struct{}) return nil } // Cleanup is run by the engine to clean up after the resource is done. func (obj *DockerImageRes) Cleanup() error { return nil } // Watch is the primary listener for this resource and it outputs events. func (obj *DockerImageRes) Watch(ctx context.Context) error { var client *dockerClient.Client var err error for { client, err = dockerClient.NewClientWithOpts(dockerClient.WithVersion(obj.APIVersion)) if err == nil { // the above won't check the connection, force that here _, err = client.Ping(ctx) } if err == nil { break } // If we didn't connect right away, it might be because we're // waiting for someone to install the docker package, and start // the service. We might even have an edge between this resource // and those dependencies, but that doesn't stop this Watch from // starting up. As a result, we will wait *once* for CheckApply // to unlock us, since that runs in dependency order. // This error looks like: Cannot connect to the Docker daemon at // unix:///var/run/docker.sock. Is the docker daemon running? if dockerClient.IsErrConnectionFailed(err) && !obj.sflag { // notify engine that we're running so that CheckApply // can start... obj.init.Running() select { case <-obj.start: obj.sflag = true continue case <-ctx.Done(): // don't block close(obj.ready) // tell CheckApply to unblock! return nil } } close(obj.ready) // tell CheckApply to unblock! return errwrap.Wrapf(err, "error creating docker client") } defer client.Close() // success, so close it later eventChan, errChan := client.Events(ctx, types.EventsOptions{}) close(obj.ready) // tell CheckApply to start now that events are running // notify engine that we're running if !obj.sflag { obj.init.Running() } for { select { case event, ok := <-eventChan: if !ok { // channel shutdown return nil } if obj.init.Debug { obj.init.Logf("%+v", event) } case err, ok := <-errChan: if !ok { return nil } return err case <-ctx.Done(): // closed by the engine to signal shutdown return nil } obj.init.Event() // notify engine of an event (this can block) } } // CheckApply method for Docker resource. func (obj *DockerImageRes) CheckApply(ctx context.Context, apply bool) (checkOK bool, err error) { obj.once.Do(func() { close(obj.start) }) // Tell Watch() it's safe to start again. // Now wait to make sure events are started before we make changes! select { case <-obj.ready: case <-ctx.Done(): // don't block return false, ctx.Err() } // Save the full image name and tag. image := dockerImageNameTag(obj.Name()) // Initialize the docker client. client, err := dockerClient.NewClientWithOpts(dockerClient.WithVersion(obj.APIVersion)) if err != nil { return false, errwrap.Wrapf(err, "error creating docker client") } defer client.Close() // Validate the image. resp, err := client.ImageSearch(ctx, image, types.ImageSearchOptions{Limit: 1}) if err != nil { return false, errwrap.Wrapf(err, "error searching for image") } if len(resp) == 0 { return false, fmt.Errorf("image: %s not found", image) } s, err := client.ImageList(ctx, dockerImage.ListOptions{ Filters: filters.NewArgs(filters.Arg("reference", image)), }) if err != nil { return false, errwrap.Wrapf(err, "error listing images") } if len(s) > 1 { return false, fmt.Errorf("more than one image found") } if obj.State == "absent" && len(s) == 0 { return true, nil } if obj.State == "exists" && len(s) == 1 { return true, nil } if !apply { return false, nil } if obj.State == "absent" { obj.init.Logf("removing...") // TODO: force? prune children? if _, err := client.ImageRemove(ctx, image, dockerImage.RemoveOptions{}); err != nil { return false, errwrap.Wrapf(err, "error removing image") } return false, nil } // pull the image obj.init.Logf("pulling...") p, err := client.ImagePull(ctx, image, dockerImage.PullOptions{}) if err != nil { return false, errwrap.Wrapf(err, "error pulling image") } // Wait for the image to download, EOF signals that it's done. if _, err := io.ReadAll(p); err != nil { return false, errwrap.Wrapf(err, "error reading image pull result") } return false, nil } // Cmp compares two resources and returns an error if they are not equivalent. func (obj *DockerImageRes) Cmp(r engine.Res) error { // we can only compare DockerImageRes to others of the same resource kind res, ok := r.(*DockerImageRes) if !ok { return fmt.Errorf("error casting r to *DockerImageRes") } if obj.State != res.State { return fmt.Errorf("the State differs") } if obj.APIVersion != res.APIVersion { return fmt.Errorf("the APIVersion differs") } return nil } // DockerImageUID is the UID struct for DockerImageRes. type DockerImageUID struct { engine.BaseUID image 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 *DockerImageRes) UIDs() []engine.ResUID { x := &DockerImageUID{ BaseUID: engine.BaseUID{Name: obj.Name(), Kind: obj.Kind()}, image: dockerImageNameTag(obj.Name()), } return []engine.ResUID{x} } // AutoEdges returns the AutoEdge interface. func (obj *DockerImageRes) AutoEdges() (engine.AutoEdge, error) { return nil, nil } // IFF aka if and only if they are equivalent, return true. If not, false. func (obj *DockerImageUID) IFF(uid engine.ResUID) bool { res, ok := uid.(*DockerImageUID) if !ok { return false } return obj.image == res.image } // UnmarshalYAML is the custom unmarshal handler for this struct. It is // primarily useful for setting the defaults. func (obj *DockerImageRes) UnmarshalYAML(unmarshal func(interface{}) error) error { type rawRes DockerImageRes // indirection to avoid infinite recursion def := obj.Default() // get the default res, ok := def.(*DockerImageRes) // put in the right format if !ok { return fmt.Errorf("could not convert to DockerImageRes") } raw := rawRes(*res) // convert; the defaults go here if err := unmarshal(&raw); err != nil { return err } *obj = DockerImageRes(raw) // restore from indirection with type conversion! return nil } // dockerImageNameTag does a naive check to see if the input includes a tag or // is a url, and if not, appends the `:latest` tag to ensure disambiguation. func dockerImageNameTag(image string) string { if strings.Contains(image, ":") { return image } return image + ":latest" }