Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: use cli-skeleton for commands #61

Merged
merged 9 commits into from
Sep 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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