Skip to content

Commit

Permalink
Merge pull request #61 from dokku/cli-skeleton
Browse files Browse the repository at this point in the history
refactor: use cli-skeleton for commands
  • Loading branch information
josegonzalez committed Sep 8, 2022
2 parents 6bca07c + c3922a0 commit 2380aaa
Show file tree
Hide file tree
Showing 7 changed files with 434 additions and 251 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ validation/*

# .env files
.env*

docker-image-labeler
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,16 @@ In addition, builds can be performed in an isolated Docker container:
```shell
make build-docker-image build-in-docker
```

## Usage

```shell
# pull an image
docker image pull mysql:8

# add a label
./docker-image-labeler relabel --label=mysql.version=8 mysql:8

# remove the label
./docker-image-labeler relabel --remove-label=mysql.version mysql:8
```
308 changes: 308 additions & 0 deletions commands/relabel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
package commands

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"

"github.com/buildpacks/imgutil/local"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
dockercli "github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
"github.com/josegonzalez/cli-skeleton/command"
"github.com/posener/complete"
flag "github.com/spf13/pflag"
)

const APIVERSION = "1.25"

type RelabelCommand struct {
command.Meta

addLabels []string
removeLabels []string
}

func (c *RelabelCommand) Name() string {
return "relabel"
}

func (c *RelabelCommand) Synopsis() string {
return "Re-labels a docker image"
}

func (c *RelabelCommand) Help() string {
return command.CommandHelp(c)
}

func (c *RelabelCommand) Examples() map[string]string {
appName := os.Getenv("CLI_APP_NAME")
return map[string]string{
"Add a label": fmt.Sprintf("%s %s --label=label.key=label.value docker/image:latest", appName, c.Name()),
"Remove a label": fmt.Sprintf("%s %s --remove-label=label.key=label.value docker/image:latest", appName, c.Name()),
"Add and remove a label": fmt.Sprintf("%s %s --label=new=value --remove-label=old=value docker/image:latest", appName, c.Name()),
}
}

func (c *RelabelCommand) Arguments() []command.Argument {
args := []command.Argument{}
args = append(args, command.Argument{
Name: "image-name",
Description: "name of image to manipulate",
Optional: false,
Type: command.ArgumentString,
})
return args
}

func (c *RelabelCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}

func (c *RelabelCommand) ParsedArguments(args []string) (map[string]command.Argument, error) {
return command.ParseArguments(args, c.Arguments())
}

func (c *RelabelCommand) FlagSet() *flag.FlagSet {
f := c.Meta.FlagSet(c.Name(), command.FlagSetClient)
f.StringSliceVar(&c.addLabels, "label", []string{}, "set of labels to add")
f.StringSliceVar(&c.removeLabels, "remove-label", []string{}, "set of labels to remove")
return f
}

func (c *RelabelCommand) AutocompleteFlags() complete.Flags {
return command.MergeAutocompleteFlags(
c.Meta.AutocompleteFlags(command.FlagSetClient),
complete.Flags{
"--label": complete.PredictAnything,
"--remove-label": complete.PredictAnything,
},
)
}

func (c *RelabelCommand) Run(args []string) int {
flags := c.FlagSet()
flags.Usage = func() { c.Ui.Output(c.Help()) }
if err := flags.Parse(args); err != nil {
c.Ui.Error(err.Error())
c.Ui.Error(command.CommandErrorText(c))
return 1
}

arguments, err := c.ParsedArguments(flags.Args())
if err != nil {
c.Ui.Error(err.Error())
c.Ui.Error(command.CommandErrorText(c))
return 1
}

imageName := arguments["image-name"].StringValue()

if len(c.addLabels) == 0 && len(c.removeLabels) == 0 {
c.Ui.Error("No labels specified\n")
return 1
}

dockerClient, err := dockercli.NewClientWithOpts(dockercli.FromEnv, dockercli.WithVersion(APIVERSION))
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to create docker client: %s", err.Error()))
return 1
}

if _, _, err = dockerClient.ImageInspectWithRaw(context.Background(), imageName); err != nil {
if client.IsErrNotFound(err) {
c.Ui.Error(fmt.Sprintf("Failed to fetch image id: %s", err.Error()))
return 1
}
}

img, err := local.NewImage(imageName, dockerClient, local.FromBaseImage(imageName))
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to create docker client: %s", err.Error()))
return 1
}

originalImageID, err := img.Identifier()
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to fetch image id: %s", err.Error()))
return 1
}

inspect, _, err := dockerClient.ImageInspectWithRaw(context.Background(), originalImageID.String())
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to inspect the source image: %s", err.Error()))
return 1
}

repoTags := inspect.RepoTags

appendLabels, err := parseNewLabels(c.addLabels)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to parse new labels: %s", err.Error()))
return 1
}

alternateTagsLabel := "com.dokku.docker-image-labeler/alternate-tags"
alternateTagValue, err := fetchTags(inspect, alternateTagsLabel)
if len(alternateTagValue) > 0 {
appendLabels[alternateTagsLabel] = alternateTagValue
}

removed, err := removeImageLabels(img, c.removeLabels)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed removing labels: %s", err.Error()))
return 1
}

added, err := addImageLabels(img, appendLabels)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed removing labels: %s", err.Error()))
return 1
}

if !removed && !added {
return 0
}

if err := img.Save(); err != nil {
c.Ui.Error(fmt.Sprintf("Failed to save image: %s", err.Error()))
return 1
}

newImageID, err := img.Identifier()
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to fetch image id: %s", err.Error()))
return 1
}

if newImageID == originalImageID {
c.Ui.Error(fmt.Sprintf("New and old image have the same identifier: %s", newImageID))
return 1
}

if len(repoTags) > 1 {
return 0
}

if len(repoTags) == 1 && repoTags[0] != imageName {
return 0
}

options := types.ImageRemoveOptions{
Force: false,
PruneChildren: false,
}

if _, err := dockerClient.ImageRemove(context.Background(), originalImageID.String(), options); err != nil {
if _, ok := err.(errdefs.ErrConflict); ok {
c.Ui.Error(fmt.Sprintf("Warning: Failed to delete old image: %s", err.Error()))
return 0
}
c.Ui.Error(fmt.Sprintf("Failed to delete old image: %s", err.Error()))
return 1
}

return 0
}

func removeImageLabels(img *local.Image, labels []string) (bool, error) {
modified := false
for _, label := range labels {
existingValue, err := img.Label(label)
if err != nil {
return modified, fmt.Errorf("Error fetching label %s (%s)\n", label, err.Error())
}

if existingValue == "" {
continue
}

modified = true
if err := img.RemoveLabel(label); err != nil {
return modified, fmt.Errorf("Error removing label %s (%s)\n", label, err.Error())
}
}

return modified, nil
}

func addImageLabels(img *local.Image, labels map[string]string) (bool, error) {
modified := false
for key, value := range labels {
existingValue, err := img.Label(key)
if err != nil {
return modified, fmt.Errorf("Error fetching label %s (%s)\n", key, err.Error())
}

if existingValue == value {
continue
}

modified = true
if err := img.SetLabel(key, value); err != nil {
return modified, fmt.Errorf("Error setting label %s=%s (%s)\n", key, value, err.Error())
}
}

return modified, nil
}

func parseNewLabels(labels []string) (map[string]string, error) {
m := map[string]string{}
for _, label := range labels {
parts := strings.SplitN(label, "=", 2)
key := parts[0]
value := ""
if len(parts) == 2 {
value = parts[1]
}

if len(key) == 0 {
return m, errors.New("Invalid label specified")
}

m[key] = value
}

return m, nil
}

func fetchTags(inspect types.ImageInspect, label string) (string, error) {
if len(inspect.RepoTags) == 0 {
return "", nil
}

tags := inspect.RepoTags
s, ok := inspect.Config.Labels[label]
if ok {
var existingTags []string
if err := json.Unmarshal([]byte(s), &existingTags); err != nil {
return "", err
}

tags = append(tags, existingTags...)
}

tags = unique(tags)
originalRepoTags, err := json.Marshal(tags)
if err != nil {
return "", fmt.Errorf("Failed to encode image's original tags as JSON (%s)\n", err.Error())
}
return string(originalRepoTags), nil
}

func unique(intSlice []string) []string {
keys := make(map[string]bool)
list := []string{}
for _, entry := range intSlice {
if _, value := keys[entry]; !value {
keys[entry] = true
list = append(list, entry)
}
}
return list
}
24 changes: 24 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,47 @@ go 1.19
require (
github.com/buildpacks/imgutil v0.0.0-20220805205524-56137f75e24d
github.com/docker/docker v20.10.17+incompatible
github.com/josegonzalez/cli-skeleton v0.6.0
github.com/mitchellh/cli v1.1.4
github.com/posener/complete v1.2.3
github.com/spf13/pflag v1.0.5
)

require (
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Masterminds/goutils v1.1.0 // indirect
github.com/Masterminds/semver/v3 v3.1.1 // indirect
github.com/Masterminds/sprig/v3 v3.2.0 // indirect
github.com/Microsoft/go-winio v0.5.2 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/bgentry/speakeasy v0.1.0 // indirect
github.com/docker/distribution v2.8.1+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-containerregistry v0.11.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/imdario/mergo v0.3.11 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/mitchellh/copystructure v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.1 // indirect
github.com/moby/term v0.0.0-20220808134915-39b0c02b01ae // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.3-0.20220114050600-8b9d41f48198 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rs/zerolog v1.27.0 // indirect
github.com/shopspring/decimal v1.2.0 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/spf13/cast v1.4.1 // indirect
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect
golang.org/x/net v0.0.0-20220805013720-a33c5aa5df48 // indirect
golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
)
Loading

0 comments on commit 2380aaa

Please sign in to comment.