diff --git a/actions/api.go b/actions/api.go index 6836596..0ebdc2d 100644 --- a/actions/api.go +++ b/actions/api.go @@ -43,6 +43,7 @@ type RunnerConfig struct { EventName string // name of event to run EventPath string // path to JSON file to use for event.json in containers, relative to WorkingDir ReuseContainers bool // reuse containers to maintain state + ForcePull bool // force pulling of the image, if already present } type environmentApplier interface { diff --git a/actions/runner_exec.go b/actions/runner_exec.go index 8466ec0..1bd7aaa 100644 --- a/actions/runner_exec.go +++ b/actions/runner_exec.go @@ -49,10 +49,25 @@ func (runner *runnerImpl) addImageExecutor(action *model.Action, executors *[]co case *model.UsesDockerImage: image = uses.Image - *executors = append(*executors, container.NewDockerPullExecutor(container.NewDockerPullExecutorInput{ - DockerExecutorInput: in, - Image: image, - })) + + pull := runner.config.ForcePull + if !pull { + imageExists, err := container.ImageExistsLocally(runner.config.Ctx, image) + if err != nil { + return "", fmt.Errorf("unable to determine if image already exists for image %q", image) + } + + if imageExists { + pull = false + } + } + + if pull { + *executors = append(*executors, container.NewDockerPullExecutor(container.NewDockerPullExecutorInput{ + DockerExecutorInput: in, + Image: image, + })) + } case *model.UsesPath: contextDir := filepath.Join(runner.config.WorkingDir, uses.String()) diff --git a/cmd/root.go b/cmd/root.go index e1dcd9c..dc6ea16 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -31,6 +31,7 @@ func Execute(ctx context.Context, version string) { rootCmd.Flags().StringP("action", "a", "", "run action") rootCmd.Flags().BoolVarP(&runnerConfig.ReuseContainers, "reuse", "r", false, "reuse action containers to maintain state") rootCmd.Flags().StringVarP(&runnerConfig.EventPath, "event", "e", "", "path to event JSON file") + rootCmd.Flags().BoolVarP(&runnerConfig.ForcePull, "pull", "p", false, "pull docker image(s) if already present") rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output") rootCmd.PersistentFlags().BoolVarP(&runnerConfig.Dryrun, "dryrun", "n", false, "dryrun mode") rootCmd.PersistentFlags().StringVarP(&runnerConfig.WorkflowPath, "file", "f", "./.github/main.workflow", "path to workflow file") diff --git a/container/docker_images.go b/container/docker_images.go new file mode 100644 index 0000000..2cb298c --- /dev/null +++ b/container/docker_images.go @@ -0,0 +1,33 @@ +package container + +import ( + "context" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/client" +) + +// ImageExistsLocally returns a boolean indicating if an image with the +// requested name (and tag) exist in the local docker image store +func ImageExistsLocally(ctx context.Context, imageName string) (bool, error) { + cli, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + return false, err + } + cli.NegotiateAPIVersion(ctx) + + filters := filters.NewArgs() + filters.Add("reference", imageName) + + imageListOptions := types.ImageListOptions{ + Filters: filters, + } + + images, err := cli.ImageList(ctx, imageListOptions) + if err != nil { + return false, err + } + + return len(images) > 0, nil +} diff --git a/container/docker_images_test.go b/container/docker_images_test.go new file mode 100644 index 0000000..c6ef7a5 --- /dev/null +++ b/container/docker_images_test.go @@ -0,0 +1,42 @@ +package container + +import ( + "context" + "io/ioutil" + "testing" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/client" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.DebugLevel) +} + +func TestImageExistsLocally(t *testing.T) { + // to help make this test reliable and not flaky, we need to have + // an image that will exist, and onew that won't exist + + exists, err := ImageExistsLocally(context.TODO(), "library/alpine:this-random-tag-will-never-exist") + assert.Nil(t, err) + assert.Equal(t, false, exists) + + // pull an image + cli, err := client.NewClientWithOpts(client.FromEnv) + assert.Nil(t, err) + cli.NegotiateAPIVersion(context.TODO()) + + // Chose alpine latest because it's so small + // maybe we should build an image instead so that tests aren't reliable on dockerhub + reader, err := cli.ImagePull(context.TODO(), "alpine:latest", types.ImagePullOptions{}) + assert.Nil(t, err) + defer reader.Close() + _, err = ioutil.ReadAll(reader) + assert.Nil(t, err) + + exists, err = ImageExistsLocally(context.TODO(), "alpine:latest") + assert.Nil(t, err) + assert.Equal(t, true, exists) +}