diff --git a/.gitignore b/.gitignore index 633d835..c274ed5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea/ .credentials -.DS_Store \ No newline at end of file +.DS_Store +nexus-cli \ No newline at end of file diff --git a/README.md b/README.md index 8f3d9b8..36fe994 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,26 @@ $ nexus-cli image info -name mlabouardy/nginx -tag 1.2.0 $ nexus-cli image delete -name mlabouardy/nginx -tag 1.2.0 ``` +``` +$ nexus-cli image tags -name mlabouardy/nginx -v -e v1 -e latest +``` + +``` +$ nexus-cli image tags -name mlabouardy/nginx -e 1.2 +``` + +``` +$ nexus-cli image delete -name mlabouardy/nginx -e '!feature' +``` + ``` $ nexus-cli image delete -name mlabouardy/nginx -keep 4 ``` +## Caveats + +Deletion of image tags is done using a tag name, but rather using a checksum of the image. If you push an image to a registry more than once (e.g. as `1.0.0` and also as `latest`). The deletion will still use the image checksum. Thus the deletion of a single tag is no problem. If the tag is not unique, the deletion will **delete a random tag** matching the checksum. + ## Tutorials * [Cleanup old Docker images from Nexus Repository](http://www.blog.labouardy.com/cleanup-old-docker-images-from-nexus-repository/) diff --git a/main.go b/main.go index 013ed5a..a8f69a2 100644 --- a/main.go +++ b/main.go @@ -3,14 +3,17 @@ package main import ( "fmt" "html/template" + "log" "os" + "regexp" + "strings" - "github.com/mlabouardy/nexus-cli/registry" + "github.com/moepi/nexus-cli/registry" "github.com/urfave/cli" ) const ( - CREDENTIALS_TEMPLATES = `# Nexus Credentials + credentialsTemplates = `# Nexus Credentials nexus_host = "{{ .Host }}" nexus_username = "{{ .Username }}" nexus_password = "{{ .Password }}" @@ -21,7 +24,7 @@ func main() { app := cli.NewApp() app.Name = "Nexus CLI" app.Usage = "Manage Docker Private Registry on Nexus" - app.Version = "1.0.0-beta" + app.Version = "1.0.0-beta-2" app.Authors = []cli.Author{ cli.Author{ Name: "Mohamed Labouardy", @@ -55,6 +58,14 @@ func main() { Name: "name, n", Usage: "List tags by image name", }, + cli.StringSliceFlag{ + Name: "expression, e", + Usage: "Filter tags by regular expression", + }, + cli.BoolFlag{ + Name: "invert, v", + Usage: "Invert filter results", + }, }, Action: func(c *cli.Context) error { return listTagsByImage(c) @@ -77,7 +88,7 @@ func main() { }, { Name: "delete", - Usage: "Delete an image", + Usage: "Delete images", Flags: []cli.Flag{ cli.StringFlag{ Name: "name, n", @@ -88,9 +99,17 @@ func main() { cli.StringFlag{ Name: "keep, k", }, + cli.StringSliceFlag{ + Name: "expression, e", + Usage: "Filter tags by regular expression", + }, + cli.BoolFlag{ + Name: "invert, v", + Usage: "Invert results filter expressions", + }, }, Action: func(c *cli.Context) error { - return deleteImage(c) + return deleteImages(c) }, }, }, @@ -125,7 +144,7 @@ func setNexusCredentials(c *cli.Context) error { repository, } - tmpl, err := template.New(".credentials").Parse(CREDENTIALS_TEMPLATES) + tmpl, err := template.New(".credentials").Parse(credentialsTemplates) if err != nil { return cli.NewExitError(err.Error(), 1) } @@ -158,6 +177,36 @@ func listImages(c *cli.Context) error { return nil } +func filterTagsByRegex(tags []string, expressions []string, invert bool) ([]string, error) { + var retTags []string + if len(expressions) == 0 { + return tags, nil + } + for _, tag := range tags { + tagMiss := false + for _, expression := range expressions { + var expressionBool = !invert + if strings.HasPrefix(expression, "!") { + expressionBool = invert + expression = strings.Trim(expression, "!") + } + retVal, err := regexp.MatchString(expression, tag) + if err != nil { + return retTags, err + } + if retVal != expressionBool { + tagMiss = true + break + } + } + // tag must match all expression, so continue with next tag on match + if !tagMiss { + retTags = append(retTags, tag) + } + } + return retTags, nil +} + func listTagsByImage(c *cli.Context) error { var imgName = c.String("name") r, err := registry.NewRegistry() @@ -169,6 +218,12 @@ func listTagsByImage(c *cli.Context) error { } tags, err := r.ListTagsByImage(imgName) + // filter tags by expressions + tags, err = filterTagsByRegex(tags, c.StringSlice("expression"), c.Bool("invert")) + if err != nil { + log.Fatal(err) + } + compareStringNumber := func(str1, str2 string) bool { return extractNumberFromString(str1) < extractNumberFromString(str2) } @@ -207,46 +262,71 @@ func showImageInfo(c *cli.Context) error { return nil } -func deleteImage(c *cli.Context) error { +func deleteImages(c *cli.Context) error { var imgName = c.String("name") var tag = c.String("tag") var keep = c.Int("keep") + var invert = c.Bool("invert") + + // Show help if no image name is present if imgName == "" { fmt.Fprintf(c.App.Writer, "You should specify the image name\n") cli.ShowSubcommandHelp(c) - } else { - r, err := registry.NewRegistry() + return nil + } + + r, err := registry.NewRegistry() + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + + // if a specific tag is provided, ignore all other options + if tag != "" { + err = r.DeleteImageByTag(imgName, tag) if err != nil { return cli.NewExitError(err.Error(), 1) } - if tag == "" { - if keep == 0 { - fmt.Fprintf(c.App.Writer, "You should either specify the tag or how many images you want to keep\n") - cli.ShowSubcommandHelp(c) - } else { - tags, err := r.ListTagsByImage(imgName) - compareStringNumber := func(str1, str2 string) bool { - return extractNumberFromString(str1) < extractNumberFromString(str2) - } - Compare(compareStringNumber).Sort(tags) - if err != nil { - return cli.NewExitError(err.Error(), 1) - } - if len(tags) >= keep { - for _, tag := range tags[:len(tags)-keep] { - fmt.Printf("%s:%s image will be deleted ...\n", imgName, tag) - r.DeleteImageByTag(imgName, tag) - } - } else { - fmt.Printf("Only %d images are available\n", len(tags)) - } - } - } else { + return nil + } + + // Get list of tags and filter them by all expressions provided + tags, err := r.ListTagsByImage(imgName) + tags, err = filterTagsByRegex(tags, c.StringSlice("expression"), invert) + if err != nil { + fmt.Fprintf(c.App.Writer, "Could not filter tags by regular expressions: %s\n", err) + return err + } + + // if no keep is specified, all flags are unset. Show help and exit. + if c.IsSet("keep") == false && len(c.StringSlice("expression")) == 0 { + fmt.Fprintf(c.App.Writer, "You should either specify use tag / filter expressions, or specify how many images you want to keep\n") + cli.ShowSubcommandHelp(c) + return fmt.Errorf("You should either specify use tag / filter expressions, or specify how many images you want to keep") + } + + if len(tags) == 0 && !c.IsSet("keep") { + fmt.Fprintf(c.App.Writer, "No images selected for deletion\n") + return fmt.Errorf("No images selected for deletion") + } + + // Remove images by using keep flag + compareStringNumber := func(str1, str2 string) bool { + return extractNumberFromString(str1) < extractNumberFromString(str2) + } + Compare(compareStringNumber).Sort(tags) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + if len(tags) >= keep { + for _, tag := range tags[:len(tags)-keep] { + fmt.Printf("%s:%s image will be deleted ...\n", imgName, tag) err = r.DeleteImageByTag(imgName, tag) if err != nil { return cli.NewExitError(err.Error(), 1) } } + } else { + fmt.Printf("Only %d images are available\n", len(tags)) } return nil } diff --git a/registry/registry.go b/registry/registry.go index 8f22558..9ad8050 100644 --- a/registry/registry.go +++ b/registry/registry.go @@ -2,58 +2,71 @@ package registry import ( "encoding/json" - "errors" "fmt" - "github.com/BurntSushi/toml" "net/http" - "os" + + "github.com/BurntSushi/toml" + "github.com/caarlos0/env" ) -const ACCEPT_HEADER = "application/vnd.docker.distribution.manifest.v2+json" -const CREDENTIALS_FILE = ".credentials" +const acceptHeader = "application/vnd.docker.distribution.manifest.v2+json" +const credentialsFile = ".credentials" +// Registry struct to access registry information type Registry struct { - Host string `toml:"nexus_host"` - Username string `toml:"nexus_username"` - Password string `toml:"nexus_password"` - Repository string `toml:"nexus_repository"` + Host string `toml:"nexus_host" env:"NEXUS_CLI_HOST"` + Username string `toml:"nexus_username" env:"NEXUS_CLI_USERNAME"` + Password string `toml:"nexus_password" env:"NEXUS_CLI_PASSWORD"` + Repository string `toml:"nexus_repository" env:"NEXUS_CLI_REPOSITORY"` } +// Repositories struct containing a slice of images type Repositories struct { Images []string `json:"repositories"` } +// ImageTags struct containing a slice of all tags for a given docker image name type ImageTags struct { Name string `json:"name"` Tags []string `json:"tags"` } +// ImageManifest struct for docker image information on schema and layers type ImageManifest struct { SchemaVersion int64 `json:"schemaVersion"` MediaType string `json:"mediaType"` Config LayerInfo `json:"config"` Layers []LayerInfo `json:"layers"` } + +// LayerInfo struct for docker image meta information type LayerInfo struct { MediaType string `json:"mediaType"` Size int64 `json:"size"` Digest string `json:"digest"` } +// NewRegistry uses local .credentials file or environment variables to return a Registry struct func NewRegistry() (Registry, error) { r := Registry{} - if _, err := os.Stat(CREDENTIALS_FILE); os.IsNotExist(err) { - return r, errors.New(fmt.Sprintf("%s file not found\n", CREDENTIALS_FILE)) - } else if err != nil { - return r, err - } + toml.DecodeFile(credentialsFile, &r) + + // Parse environment variables by struct `env`-tags + env.Parse(&r) - if _, err := toml.DecodeFile(CREDENTIALS_FILE, &r); err != nil { - return r, err + if len(r.Host) == 0 { + return r, fmt.Errorf("Problem reading host from configuration") + } else if len(r.Username) == 0 { + return r, fmt.Errorf("Problem reading username from configuration") + } else if len(r.Password) == 0 { + return r, fmt.Errorf("Problem reading password from configuration") + } else if len(r.Repository) == 0 { + return r, fmt.Errorf("Problem reading repository from configuration") } return r, nil } +// ListImages returns image names as a slice of strings func (r Registry) ListImages() ([]string, error) { client := &http.Client{} @@ -63,7 +76,7 @@ func (r Registry) ListImages() ([]string, error) { return nil, err } req.SetBasicAuth(r.Username, r.Password) - req.Header.Add("Accept", ACCEPT_HEADER) + req.Header.Add("Accept", acceptHeader) resp, err := client.Do(req) if err != nil { @@ -72,7 +85,7 @@ func (r Registry) ListImages() ([]string, error) { defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, errors.New(fmt.Sprintf("HTTP Code: %d", resp.StatusCode)) + return nil, fmt.Errorf("HTTP Code: %d", resp.StatusCode) } var repositories Repositories @@ -81,6 +94,7 @@ func (r Registry) ListImages() ([]string, error) { return repositories.Images, nil } +// ListTagsByImage expects an image name as string to return a slice of tage names func (r Registry) ListTagsByImage(image string) ([]string, error) { client := &http.Client{} @@ -90,7 +104,7 @@ func (r Registry) ListTagsByImage(image string) ([]string, error) { return nil, err } req.SetBasicAuth(r.Username, r.Password) - req.Header.Add("Accept", ACCEPT_HEADER) + req.Header.Add("Accept", acceptHeader) resp, err := client.Do(req) if err != nil { @@ -99,7 +113,7 @@ func (r Registry) ListTagsByImage(image string) ([]string, error) { defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, errors.New(fmt.Sprintf("HTTP Code: %d", resp.StatusCode)) + return nil, fmt.Errorf("HTTP Code: %d", resp.StatusCode) } var imageTags ImageTags @@ -108,6 +122,7 @@ func (r Registry) ListTagsByImage(image string) ([]string, error) { return imageTags.Tags, nil } +// ImageManifest expects image name and tag as string to return an ImageManifest struct func (r Registry) ImageManifest(image string, tag string) (ImageManifest, error) { var imageManifest ImageManifest client := &http.Client{} @@ -118,7 +133,7 @@ func (r Registry) ImageManifest(image string, tag string) (ImageManifest, error) return imageManifest, err } req.SetBasicAuth(r.Username, r.Password) - req.Header.Add("Accept", ACCEPT_HEADER) + req.Header.Add("Accept", acceptHeader) resp, err := client.Do(req) if err != nil { @@ -127,7 +142,7 @@ func (r Registry) ImageManifest(image string, tag string) (ImageManifest, error) defer resp.Body.Close() if resp.StatusCode != 200 { - return imageManifest, errors.New(fmt.Sprintf("HTTP Code: %d", resp.StatusCode)) + return imageManifest, fmt.Errorf("HTTP Code: %d", resp.StatusCode) } json.NewDecoder(resp.Body).Decode(&imageManifest) @@ -136,6 +151,7 @@ func (r Registry) ImageManifest(image string, tag string) (ImageManifest, error) } +// DeleteImageByTag expects an image name and a tag to delete an image tag func (r Registry) DeleteImageByTag(image string, tag string) error { sha, err := r.getImageSHA(image, tag) if err != nil { @@ -149,7 +165,7 @@ func (r Registry) DeleteImageByTag(image string, tag string) error { return err } req.SetBasicAuth(r.Username, r.Password) - req.Header.Add("Accept", ACCEPT_HEADER) + req.Header.Add("Accept", acceptHeader) resp, err := client.Do(req) if err != nil { @@ -158,7 +174,7 @@ func (r Registry) DeleteImageByTag(image string, tag string) error { defer resp.Body.Close() if resp.StatusCode != 202 { - return errors.New(fmt.Sprintf("HTTP Code: %d", resp.StatusCode)) + return fmt.Errorf("HTTP Code: %d", resp.StatusCode) } fmt.Printf("%s:%s has been successful deleted\n", image, tag) @@ -175,7 +191,7 @@ func (r Registry) getImageSHA(image string, tag string) (string, error) { return "", err } req.SetBasicAuth(r.Username, r.Password) - req.Header.Add("Accept", ACCEPT_HEADER) + req.Header.Add("Accept", acceptHeader) resp, err := client.Do(req) if err != nil { @@ -184,7 +200,7 @@ func (r Registry) getImageSHA(image string, tag string) (string, error) { defer resp.Body.Close() if resp.StatusCode != 200 { - return "", errors.New(fmt.Sprintf("HTTP Code: %d", resp.StatusCode)) + return "", fmt.Errorf("HTTP Code: %d", resp.StatusCode) } return resp.Header.Get("docker-content-digest"), nil