├── internal ├── commands │ ├── tag │ │ ├── test.json │ │ ├── testdata │ │ │ ├── inspect-manifest-list.golden │ │ │ └── inspect-manifest.golden │ │ ├── cmd.go │ │ ├── list_test.go │ │ ├── rm.go │ │ ├── inspect_test.go │ │ └── list.go │ ├── account │ │ ├── testdata │ │ │ └── info.golden │ │ ├── cmd.go │ │ ├── info_test.go │ │ ├── ratelimiting.go │ │ └── info.go │ ├── repo │ │ ├── cmd.go │ │ ├── rm.go │ │ └── list.go │ ├── org │ │ ├── cmd.go │ │ ├── members.go │ │ ├── teams.go │ │ └── list.go │ ├── logout.go │ ├── token │ │ ├── cmd.go │ │ ├── activate.go │ │ ├── deactivate.go │ │ ├── rm.go │ │ ├── create.go │ │ ├── inspect.go │ │ └── list.go │ ├── login.go │ └── root.go ├── format │ ├── tabwriter │ │ ├── testdata │ │ │ └── twolines.golden │ │ ├── tabwriter_test.go │ │ └── tabwriter.go │ └── json.go ├── errdef │ └── errdef.go ├── version.go ├── metrics │ └── metrics.go ├── ansi │ └── ansi.go └── login │ └── login.go ├── .dockerignore ├── .gitignore ├── .gitattributes ├── NOTICE ├── packaging └── LICENSE ├── scripts └── validate │ ├── check-go-mod │ ├── template │ ├── bash.txt │ ├── makefile.txt │ ├── dockerfile.txt │ └── go.txt │ └── fileheader ├── .golangci.yml ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── build-pr.yml │ └── release-weekly-build.yml └── ISSUE_TEMPLATE.md ├── pkg ├── README.md ├── hub │ ├── client_test.go │ ├── error_test.go │ ├── instances.go │ ├── error.go │ ├── user.go │ ├── consumption.go │ ├── plan.go │ ├── teams.go │ ├── members.go │ ├── repositories.go │ ├── ratelimiting.go │ ├── organizations.go │ ├── tags.go │ ├── tokens.go │ └── client.go └── credentials │ └── store.go ├── vars.mk ├── e2e ├── login_test.go ├── version_test.go └── helper_test.go ├── main.go ├── README.md ├── Makefile ├── go.mod ├── Dockerfile └── LICENSE /internal/commands/tag/test.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | Makefile 3 | bin/ 4 | dist/ 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | bin/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /internal/format/tabwriter/testdata/twolines.golden: -------------------------------------------------------------------------------- 1 | test test 2 | docker docker 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | * text eol=lf 3 | *.png binary 4 | *.exe binary 5 | *.ico binary 6 | *.jar binary 7 | vendor/** -text 8 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Docker Hub Tool 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | This product includes software developed at Docker, Inc. (https://www.docker.com). 5 | -------------------------------------------------------------------------------- /packaging/LICENSE: -------------------------------------------------------------------------------- 1 | The Docker End User License Agreement (https://www.docker.com/legal/docker-software-end-user-license-agreement) describes Docker's Terms for this software. 2 | By downloading, accessing, or using this software you expressly accept and agree to the Terms set out in the Docker End User License Agreement. 3 | -------------------------------------------------------------------------------- /internal/commands/account/testdata/info.golden: -------------------------------------------------------------------------------- 1 | Name: my-user-name 2 | Full name: My Full Name 3 | Company: My Company 4 | Location: MyLocation 5 | Joined: Less than a second ago 6 | Plan: free 7 | Limits: 8 | Seats: 0/1 9 | Private repositories: 1/2 10 | Teams: unlimited 11 | Collaborators: unlimited 12 | Parallel builds: 3 13 | -------------------------------------------------------------------------------- /scripts/validate/check-go-mod: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | go mod tidy 6 | DIFF=$(git status --porcelain -- go.mod go.sum) 7 | 8 | if [ "$DIFF" ]; then 9 | echo 10 | echo "These files were changed:" 11 | echo 12 | echo "$DIFF" 13 | echo 14 | git diff go.mod go.sum 15 | exit 1 16 | else 17 | echo "go.mod is correct" 18 | fi; 19 | -------------------------------------------------------------------------------- /internal/commands/tag/testdata/inspect-manifest-list.golden: -------------------------------------------------------------------------------- 1 | Manifest List: 2 | Name: image:latest 3 | MediaType: mediatype/ociindex 4 | Digest: sha256:abcdef 5 | Annotations: 6 | annotation1: value1 7 | annotation2: value2 8 | 9 | Manifests: 10 | Name: image:latest@sha256:abcdef 11 | Mediatype: mediatype/manifest 12 | Platform: os/arch/variant 13 | 14 | Name: image:latest@sha256:beef 15 | Mediatype: mediatype/manifest 16 | Platform: os2/arch2 17 | -------------------------------------------------------------------------------- /scripts/validate/template/bash.txt: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Docker Hub Tool authors 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /scripts/validate/template/makefile.txt: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Docker Hub Tool authors 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /scripts/validate/template/dockerfile.txt: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Docker Hub Tool authors 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /scripts/validate/template/go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | -------------------------------------------------------------------------------- /internal/errdef/errdef.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package errdef 18 | 19 | import "errors" 20 | 21 | // ErrCanceled represents a normally canceled operation 22 | var ErrCanceled = errors.New("canceled") 23 | -------------------------------------------------------------------------------- /internal/commands/tag/testdata/inspect-manifest.golden: -------------------------------------------------------------------------------- 1 | Manifest: 2 | Name: image:latest 3 | MediaType: mediatype/manifest 4 | Digest: sha256:abcdef 5 | Platform: os/arch/variant 6 | Annotations: 7 | annotation1: value1 8 | annotation2: value2 9 | Os/Arch: os/arch 10 | Author: author 11 | Created: Less than a second ago 12 | 13 | Config: 14 | MediaType: mediatype/config 15 | Size: 123B 16 | Digest: sha256:beef 17 | Command: "./cmd parameter" 18 | Entrypoint: "./entrypoint parameter" 19 | User: user 20 | Exposed ports: 80/tcp 21 | Environment: 22 | KEY=VALUE 23 | Volumes: 24 | /volume 25 | Working Directory: "/workingdir" 26 | Labels: 27 | label="value" 28 | Stop signal: SIGTERM 29 | 30 | Layers: 31 | MediaType: mediatype/layer 32 | Size: 456B 33 | Digest: sha256:c0ffee 34 | Command: apt-get install 35 | Created: Less than a second ago 36 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | run: 3 | concurrency: 2 4 | enable-all: false 5 | disable-all: true 6 | enable: 7 | - deadcode 8 | - errcheck 9 | - gocyclo 10 | - gofmt 11 | - goimports 12 | - golint 13 | - gosimple 14 | - govet 15 | - ineffassign 16 | - interfacer 17 | - lll 18 | - misspell 19 | - nakedret 20 | - staticcheck 21 | - structcheck 22 | - typecheck 23 | - unconvert 24 | - unparam 25 | - unused 26 | - varcheck 27 | linters-settings: 28 | gocyclo: 29 | min-complexity: 16 30 | lll: 31 | line-length: 200 32 | issues: 33 | # golangci hides some golint warnings (the warning about exported things 34 | # withtout documentation for example), this will make it show them anyway. 35 | exclude-use-default: false 36 | exclude: 37 | - should not use dot imports 38 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 15 | 16 | **- What I did** 17 | 18 | **- How I did it** 19 | 20 | **- How to verify it** 21 | 22 | **- Description for the changelog** 23 | 27 | 28 | 29 | **- A picture of a cute animal (not mandatory)** 30 | 31 | -------------------------------------------------------------------------------- /internal/version.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package internal 18 | 19 | var ( 20 | // Version is the version tag of the docker scan binary, set at build time 21 | Version = "unknown" 22 | // GitCommit is the commit of the docker scan binary, set at build time 23 | GitCommit = "unknown" 24 | ) 25 | -------------------------------------------------------------------------------- /pkg/README.md: -------------------------------------------------------------------------------- 1 | # Using tool as lib 2 | 3 | You can use this tool as a library to make actions to Docker Hub. 4 | 5 | ## Examples 6 | 7 | ### Login 8 | 9 | ``` 10 | username := toto 11 | password := pass 12 | 13 | hubClient, err := hub.NewClient( 14 | hub.WithHubAccount(username), 15 | hub.WithPassword(password)) 16 | if err != nil { 17 | log.Fatalf("Can't initiate hubClient | %s", err.Error()) 18 | } 19 | 20 | //Login to retrieve new token to Hub 21 | token, _, err := hubClient.Login(username, password, func() (string, error) { 22 | return "2FA required, please provide the 6 digit code: ", nil 23 | }) 24 | if err != nil { 25 | log.Fatalf("Can't get token from Docker Hub | %s", err.Error()) 26 | } 27 | ``` 28 | 29 | After a successfull login, it is quite easy to do any action possible and listed inside `pkg/` directory. 30 | 31 | ### Removing a tag 32 | 33 | ``` 34 | err = hubClient.RemoveTag("toto/myrepo", "v1.0.0") 35 | if err != nil { 36 | log.Fatalf("Can't remove tag | %s", err.Error()) 37 | } 38 | ``` 39 | -------------------------------------------------------------------------------- /scripts/validate/fileheader: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright The Compose Specification Authors. 4 | 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | set -eu -o pipefail 19 | 20 | if ! command -v ltag; then 21 | >&2 echo "ERROR: ltag not found. Install with:" 22 | >&2 echo " go install github.com/kunalkushwaha/ltag@latest" 23 | exit 1 24 | fi 25 | 26 | BASEPATH="${1-}" 27 | 28 | ltag -t "${BASEPATH}scripts/validate/template" -excludes "validate testdata" --check -v -------------------------------------------------------------------------------- /vars.mk: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Docker Hub Tool authors 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Pinned Versions 16 | GO_VERSION=1.22.0-alpine3.19 17 | CLI_VERSION=20.10.2 18 | ALPINE_VERSION=3.12.2 19 | GOLANGCI_LINT_VERSION=v1.56.2-alpine 20 | GOTESTSUM_VERSION=1.11.0 21 | 22 | GOOS?=$(shell go env GOOS) 23 | GOARCH?=$(shell go env GOARCH) 24 | BINARY_EXT= 25 | ifeq ($(GOOS),windows) 26 | BINARY_EXT=.exe 27 | endif 28 | BINARY_NAME=hub-tool 29 | PLATFORM_BINARY:=$(BINARY_NAME)_$(GOOS)_$(GOARCH)$(BINARY_EXT) 30 | BINARY=$(BINARY_NAME)$(BINARY_EXT) 31 | -------------------------------------------------------------------------------- /e2e/login_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package e2e 18 | 19 | import ( 20 | "testing" 21 | 22 | "gotest.tools/v3/icmd" 23 | ) 24 | 25 | func TestUserNeedsToBeLoggedIn(t *testing.T) { 26 | cmd, cleanup := hubToolCmd(t, "--version") 27 | // Remove the config file 28 | cleanup() 29 | 30 | output := icmd.RunCmd(cmd) 31 | output.Equal(icmd.Expected{ 32 | ExitCode: 1, 33 | Out: `You need to be logged in to Docker Hub to use this tool. 34 | Please login to Docker Hub using the "docker login" command 35 | `, 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /internal/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package metrics 18 | 19 | import ( 20 | "net/http" 21 | "strings" 22 | 23 | "github.com/docker/compose-cli/cli/metrics" 24 | ) 25 | 26 | // Send emit a metric message to the local Desktop metric listener 27 | func Send(parent, command string) { 28 | client := metrics.NewClient(metrics.NewHTTPReporter(http.DefaultClient)) 29 | client.SendUsage(metrics.CommandUsage{ 30 | Command: strings.Join([]string{parent, command}, " "), 31 | Context: "hub", 32 | Source: "hub-cli", 33 | Status: metrics.SuccessStatus, 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/build-pr.yml: -------------------------------------------------------------------------------- 1 | name: Build PR 2 | on: [pull_request] 3 | 4 | jobs: 5 | lint: 6 | name: Lint 7 | timeout-minutes: 10 8 | runs-on: ubuntu-latest 9 | env: 10 | GO111MODULE: "on" 11 | steps: 12 | - name: Set up Go 1.22 13 | uses: actions/setup-go@v1 14 | with: 15 | go-version: 1.22 16 | id: go 17 | 18 | - name: Checkout code into the Go module directory 19 | uses: actions/checkout@v2 20 | 21 | - name: Check license headers 22 | run: make validate 23 | 24 | - name: Run golangci-lint 25 | run: make lint 26 | 27 | build-linux: 28 | name: Build Linux 29 | timeout-minutes: 10 30 | runs-on: ubuntu-latest 31 | env: 32 | GO111MODULE: "on" 33 | E2E_HUB_USERNAME: ${{ secrets.E2E_HUB_USERNAME }} 34 | E2E_HUB_TOKEN: ${{ secrets.E2E_HUB_TOKEN }} 35 | steps: 36 | - name: Docker version 37 | run: docker version 38 | 39 | - name: Set up Go 1.22 40 | uses: actions/setup-go@v1 41 | with: 42 | go-version: 1.22 43 | id: go 44 | 45 | - name: Checkout code into the Go module directory 46 | uses: actions/checkout@v2 47 | 48 | - name: Build CLI 49 | run: make build 50 | 51 | - name: Unit Tests 52 | run: make test-unit 53 | 54 | - name: End-to-end Tests 55 | run: make e2e 56 | -------------------------------------------------------------------------------- /internal/commands/tag/cmd.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package tag 18 | 19 | import ( 20 | "github.com/docker/cli/cli" 21 | "github.com/docker/cli/cli/command" 22 | "github.com/spf13/cobra" 23 | 24 | "github.com/docker/hub-tool/pkg/hub" 25 | ) 26 | 27 | const ( 28 | tagName = "tag" 29 | ) 30 | 31 | // NewTagCmd configures the tag manage command 32 | func NewTagCmd(streams command.Streams, hubClient *hub.Client) *cobra.Command { 33 | cmd := &cobra.Command{ 34 | Use: tagName, 35 | Short: "Manage tags", 36 | Args: cli.NoArgs, 37 | RunE: command.ShowHelp(streams.Err()), 38 | } 39 | cmd.AddCommand( 40 | newInspectCmd(streams, hubClient, tagName), 41 | newListCmd(streams, hubClient, tagName), 42 | newRmCmd(streams, hubClient, tagName), 43 | ) 44 | return cmd 45 | } 46 | -------------------------------------------------------------------------------- /pkg/hub/client_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import ( 20 | "net/http" 21 | "net/http/httptest" 22 | "testing" 23 | 24 | "gotest.tools/v3/assert" 25 | 26 | "github.com/docker/hub-tool/internal" 27 | ) 28 | 29 | func TestDoRequestAddsCustomUserAgent(t *testing.T) { 30 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 31 | assert.Equal(t, r.Header.Get("Accept"), "application/json") 32 | assert.Equal(t, r.Header.Get("User-Agent"), "hub-tool/"+internal.Version) 33 | })) 34 | defer server.Close() 35 | req, err := http.NewRequest("GET", server.URL, nil) 36 | assert.NilError(t, err) 37 | client, err := NewClient() 38 | assert.NilError(t, err) 39 | _, err = client.doRequest(req) 40 | assert.NilError(t, err) 41 | } 42 | -------------------------------------------------------------------------------- /internal/commands/repo/cmd.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package repo 18 | 19 | import ( 20 | "github.com/docker/cli/cli" 21 | "github.com/docker/cli/cli/command" 22 | "github.com/spf13/cobra" 23 | 24 | "github.com/docker/hub-tool/pkg/hub" 25 | ) 26 | 27 | const ( 28 | repoName = "repo" 29 | ) 30 | 31 | // NewRepoCmd configures the repo manage command 32 | func NewRepoCmd(streams command.Streams, hubClient *hub.Client) *cobra.Command { 33 | cmd := &cobra.Command{ 34 | Use: repoName, 35 | Short: "Manage repositories", 36 | Args: cli.NoArgs, 37 | DisableFlagsInUseLine: true, 38 | RunE: command.ShowHelp(streams.Err()), 39 | } 40 | cmd.AddCommand( 41 | newListCmd(streams, hubClient, repoName), 42 | newRmCmd(streams, hubClient, repoName), 43 | ) 44 | return cmd 45 | } 46 | -------------------------------------------------------------------------------- /internal/format/tabwriter/tabwriter_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package tabwriter 18 | 19 | import ( 20 | "bytes" 21 | "testing" 22 | 23 | "gotest.tools/v3/assert" 24 | "gotest.tools/v3/golden" 25 | ) 26 | 27 | func TestSimple(t *testing.T) { 28 | b := bytes.NewBuffer(nil) 29 | tw := New(b, "") 30 | s := "test" 31 | tw.Column(s, len(s)) 32 | err := tw.Flush() 33 | assert.NilError(t, err) 34 | assert.Equal(t, s+"\n", b.String()) 35 | } 36 | 37 | func TestTwoLines(t *testing.T) { 38 | b := bytes.NewBuffer(nil) 39 | tw := New(b, " ") 40 | first := "test" 41 | second := "docker" 42 | tw.Column(first, len(first)) 43 | tw.Column(first, len(first)) 44 | tw.Line() 45 | tw.Column(second, len(second)) 46 | tw.Column(second, len(second)) 47 | err := tw.Flush() 48 | assert.NilError(t, err) 49 | golden.Assert(t, b.String(), "twolines.golden") 50 | } 51 | -------------------------------------------------------------------------------- /pkg/hub/error_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import ( 20 | "errors" 21 | "testing" 22 | 23 | "gotest.tools/v3/assert" 24 | ) 25 | 26 | func TestIsAuthenticationError(t *testing.T) { 27 | assert.Assert(t, IsAuthenticationError(&authenticationError{})) 28 | assert.Assert(t, !IsAuthenticationError(errors.New(""))) 29 | } 30 | 31 | func TestIsInvalidTokenError(t *testing.T) { 32 | assert.Assert(t, IsInvalidTokenError(&invalidTokenError{})) 33 | assert.Assert(t, !IsInvalidTokenError(errors.New(""))) 34 | } 35 | 36 | func TestIsForbiddenError(t *testing.T) { 37 | assert.Assert(t, IsForbiddenError(&forbiddenError{})) 38 | assert.Assert(t, !IsForbiddenError(errors.New(""))) 39 | } 40 | 41 | func TestIsNotFoundError(t *testing.T) { 42 | assert.Assert(t, IsNotFoundError(¬FoundError{})) 43 | assert.Assert(t, !IsNotFoundError(errors.New(""))) 44 | } 45 | -------------------------------------------------------------------------------- /internal/commands/org/cmd.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package org 18 | 19 | import ( 20 | "github.com/docker/cli/cli" 21 | "github.com/docker/cli/cli/command" 22 | "github.com/spf13/cobra" 23 | 24 | "github.com/docker/hub-tool/pkg/hub" 25 | ) 26 | 27 | const ( 28 | orgName = "org" 29 | ) 30 | 31 | // NewOrgCmd configures the org manage command 32 | func NewOrgCmd(streams command.Streams, hubClient *hub.Client) *cobra.Command { 33 | cmd := &cobra.Command{ 34 | Use: orgName, 35 | Short: "Manage organizations", 36 | Args: cli.NoArgs, 37 | DisableFlagsInUseLine: true, 38 | RunE: command.ShowHelp(streams.Err()), 39 | } 40 | cmd.AddCommand( 41 | newListCmd(streams, hubClient, orgName), 42 | newMembersCmd(streams, hubClient, orgName), 43 | newTeamsCmd(streams, hubClient, orgName), 44 | ) 45 | return cmd 46 | } 47 | -------------------------------------------------------------------------------- /e2e/version_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package e2e 18 | 19 | import ( 20 | "fmt" 21 | "testing" 22 | 23 | "gotest.tools/v3/assert" 24 | "gotest.tools/v3/icmd" 25 | 26 | "github.com/docker/hub-tool/internal" 27 | ) 28 | 29 | func TestVersionCmd(t *testing.T) { 30 | cmd, cleanup := hubToolCmd(t, "version") 31 | defer cleanup() 32 | 33 | output := icmd.RunCmd(cmd).Assert(t, icmd.Success).Combined() 34 | expected := fmt.Sprintf("Version: %s\nGit commit: %s\n", internal.Version, internal.GitCommit) 35 | 36 | assert.Equal(t, output, expected) 37 | } 38 | 39 | func TestVersionFlag(t *testing.T) { 40 | cmd, cleanup := hubToolCmd(t, "--version") 41 | defer cleanup() 42 | 43 | output := icmd.RunCmd(cmd).Assert(t, icmd.Success).Combined() 44 | expected := fmt.Sprintf("Docker Hub Tool %s, build %s\n", internal.Version, internal.GitCommit[:7]) 45 | 46 | assert.Equal(t, output, expected) 47 | } 48 | -------------------------------------------------------------------------------- /internal/commands/account/cmd.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package account 18 | 19 | import ( 20 | "github.com/docker/cli/cli" 21 | "github.com/docker/cli/cli/command" 22 | "github.com/spf13/cobra" 23 | 24 | "github.com/docker/hub-tool/pkg/hub" 25 | ) 26 | 27 | const ( 28 | accountName = "account" 29 | ) 30 | 31 | // NewAccountCmd configures the org manage command 32 | func NewAccountCmd(streams command.Streams, hubClient *hub.Client) *cobra.Command { 33 | cmd := &cobra.Command{ 34 | Use: accountName, 35 | Short: "Manage your account", 36 | Args: cli.NoArgs, 37 | DisableFlagsInUseLine: true, 38 | Annotations: map[string]string{ 39 | "sudo": "true", 40 | }, 41 | RunE: command.ShowHelp(streams.Err()), 42 | } 43 | cmd.AddCommand( 44 | newInfoCmd(streams, hubClient, accountName), 45 | newRateLimitingCmd(streams, hubClient, accountName), 46 | ) 47 | return cmd 48 | } 49 | -------------------------------------------------------------------------------- /internal/commands/logout.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package commands 18 | 19 | import ( 20 | "fmt" 21 | 22 | "github.com/spf13/cobra" 23 | 24 | "github.com/docker/cli/cli/command" 25 | "github.com/docker/hub-tool/internal/ansi" 26 | "github.com/docker/hub-tool/internal/metrics" 27 | "github.com/docker/hub-tool/pkg/credentials" 28 | ) 29 | 30 | const ( 31 | logoutName = "logout" 32 | ) 33 | 34 | func newLogoutCmd(streams command.Streams, store credentials.Store) *cobra.Command { 35 | cmd := &cobra.Command{ 36 | Use: logoutName + " USERNAME", 37 | Short: "Logout of the Hub", 38 | DisableFlagsInUseLine: true, 39 | PreRun: func(cmd *cobra.Command, args []string) { 40 | metrics.Send("root", logoutName) 41 | }, 42 | RunE: func(_ *cobra.Command, args []string) error { 43 | if err := store.Erase(); err != nil { 44 | return err 45 | } 46 | fmt.Fprintln(streams.Out(), ansi.Info("Logout Succeeded")) 47 | return nil 48 | }, 49 | } 50 | return cmd 51 | } 52 | -------------------------------------------------------------------------------- /internal/commands/token/cmd.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package token 18 | 19 | import ( 20 | "github.com/docker/cli/cli" 21 | "github.com/docker/cli/cli/command" 22 | "github.com/spf13/cobra" 23 | 24 | "github.com/docker/hub-tool/pkg/hub" 25 | ) 26 | 27 | const ( 28 | tokenName = "token" 29 | ) 30 | 31 | // NewTokenCmd configures the token manage command 32 | func NewTokenCmd(streams command.Streams, hubClient *hub.Client) *cobra.Command { 33 | cmd := &cobra.Command{ 34 | Use: tokenName, 35 | Short: "Manage Personal Access Tokens", 36 | Args: cli.NoArgs, 37 | RunE: command.ShowHelp(streams.Err()), 38 | Annotations: map[string]string{ 39 | "sudo": "true", 40 | }, 41 | } 42 | cmd.AddCommand( 43 | newCreateCmd(streams, hubClient, tokenName), 44 | newInspectCmd(streams, hubClient, tokenName), 45 | newListCmd(streams, hubClient, tokenName), 46 | newActivateCmd(streams, hubClient, tokenName), 47 | newDeactivateCmd(streams, hubClient, tokenName), 48 | newRmCmd(streams, hubClient, tokenName), 49 | ) 50 | return cmd 51 | } 52 | -------------------------------------------------------------------------------- /internal/commands/account/info_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package account 18 | 19 | import ( 20 | "bytes" 21 | "testing" 22 | "time" 23 | 24 | "gotest.tools/v3/assert" 25 | "gotest.tools/v3/golden" 26 | 27 | "github.com/docker/hub-tool/pkg/hub" 28 | ) 29 | 30 | func TestInfoOutput(t *testing.T) { 31 | account := account{ 32 | Account: &hub.Account{ 33 | ID: "id", 34 | Name: "my-user-name", 35 | FullName: "My Full Name", 36 | Location: "MyLocation", 37 | Company: "My Company", 38 | Joined: time.Now(), 39 | }, 40 | Plan: &hub.Plan{ 41 | Name: "free", 42 | Limits: hub.Limits{ 43 | Seats: 1, 44 | PrivateRepos: 2, 45 | Teams: 9999, 46 | Collaborators: 9999, 47 | ParallelBuilds: 3, 48 | }, 49 | }, 50 | Consumption: &hub.Consumption{ 51 | Seats: 0, 52 | PrivateRepositories: 1, 53 | Teams: 2, 54 | }, 55 | } 56 | buf := bytes.NewBuffer(nil) 57 | err := printAccount(buf, account) 58 | assert.NilError(t, err) 59 | golden.Assert(t, buf.String(), "info.golden") 60 | } 61 | -------------------------------------------------------------------------------- /pkg/hub/instances.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import ( 20 | "os" 21 | 22 | "github.com/docker/docker/api/types/registry" 23 | ) 24 | 25 | // Instance stores all the specific pieces needed to dialog with Hub 26 | type Instance struct { 27 | APIHubBaseURL string 28 | RegistryInfo *registry.IndexInfo 29 | } 30 | 31 | var ( 32 | hub = Instance{ 33 | APIHubBaseURL: "https://hub.docker.com", 34 | RegistryInfo: ®istry.IndexInfo{ 35 | Name: "registry-1.docker.io", 36 | Mirrors: nil, 37 | Secure: true, 38 | Official: true, 39 | }, 40 | } 41 | ) 42 | 43 | // getInstance returns the current hub instance, which can be overridden by 44 | // DOCKER_REGISTRY_URL and DOCKER_REGISTRY_URL env var 45 | func getInstance() *Instance { 46 | apiBaseURL := os.Getenv("DOCKER_HUB_API_URL") 47 | reg := os.Getenv("DOCKER_REGISTRY_URL") 48 | 49 | if apiBaseURL != "" && reg != "" { 50 | return &Instance{ 51 | APIHubBaseURL: apiBaseURL, 52 | RegistryInfo: ®istry.IndexInfo{ 53 | Name: reg, 54 | Mirrors: nil, 55 | Secure: true, 56 | Official: false, 57 | }, 58 | } 59 | } 60 | 61 | return &hub 62 | } 63 | -------------------------------------------------------------------------------- /internal/format/json.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package format 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "io" 23 | 24 | "github.com/spf13/pflag" 25 | ) 26 | 27 | // Option handles format flags and printing the values depending the format 28 | type Option struct { 29 | format string 30 | } 31 | 32 | // PrettyPrinter prints all the values in a pretty print format 33 | type PrettyPrinter func(io.Writer, interface{}) error 34 | 35 | // AddFormatFlag add the format flag to a command 36 | func (o *Option) AddFormatFlag(flags *pflag.FlagSet) { 37 | flags.StringVar(&o.format, "format", "", `Print values using a custom format ("json")`) 38 | } 39 | 40 | // Print outputs values depending the given format 41 | func (o *Option) Print(out io.Writer, values interface{}, prettyPrinter PrettyPrinter) error { 42 | switch o.format { 43 | case "": 44 | return prettyPrinter(out, values) 45 | case "json": 46 | return printJSON(out, values) 47 | default: 48 | return fmt.Errorf("unsupported format type: %q", o.format) 49 | } 50 | } 51 | 52 | func printJSON(out io.Writer, values interface{}) error { 53 | data, err := json.MarshalIndent(values, "", " ") 54 | if err != nil { 55 | return err 56 | } 57 | _, err = fmt.Fprintln(out, string(data)) 58 | return err 59 | } 60 | -------------------------------------------------------------------------------- /e2e/helper_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package e2e 18 | 19 | import ( 20 | "encoding/json" 21 | "os" 22 | "path/filepath" 23 | "runtime" 24 | "testing" 25 | 26 | "github.com/docker/cli/cli/config/configfile" 27 | clitypes "github.com/docker/cli/cli/config/types" 28 | "gotest.tools/v3/assert" 29 | "gotest.tools/v3/fs" 30 | "gotest.tools/v3/icmd" 31 | ) 32 | 33 | func hubToolCmd(t *testing.T, args ...string) (icmd.Cmd, func()) { 34 | user := os.Getenv("E2E_HUB_USERNAME") 35 | token := os.Getenv("E2E_HUB_TOKEN") 36 | 37 | config := configfile.ConfigFile{ 38 | AuthConfigs: map[string]clitypes.AuthConfig{"https://index.docker.io/v1/": { 39 | Username: user, 40 | Password: token, 41 | }}, 42 | } 43 | data, err := json.Marshal(&config) 44 | assert.NilError(t, err) 45 | 46 | pwd, err := os.Getwd() 47 | assert.NilError(t, err) 48 | hubTool := os.Getenv("BINARY") 49 | configDir := fs.NewDir(t, t.Name(), fs.WithFile("config.json", string(data))) 50 | t.Setenv("PATH", os.Getenv("PATH")+getPathSeparator()+filepath.Join(pwd, "..", "bin")) 51 | env := append(os.Environ(), "DOCKER_CONFIG="+configDir.Path()) 52 | 53 | return icmd.Cmd{Command: append([]string{hubTool}, args...), Env: env}, func() { configDir.Remove() } 54 | } 55 | 56 | func getPathSeparator() string { 57 | if runtime.GOOS == "windows" { 58 | return ";" 59 | } 60 | return ":" 61 | } 62 | -------------------------------------------------------------------------------- /pkg/hub/error.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import "fmt" 20 | 21 | type authenticationError struct { 22 | } 23 | 24 | func (a authenticationError) Error() string { 25 | return "authentication error" 26 | } 27 | 28 | // IsAuthenticationError check if the error type is an authentication error 29 | func IsAuthenticationError(err error) bool { 30 | _, ok := err.(*authenticationError) 31 | return ok 32 | } 33 | 34 | type invalidTokenError struct { 35 | token string 36 | } 37 | 38 | func (i invalidTokenError) Error() string { 39 | return fmt.Sprintf("invalid authentication token %q", i.token) 40 | } 41 | 42 | // IsInvalidTokenError check if the error type is an invalid token error 43 | func IsInvalidTokenError(err error) bool { 44 | _, ok := err.(*invalidTokenError) 45 | return ok 46 | } 47 | 48 | type forbiddenError struct{} 49 | 50 | func (f forbiddenError) Error() string { 51 | return "operation not permitted" 52 | } 53 | 54 | // IsForbiddenError check if the error type is a forbidden error 55 | func IsForbiddenError(err error) bool { 56 | _, ok := err.(*forbiddenError) 57 | return ok 58 | } 59 | 60 | type notFoundError struct{} 61 | 62 | func (n notFoundError) Error() string { 63 | return "resource not found" 64 | } 65 | 66 | // IsNotFoundError check if the error type is a not found error 67 | func IsNotFoundError(err error) bool { 68 | _, ok := err.(*notFoundError) 69 | return ok 70 | } 71 | -------------------------------------------------------------------------------- /internal/commands/token/activate.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package token 18 | 19 | import ( 20 | "fmt" 21 | 22 | "github.com/docker/cli/cli" 23 | "github.com/docker/cli/cli/command" 24 | "github.com/google/uuid" 25 | "github.com/spf13/cobra" 26 | 27 | "github.com/docker/hub-tool/internal/ansi" 28 | "github.com/docker/hub-tool/internal/metrics" 29 | "github.com/docker/hub-tool/pkg/hub" 30 | ) 31 | 32 | const ( 33 | activateName = "activate" 34 | ) 35 | 36 | func newActivateCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 37 | cmd := &cobra.Command{ 38 | Use: activateName + " TOKEN_UUID", 39 | Short: "Activate a Personal Access Token", 40 | Args: cli.ExactArgs(1), 41 | DisableFlagsInUseLine: true, 42 | Annotations: map[string]string{ 43 | "sudo": "true", 44 | }, 45 | PreRun: func(cmd *cobra.Command, args []string) { 46 | metrics.Send(parent, activateName) 47 | }, 48 | RunE: func(_ *cobra.Command, args []string) error { 49 | return runActivate(streams, hubClient, args[0]) 50 | }, 51 | } 52 | return cmd 53 | } 54 | 55 | func runActivate(streams command.Streams, hubClient *hub.Client, tokenUUID string) error { 56 | u, err := uuid.Parse(tokenUUID) 57 | if err != nil { 58 | return err 59 | } 60 | if _, err := hubClient.UpdateToken(u.String(), "", true); err != nil { 61 | return err 62 | } 63 | fmt.Fprintf(streams.Out(), ansi.Emphasise("%s is active\n"), u.String()) 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/commands/login.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package commands 18 | 19 | import ( 20 | "fmt" 21 | 22 | "github.com/pkg/errors" 23 | 24 | "github.com/docker/cli/cli" 25 | "github.com/docker/cli/cli/command" 26 | "github.com/spf13/cobra" 27 | 28 | "github.com/docker/hub-tool/internal/ansi" 29 | "github.com/docker/hub-tool/internal/errdef" 30 | "github.com/docker/hub-tool/internal/login" 31 | "github.com/docker/hub-tool/internal/metrics" 32 | "github.com/docker/hub-tool/pkg/credentials" 33 | "github.com/docker/hub-tool/pkg/hub" 34 | ) 35 | 36 | const ( 37 | loginName = "login" 38 | ) 39 | 40 | func newLoginCmd(streams command.Streams, store credentials.Store, hubClient *hub.Client) *cobra.Command { 41 | cmd := &cobra.Command{ 42 | Use: loginName + " [USERNAME]", 43 | Short: "Login to the Hub", 44 | Args: cli.RequiresMaxArgs(1), 45 | DisableFlagsInUseLine: true, 46 | PreRun: func(cmd *cobra.Command, args []string) { 47 | metrics.Send("root", loginName) 48 | }, 49 | RunE: func(cmd *cobra.Command, args []string) error { 50 | username := "" 51 | if len(args) > 0 { 52 | username = args[0] 53 | } 54 | err := login.RunLogin(cmd.Context(), streams, hubClient, store, username) 55 | if err != nil { 56 | if errors.Is(err, errdef.ErrCanceled) { 57 | return nil 58 | } 59 | return err 60 | } 61 | 62 | fmt.Fprintln(streams.Out(), ansi.Info("Login Succeeded")) 63 | return nil 64 | }, 65 | } 66 | return cmd 67 | } 68 | -------------------------------------------------------------------------------- /internal/commands/token/deactivate.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package token 18 | 19 | import ( 20 | "fmt" 21 | 22 | "github.com/docker/cli/cli" 23 | "github.com/docker/cli/cli/command" 24 | "github.com/google/uuid" 25 | "github.com/spf13/cobra" 26 | 27 | "github.com/docker/hub-tool/internal/ansi" 28 | "github.com/docker/hub-tool/internal/metrics" 29 | "github.com/docker/hub-tool/pkg/hub" 30 | ) 31 | 32 | const ( 33 | deactivateName = "deactivate" 34 | ) 35 | 36 | func newDeactivateCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 37 | cmd := &cobra.Command{ 38 | Use: deactivateName + " TOKEN_UUID", 39 | Short: "Deactivate a Personal Access Token", 40 | Args: cli.ExactArgs(1), 41 | DisableFlagsInUseLine: true, 42 | Annotations: map[string]string{ 43 | "sudo": "true", 44 | }, 45 | PreRun: func(cmd *cobra.Command, args []string) { 46 | metrics.Send(parent, deactivateName) 47 | }, 48 | RunE: func(_ *cobra.Command, args []string) error { 49 | return runDeactivate(streams, hubClient, args[0]) 50 | }, 51 | } 52 | return cmd 53 | } 54 | 55 | func runDeactivate(streams command.Streams, hubClient *hub.Client, tokenUUID string) error { 56 | u, err := uuid.Parse(tokenUUID) 57 | if err != nil { 58 | return err 59 | } 60 | if _, err := hubClient.UpdateToken(u.String(), "", false); err != nil { 61 | return err 62 | } 63 | fmt.Fprintf(streams.Out(), ansi.Emphasise("%s is inactive\n"), u.String()) 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 32 | 33 | **Description** 34 | 35 | 38 | 39 | **Steps to reproduce the issue:** 40 | 1. 41 | 2. 42 | 3. 43 | 44 | **Describe the results you received:** 45 | 46 | 47 | **Describe the results you expected:** 48 | 49 | 50 | **Additional information you deem important (e.g. issue happens only occasionally):** 51 | 52 | **Output of `hub-tool --version`:** 53 | 54 | ``` 55 | (paste your output here) 56 | ``` 57 | 58 | **Output of `docker version`:** 59 | 60 | ``` 61 | (paste your output here) 62 | ``` 63 | 64 | **Output of `docker info`:** 65 | 66 | ``` 67 | (paste your output here) 68 | ``` 69 | 70 | **Additional environment details (AWS, VirtualBox, physical, etc.):** 71 | -------------------------------------------------------------------------------- /internal/commands/tag/list_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package tag 18 | 19 | import ( 20 | "testing" 21 | 22 | "gotest.tools/v3/assert" 23 | ) 24 | 25 | func TestMappingSortFieldToOrderingAPI(t *testing.T) { 26 | testCases := []struct { 27 | name string 28 | sort string 29 | ordering string 30 | expectedError string 31 | }{ 32 | { 33 | name: "default", 34 | sort: "", 35 | ordering: "", 36 | }, 37 | { 38 | name: "invalid sort by", 39 | sort: "invalid", 40 | ordering: "", 41 | expectedError: `unknown sorting column "invalid": should be either "name" or "updated"`, 42 | }, 43 | { 44 | name: "ascending order by default", 45 | sort: "updated", 46 | ordering: "last_updated", 47 | }, 48 | { 49 | name: "updated ascending", 50 | sort: "updated=asc", 51 | ordering: "last_updated", 52 | }, 53 | { 54 | name: "updated descending", 55 | sort: "updated=desc", 56 | ordering: "-last_updated", 57 | }, 58 | { 59 | name: "name ascending by default", 60 | sort: "name", 61 | ordering: "-name", 62 | }, 63 | { 64 | name: "name ascending", 65 | sort: "name=asc", 66 | ordering: "-name", 67 | }, 68 | { 69 | name: "name descending", 70 | sort: "name=desc", 71 | ordering: "name", 72 | }, 73 | } 74 | for _, testCase := range testCases { 75 | t.Run(testCase.name, func(t *testing.T) { 76 | actualOrdering, actualError := mapOrdering(testCase.sort) 77 | if testCase.expectedError != "" { 78 | assert.Error(t, actualError, testCase.expectedError) 79 | } else { 80 | assert.Equal(t, actualOrdering, testCase.ordering) 81 | } 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "log" 23 | "os" 24 | "os/signal" 25 | "syscall" 26 | 27 | "github.com/docker/cli/cli/command" 28 | dockercredentials "github.com/docker/cli/cli/config/credentials" 29 | cliflags "github.com/docker/cli/cli/flags" 30 | 31 | "github.com/docker/hub-tool/internal/commands" 32 | "github.com/docker/hub-tool/pkg/credentials" 33 | "github.com/docker/hub-tool/pkg/hub" 34 | ) 35 | 36 | func main() { 37 | ctx, closeFunc := newSigContext() 38 | defer closeFunc() 39 | 40 | dockerCli, err := command.NewDockerCli() 41 | if err != nil { 42 | fmt.Fprintln(os.Stderr, err) 43 | os.Exit(1) 44 | } 45 | opts := cliflags.NewClientOptions() 46 | if err := dockerCli.Initialize(opts); err != nil { 47 | log.Fatal(err) 48 | } 49 | 50 | store := credentials.NewStore(func(key string) dockercredentials.Store { 51 | config := dockerCli.ConfigFile() 52 | return config.GetCredentialsStore(key) 53 | }) 54 | auth, err := store.GetAuth() 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | 59 | hubClient, err := hub.NewClient( 60 | hub.WithContext(ctx), 61 | hub.WithInStream(dockerCli.In()), 62 | hub.WithOutStream(dockerCli.Out()), 63 | hub.WithHubAccount(auth.Username), 64 | hub.WithPassword(auth.Password), 65 | hub.WithRefreshToken(auth.RefreshToken), 66 | hub.WithHubToken(auth.Token)) 67 | if err != nil { 68 | log.Fatal(err) 69 | } 70 | 71 | rootCmd := commands.NewRootCmd(dockerCli, hubClient, store, os.Args[0]) 72 | if err := rootCmd.ExecuteContext(ctx); err != nil { 73 | os.Exit(1) 74 | } 75 | os.Exit(0) 76 | } 77 | 78 | func newSigContext() (context.Context, func()) { 79 | ctx, cancel := context.WithCancel(context.Background()) 80 | s := make(chan os.Signal, 1) 81 | signal.Notify(s, syscall.SIGTERM, syscall.SIGINT) 82 | go func() { 83 | <-s 84 | cancel() 85 | }() 86 | return ctx, cancel 87 | } 88 | -------------------------------------------------------------------------------- /pkg/hub/user.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import ( 20 | "encoding/json" 21 | "net/http" 22 | "net/url" 23 | "time" 24 | ) 25 | 26 | const ( 27 | //UserURL path to user informations 28 | UserURL = "/v2/user/" 29 | ) 30 | 31 | // Account represents a user or organization information 32 | type Account struct { 33 | ID string 34 | Name string 35 | FullName string 36 | Location string 37 | Company string 38 | Joined time.Time 39 | } 40 | 41 | // GetUserInfo returns the information on the user retrieved from Hub 42 | func (c *Client) GetUserInfo() (*Account, error) { 43 | u, err := url.Parse(c.domain + UserURL) 44 | if err != nil { 45 | return nil, err 46 | } 47 | req, err := http.NewRequest("GET", u.String(), nil) 48 | if err != nil { 49 | return nil, err 50 | } 51 | response, err := c.doRequest(req, withHubToken(c.token)) 52 | if err != nil { 53 | return nil, err 54 | } 55 | var hubResponse hubUserResponse 56 | if err := json.Unmarshal(response, &hubResponse); err != nil { 57 | return nil, err 58 | } 59 | return &Account{ 60 | ID: hubResponse.ID, 61 | Name: hubResponse.UserName, 62 | FullName: hubResponse.FullName, 63 | Location: hubResponse.Location, 64 | Company: hubResponse.Company, 65 | Joined: hubResponse.DateJoined, 66 | }, nil 67 | } 68 | 69 | type hubUserResponse struct { 70 | ID string `json:"id"` 71 | UserName string `json:"username"` 72 | FullName string `json:"full_name"` 73 | Location string `json:"location"` 74 | Company string `json:"company"` 75 | GravatarEmail string `json:"gravatar_email"` 76 | GravatarURL string `json:"gravatar_url"` 77 | IsStaff bool `json:"is_staff"` 78 | IsAdmin bool `json:"is_admin"` 79 | ProfileURL string `json:"profile_url"` 80 | DateJoined time.Time `json:"date_joined"` 81 | Type string `json:"type"` 82 | } 83 | -------------------------------------------------------------------------------- /internal/format/tabwriter/tabwriter.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package tabwriter 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | ) 23 | 24 | type tw struct { 25 | idx int 26 | lines [][]string 27 | widths [][]int 28 | w io.Writer 29 | padding string 30 | } 31 | 32 | // TabWriter interface :) 33 | type TabWriter interface { 34 | Column(string, int) 35 | Line() 36 | Flush() error 37 | } 38 | 39 | // New creates a new tab writer that output to the io.Writer 40 | func New(w io.Writer, padding string) TabWriter { 41 | return &tw{ 42 | idx: 0, 43 | lines: [][]string{}, 44 | widths: [][]int{}, 45 | w: w, 46 | padding: padding, 47 | } 48 | } 49 | 50 | func (t *tw) Column(s string, l int) { 51 | if len(t.lines) <= t.idx { 52 | t.lines = append(t.lines, []string{}) 53 | t.widths = append(t.widths, []int{}) 54 | } 55 | 56 | t.lines[t.idx] = append(t.lines[t.idx], s) 57 | t.widths[t.idx] = append(t.widths[t.idx], l) 58 | } 59 | 60 | func (t *tw) Line() { 61 | t.idx++ 62 | } 63 | 64 | func (t *tw) Flush() error { 65 | maxs := []int{} 66 | for i := range t.lines[0] { 67 | maxs = append(maxs, t.max(i)) 68 | } 69 | 70 | for k, l := range t.lines { 71 | for i, c := range l { 72 | pad := "" 73 | if i != 0 { 74 | pad = t.pad(maxs[i-1], t.widths[k][i-1]) 75 | } 76 | if _, err := fmt.Fprint(t.w, pad+c); err != nil { 77 | return err 78 | } 79 | } 80 | if _, err := fmt.Fprintln(t.w, ""); err != nil { 81 | return err 82 | } 83 | } 84 | 85 | return nil 86 | } 87 | 88 | func (t *tw) pad(max int, l int) string { 89 | ret := t.padding 90 | for i := 0; i < max-l; i++ { 91 | ret += " " 92 | } 93 | return ret 94 | } 95 | 96 | func (t *tw) max(col int) int { 97 | w := 0 98 | for _, width := range t.widths { 99 | for i, ww := range width { 100 | if i == col { 101 | if ww > w { 102 | w = ww 103 | } 104 | } 105 | } 106 | } 107 | return w 108 | } 109 | -------------------------------------------------------------------------------- /pkg/hub/consumption.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import ( 20 | "context" 21 | 22 | "golang.org/x/sync/errgroup" 23 | ) 24 | 25 | // Consumption represents current user or org consumption 26 | type Consumption struct { 27 | Seats int 28 | PrivateRepositories int 29 | Teams int 30 | } 31 | 32 | // GetOrgConsumption return the current organization consumption 33 | func (c *Client) GetOrgConsumption(org string) (*Consumption, error) { 34 | var ( 35 | members int 36 | privateRepos int 37 | teams int 38 | ) 39 | c.fetchAllElements = true 40 | eg, _ := errgroup.WithContext(context.Background()) 41 | eg.Go(func() error { 42 | count, err := c.GetMembersCount(org) 43 | if err != nil { 44 | return err 45 | } 46 | members = count 47 | return nil 48 | }) 49 | eg.Go(func() error { 50 | count, err := c.GetTeamsCount(org) 51 | if err != nil { 52 | return err 53 | } 54 | teams = count 55 | return nil 56 | }) 57 | eg.Go(func() error { 58 | repos, _, err := c.GetRepositories(org) 59 | if err != nil { 60 | return err 61 | } 62 | for _, r := range repos { 63 | if r.IsPrivate { 64 | privateRepos++ 65 | } 66 | } 67 | return nil 68 | }) 69 | 70 | if err := eg.Wait(); err != nil { 71 | return nil, err 72 | } 73 | 74 | return &Consumption{ 75 | Seats: members, 76 | PrivateRepositories: privateRepos, 77 | Teams: teams, 78 | }, nil 79 | } 80 | 81 | // GetUserConsumption return the current user consumption 82 | func (c *Client) GetUserConsumption(user string) (*Consumption, error) { 83 | c.fetchAllElements = true 84 | privateRepos := 0 85 | repos, _, err := c.GetRepositories(user) 86 | if err != nil { 87 | return nil, err 88 | } 89 | for _, r := range repos { 90 | if r.IsPrivate { 91 | privateRepos++ 92 | } 93 | } 94 | return &Consumption{ 95 | Seats: 1, 96 | PrivateRepositories: privateRepos, 97 | Teams: 0, 98 | }, nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/ansi/ansi.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package ansi 18 | 19 | import ( 20 | "fmt" 21 | "os" 22 | 23 | "github.com/cli/cli/pkg/iostreams" 24 | "github.com/mattn/go-isatty" 25 | "github.com/mgutz/ansi" 26 | ) 27 | 28 | var ( 29 | // Outputs ANSI color if stdout is a tty 30 | red = makeColorFunc("red") 31 | yellow = makeColorFunc("yellow") 32 | blue = makeColorFunc("blue") 33 | green = makeColorFunc("green") 34 | ) 35 | 36 | func makeColorFunc(color string) func(string) string { 37 | cf := ansi.ColorFunc(color) 38 | return func(arg string) string { 39 | if isColorEnabled() { 40 | if color == "black+h" && iostreams.Is256ColorSupported() { 41 | return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, arg) 42 | } 43 | return cf(arg) 44 | } 45 | return arg 46 | } 47 | } 48 | 49 | func isColorEnabled() bool { 50 | if iostreams.EnvColorForced() { 51 | return true 52 | } 53 | 54 | if iostreams.EnvColorDisabled() { 55 | return false 56 | } 57 | 58 | // TODO ignores cmd.OutOrStdout 59 | return isTerminal(os.Stdout) 60 | } 61 | 62 | var isTerminal = func(f *os.File) bool { 63 | return isatty.IsTerminal(f.Fd()) || isCygwinTerminal(f) 64 | } 65 | 66 | func isCygwinTerminal(f *os.File) bool { 67 | return isatty.IsCygwinTerminal(f.Fd()) 68 | } 69 | 70 | var ( 71 | // Title color should be used for any important title 72 | Title = green 73 | // Header color should be used for all the listing column headers 74 | Header = blue 75 | // Key color should be used for all key title content 76 | Key = blue 77 | // Info color should be used when we prompt an info 78 | Info = blue 79 | // Warn color should be used when we warn the user 80 | Warn = yellow 81 | // Error color should be used when something bad happened 82 | Error = red 83 | // Emphasise color should be used with important content 84 | Emphasise = green 85 | // NoColor doesn't add any colors to the output 86 | NoColor = noop 87 | ) 88 | 89 | func noop(in string) string { 90 | return in 91 | } 92 | 93 | // Link returns an ANSI terminal hyperlink 94 | func Link(url string, text string) string { 95 | return fmt.Sprintf("\u001B]8;;%s\u0007%s\u001B]8;;\u0007", url, text) 96 | } 97 | -------------------------------------------------------------------------------- /pkg/hub/plan.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "net/http" 23 | "net/url" 24 | ) 25 | 26 | const ( 27 | //HubPlanURL path to the billing API returning the account hub plan 28 | HubPlanURL = "/api/billing/v4/accounts/%s/hub-plan" 29 | //TeamPlan refers to a hub team paid account 30 | TeamPlan = "team" 31 | //ProPlan refers to a hub individual paid account 32 | ProPlan = "pro" 33 | //FreePlan refers to a hub non-paid account 34 | FreePlan = "free" 35 | ) 36 | 37 | // Plan represents the current account Hub plan 38 | type Plan struct { 39 | Name string 40 | Limits Limits 41 | } 42 | 43 | // Limits represents the current account limits 44 | type Limits struct { 45 | Seats int 46 | PrivateRepos int 47 | Teams int 48 | Collaborators int 49 | ParallelBuilds int 50 | } 51 | 52 | // GetHubPlan returns an account current Hub plan 53 | func (c *Client) GetHubPlan(accountID string) (*Plan, error) { 54 | u, err := url.Parse(c.domain + fmt.Sprintf(HubPlanURL, accountID)) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | req, err := http.NewRequest("GET", u.String(), nil) 60 | if err != nil { 61 | return nil, err 62 | } 63 | response, err := c.doRequest(req, withHubToken(c.token)) 64 | if err != nil { 65 | return nil, err 66 | } 67 | var hubResponse hubPlanResponse 68 | if err := json.Unmarshal(response, &hubResponse); err != nil { 69 | return nil, err 70 | } 71 | return &Plan{ 72 | Name: hubResponse.Name, 73 | Limits: Limits{ 74 | Seats: hubResponse.Seats, 75 | PrivateRepos: hubResponse.PrivateRepos, 76 | Teams: hubResponse.Teams, 77 | Collaborators: hubResponse.Collaborators, 78 | ParallelBuilds: hubResponse.ParallelBuilds, 79 | }, 80 | }, nil 81 | } 82 | 83 | type hubPlanResponse struct { 84 | Name string `json:"name"` 85 | Legacy bool `json:"legacy"` 86 | Seats int `json:"seats"` 87 | PrivateRepos int `json:"private_repos"` 88 | Teams int `json:"teams"` 89 | Collaborators int `json:"collaborators"` 90 | ParallelBuilds int `json:"parallel_builds"` 91 | Duration string `json:"duration"` 92 | } 93 | -------------------------------------------------------------------------------- /internal/commands/token/rm.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package token 18 | 19 | import ( 20 | "bufio" 21 | "fmt" 22 | "strings" 23 | 24 | "github.com/docker/cli/cli" 25 | "github.com/docker/cli/cli/command" 26 | "github.com/google/uuid" 27 | "github.com/spf13/cobra" 28 | 29 | "github.com/docker/hub-tool/internal/ansi" 30 | "github.com/docker/hub-tool/internal/metrics" 31 | "github.com/docker/hub-tool/pkg/hub" 32 | ) 33 | 34 | const ( 35 | removeNAme = "rm" 36 | ) 37 | 38 | type removeOptions struct { 39 | force bool 40 | } 41 | 42 | func newRmCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 43 | var opts removeOptions 44 | cmd := &cobra.Command{ 45 | Use: removeNAme + " [OPTIONS] TOKEN_UUID", 46 | Short: "Delete a Personal Access Token", 47 | Args: cli.ExactArgs(1), 48 | DisableFlagsInUseLine: true, 49 | Annotations: map[string]string{ 50 | "sudo": "true", 51 | }, 52 | PreRun: func(cmd *cobra.Command, args []string) { 53 | metrics.Send(parent, removeNAme) 54 | }, 55 | RunE: func(_ *cobra.Command, args []string) error { 56 | return runRemove(streams, hubClient, opts, args[0]) 57 | }, 58 | } 59 | cmd.Flags().BoolVarP(&opts.force, "force", "f", false, "Force deletion of the tag") 60 | return cmd 61 | } 62 | 63 | func runRemove(streams command.Streams, hubClient *hub.Client, opts removeOptions, tokenUUID string) error { 64 | u, err := uuid.Parse(tokenUUID) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | if !opts.force { 70 | 71 | fmt.Fprintf(streams.Out(), ansi.Warn("WARNING: This action is irreversible.")+` 72 | By confirming, you will permanently delete the access token. 73 | Removing the tokens will invalidate your credentials on all Docker clients currently authenticated with the tokens. 74 | 75 | Please type your username %q to confirm token deletion: `, hubClient.AuthConfig.Username) 76 | reader := bufio.NewReader(streams.In()) 77 | input, _ := reader.ReadString('\n') 78 | input = strings.ToLower(strings.TrimSpace(input)) 79 | if input != hubClient.AuthConfig.Username { 80 | return fmt.Errorf("%q differs from your username, deletion aborted", input) 81 | } 82 | } 83 | 84 | if err := hubClient.RemoveToken(u.String()); err != nil { 85 | return err 86 | } 87 | fmt.Fprintln(streams.Out(), ansi.Emphasise("Access token deleted"), u) 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /internal/commands/account/ratelimiting.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package account 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "time" 23 | 24 | "github.com/docker/cli/cli" 25 | "github.com/docker/cli/cli/command" 26 | "github.com/docker/go-units" 27 | "github.com/spf13/cobra" 28 | 29 | "github.com/docker/hub-tool/internal/ansi" 30 | "github.com/docker/hub-tool/internal/format" 31 | "github.com/docker/hub-tool/internal/metrics" 32 | "github.com/docker/hub-tool/pkg/hub" 33 | ) 34 | 35 | const ( 36 | rateLimitingName = "rate-limiting" 37 | ) 38 | 39 | type rateLimitingOptions struct { 40 | format.Option 41 | } 42 | 43 | func newRateLimitingCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 44 | var opts rateLimitingOptions 45 | cmd := &cobra.Command{ 46 | Use: rateLimitingName, 47 | Short: "Print the rate limiting information", 48 | Args: cli.NoArgs, 49 | DisableFlagsInUseLine: true, 50 | Annotations: map[string]string{ 51 | "sudo": "true", 52 | }, 53 | PreRun: func(cmd *cobra.Command, args []string) { 54 | metrics.Send(parent, rateLimitingName) 55 | }, 56 | RunE: func(cmd *cobra.Command, args []string) error { 57 | return runRateLimiting(streams, hubClient, opts) 58 | }, 59 | } 60 | opts.AddFormatFlag(cmd.Flags()) 61 | 62 | return cmd 63 | } 64 | 65 | func runRateLimiting(streams command.Streams, hubClient *hub.Client, opts rateLimitingOptions) error { 66 | rl, err := hubClient.GetRateLimits() 67 | if err != nil { 68 | return err 69 | } 70 | value := &hub.RateLimits{} 71 | if rl != nil { 72 | value = rl 73 | } 74 | return opts.Print(streams.Out(), value, printRateLimit(rl)) 75 | } 76 | 77 | func printRateLimit(rl *hub.RateLimits) func(io.Writer, interface{}) error { 78 | return func(out io.Writer, _ interface{}) error { 79 | if rl == nil { 80 | fmt.Fprintln(out, ansi.Emphasise("Unlimited")) 81 | return nil 82 | } 83 | color := ansi.NoColor 84 | if *rl.Remaining <= 50 { 85 | color = ansi.Warn 86 | } 87 | if *rl.Remaining <= 10 { 88 | color = ansi.Error 89 | } 90 | fmt.Fprintf(out, color("Limit: %d, %s window\n"), *rl.Limit, units.HumanDuration(time.Duration(*rl.LimitWindow)*time.Second)) 91 | fmt.Fprintf(out, color("Remaining: %d, %s window\n"), *rl.Remaining, units.HumanDuration(time.Duration(*rl.RemainingWindow)*time.Second)) 92 | return nil 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /internal/commands/org/members.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package org 18 | 19 | import ( 20 | "io" 21 | 22 | "github.com/docker/cli/cli" 23 | "github.com/docker/cli/cli/command" 24 | "github.com/spf13/cobra" 25 | 26 | "github.com/docker/hub-tool/internal/ansi" 27 | "github.com/docker/hub-tool/internal/format" 28 | "github.com/docker/hub-tool/internal/format/tabwriter" 29 | "github.com/docker/hub-tool/internal/metrics" 30 | "github.com/docker/hub-tool/pkg/hub" 31 | ) 32 | 33 | const ( 34 | membersName = "members" 35 | ) 36 | 37 | var ( 38 | memberColumns = []memberColumn{ 39 | {"USERNAME", func(m hub.Member) (string, int) { return m.Username, len(m.Username) }}, 40 | {"FULL NAME", func(m hub.Member) (string, int) { return m.FullName, len(m.FullName) }}, 41 | } 42 | ) 43 | 44 | type memberColumn struct { 45 | header string 46 | value func(m hub.Member) (string, int) 47 | } 48 | 49 | type memberOptions struct { 50 | format.Option 51 | } 52 | 53 | func newMembersCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 54 | var opts memberOptions 55 | cmd := &cobra.Command{ 56 | Use: membersName + " ORGANIZATION", 57 | Short: "List all the members in an organization", 58 | Args: cli.ExactArgs(1), 59 | DisableFlagsInUseLine: true, 60 | PreRun: func(cmd *cobra.Command, args []string) { 61 | metrics.Send(parent, membersName) 62 | }, 63 | RunE: func(cmd *cobra.Command, args []string) error { 64 | return runMembers(streams, hubClient, opts, args[0]) 65 | }, 66 | } 67 | opts.AddFormatFlag(cmd.Flags()) 68 | return cmd 69 | } 70 | 71 | func runMembers(streams command.Streams, hubClient *hub.Client, opts memberOptions, organization string) error { 72 | members, err := hubClient.GetMembers(organization) 73 | if err != nil { 74 | return err 75 | } 76 | return opts.Print(streams.Out(), members, printMembers) 77 | } 78 | 79 | func printMembers(out io.Writer, values interface{}) error { 80 | members := values.([]hub.Member) 81 | tw := tabwriter.New(out, " ") 82 | for _, column := range memberColumns { 83 | tw.Column(ansi.Header(column.header), len(column.header)) 84 | } 85 | 86 | tw.Line() 87 | 88 | for _, member := range members { 89 | for _, column := range memberColumns { 90 | value, width := column.value(member) 91 | tw.Column(value, width) 92 | } 93 | tw.Line() 94 | } 95 | 96 | return tw.Flush() 97 | } 98 | -------------------------------------------------------------------------------- /internal/commands/org/teams.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package org 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | 23 | "github.com/docker/cli/cli" 24 | "github.com/docker/cli/cli/command" 25 | "github.com/spf13/cobra" 26 | 27 | "github.com/docker/hub-tool/internal/ansi" 28 | "github.com/docker/hub-tool/internal/format" 29 | "github.com/docker/hub-tool/internal/format/tabwriter" 30 | "github.com/docker/hub-tool/internal/metrics" 31 | "github.com/docker/hub-tool/pkg/hub" 32 | ) 33 | 34 | const ( 35 | teamsName = "teams" 36 | ) 37 | 38 | var ( 39 | teamsColumns = []teamColumn{ 40 | {"TEAM", func(t hub.Team) (string, int) { return t.Name, len(t.Name) }}, 41 | {"DESCRIPTION", func(t hub.Team) (string, int) { return t.Description, len(t.Description) }}, 42 | {"MEMBERS", func(t hub.Team) (string, int) { 43 | s := fmt.Sprintf("%v", len(t.Members)) 44 | return s, len(s) 45 | }}, 46 | } 47 | ) 48 | 49 | type teamColumn struct { 50 | header string 51 | value func(t hub.Team) (string, int) 52 | } 53 | 54 | type teamsOptions struct { 55 | format.Option 56 | } 57 | 58 | func newTeamsCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 59 | var opts teamsOptions 60 | cmd := &cobra.Command{ 61 | Use: teamsName + " ORGANIZATION", 62 | Short: "List all the teams in an organization", 63 | Args: cli.ExactArgs(1), 64 | DisableFlagsInUseLine: true, 65 | PreRun: func(cmd *cobra.Command, args []string) { 66 | metrics.Send(parent, teamsName) 67 | }, 68 | RunE: func(cmd *cobra.Command, args []string) error { 69 | return runTeams(streams, hubClient, opts, args[0]) 70 | }, 71 | } 72 | opts.AddFormatFlag(cmd.Flags()) 73 | return cmd 74 | } 75 | 76 | func runTeams(streams command.Streams, hubClient *hub.Client, opts teamsOptions, organization string) error { 77 | teams, err := hubClient.GetTeams(organization) 78 | if err != nil { 79 | return err 80 | } 81 | return opts.Print(streams.Out(), teams, printTeams) 82 | } 83 | 84 | func printTeams(out io.Writer, values interface{}) error { 85 | teams := values.([]hub.Team) 86 | tw := tabwriter.New(out, " ") 87 | 88 | for _, column := range teamsColumns { 89 | tw.Column(ansi.Header(column.header), len(column.header)) 90 | } 91 | 92 | tw.Line() 93 | for _, team := range teams { 94 | for _, column := range teamsColumns { 95 | value, width := column.value(team) 96 | tw.Column(value, width) 97 | } 98 | tw.Line() 99 | } 100 | 101 | return tw.Flush() 102 | } 103 | -------------------------------------------------------------------------------- /internal/commands/token/create.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package token 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | 23 | "github.com/docker/cli/cli" 24 | "github.com/docker/cli/cli/command" 25 | "github.com/spf13/cobra" 26 | 27 | "github.com/docker/hub-tool/internal/ansi" 28 | "github.com/docker/hub-tool/internal/format" 29 | "github.com/docker/hub-tool/internal/metrics" 30 | "github.com/docker/hub-tool/pkg/hub" 31 | ) 32 | 33 | const ( 34 | createName = "create" 35 | ) 36 | 37 | type createOptions struct { 38 | format.Option 39 | description string 40 | quiet bool 41 | } 42 | 43 | func newCreateCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 44 | var opts createOptions 45 | cmd := &cobra.Command{ 46 | Use: createName + " [OPTIONS]", 47 | Short: "Create a Personal Access Token", 48 | Args: cli.NoArgs, 49 | DisableFlagsInUseLine: true, 50 | Annotations: map[string]string{ 51 | "sudo": "true", 52 | }, 53 | PreRun: func(cmd *cobra.Command, args []string) { 54 | metrics.Send(parent, createName) 55 | }, 56 | RunE: func(_ *cobra.Command, args []string) error { 57 | return runCreate(streams, hubClient, opts) 58 | }, 59 | } 60 | opts.AddFormatFlag(cmd.Flags()) 61 | cmd.Flags().StringVar(&opts.description, "description", "", "Set token's description") 62 | cmd.Flags().BoolVar(&opts.quiet, "quiet", false, "Display only created token") 63 | return cmd 64 | } 65 | 66 | func runCreate(streams command.Streams, hubClient *hub.Client, opts createOptions) error { 67 | token, err := hubClient.CreateToken(opts.description) 68 | if err != nil { 69 | return err 70 | } 71 | if opts.quiet { 72 | fmt.Fprintln(streams.Out(), token.Token) 73 | return nil 74 | } 75 | return opts.Print(streams.Out(), token, printCreatedToken(hubClient)) 76 | } 77 | 78 | func printCreatedToken(hubClient *hub.Client) format.PrettyPrinter { 79 | return func(out io.Writer, value interface{}) error { 80 | helper := value.(*hub.Token) 81 | fmt.Fprintf(out, ansi.Emphasise("Personal Access Token successfully created!")+` 82 | 83 | When logging in from your Docker CLI client, use this token as a password. 84 | `+ansi.Header("Description:")+` %s 85 | 86 | To use the access token from your Docker CLI client: 87 | 1. Run: docker login --username %s 88 | 2. At the password prompt, enter the personal access token. 89 | 90 | %s 91 | 92 | `+ansi.Warn(`WARNING: This access token cannot be displayed again. 93 | It will not be stored and cannot be retrieved. Please be sure to save it now. 94 | `), 95 | helper.Description, 96 | hubClient.AuthConfig.Username, 97 | ansi.Emphasise(helper.Token)) 98 | return nil 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /internal/commands/tag/rm.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package tag 18 | 19 | import ( 20 | "bufio" 21 | "context" 22 | "fmt" 23 | "strings" 24 | 25 | "github.com/distribution/reference" 26 | "github.com/docker/cli/cli" 27 | "github.com/docker/cli/cli/command" 28 | "github.com/docker/hub-tool/internal/ansi" 29 | "github.com/docker/hub-tool/internal/errdef" 30 | "github.com/docker/hub-tool/internal/metrics" 31 | "github.com/docker/hub-tool/pkg/hub" 32 | "github.com/pkg/errors" 33 | "github.com/spf13/cobra" 34 | ) 35 | 36 | const ( 37 | rmName = "rm" 38 | ) 39 | 40 | type rmOptions struct { 41 | force bool 42 | } 43 | 44 | func newRmCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 45 | var opts rmOptions 46 | cmd := &cobra.Command{ 47 | Use: rmName + " [OPTIONS] REPOSITORY:TAG", 48 | Short: "Delete a tag in a repository", 49 | Args: cli.ExactArgs(1), 50 | DisableFlagsInUseLine: true, 51 | PreRun: func(cmd *cobra.Command, args []string) { 52 | metrics.Send(parent, rmName) 53 | }, 54 | RunE: func(cmd *cobra.Command, args []string) error { 55 | err := runRm(cmd.Context(), streams, hubClient, opts, args[0]) 56 | if err == nil || errors.Is(err, errdef.ErrCanceled) { 57 | return nil 58 | } 59 | return err 60 | }, 61 | } 62 | cmd.Flags().BoolVarP(&opts.force, "force", "f", false, "Force deletion of the tag") 63 | return cmd 64 | } 65 | 66 | func runRm(ctx context.Context, streams command.Streams, hubClient *hub.Client, opts rmOptions, image string) error { 67 | normRef, err := reference.ParseNormalizedNamed(image) 68 | if err != nil { 69 | return err 70 | } 71 | normRef = reference.TagNameOnly(normRef) 72 | ref, ok := normRef.(reference.NamedTagged) 73 | if !ok { 74 | return fmt.Errorf("invalid reference: tag must be specified") 75 | } 76 | 77 | if !opts.force { 78 | fmt.Fprintln(streams.Out(), ansi.Warn(fmt.Sprintf(`WARNING: You are about to permanently delete image "%s:%s"`, reference.FamiliarName(ref), ref.Tag()))) 79 | fmt.Fprintln(streams.Out(), ansi.Warn(" This action is irreversible")) 80 | fmt.Fprintf(streams.Out(), ansi.Info("Are you sure you want to delete the image tagged %q from repository %q? [y/N] "), ref.Tag(), reference.FamiliarName(ref)) 81 | userIn := make(chan string, 1) 82 | go func() { 83 | reader := bufio.NewReader(streams.In()) 84 | input, _ := reader.ReadString('\n') 85 | userIn <- strings.ToLower(strings.TrimSpace(input)) 86 | }() 87 | input := "" 88 | select { 89 | case <-ctx.Done(): 90 | return errdef.ErrCanceled 91 | case input = <-userIn: 92 | } 93 | if strings.ToLower(input) != "y" { 94 | return errors.New("deletion aborted") 95 | } 96 | } 97 | 98 | if err := hubClient.RemoveTag(reference.FamiliarName(ref), ref.Tag()); err != nil { 99 | return err 100 | } 101 | fmt.Fprintln(streams.Out(), "Deleted", image) 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /internal/commands/org/list.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package org 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "io" 23 | 24 | "github.com/docker/cli/cli" 25 | "github.com/docker/cli/cli/command" 26 | "github.com/spf13/cobra" 27 | 28 | "github.com/docker/hub-tool/internal/ansi" 29 | "github.com/docker/hub-tool/internal/format" 30 | "github.com/docker/hub-tool/internal/format/tabwriter" 31 | "github.com/docker/hub-tool/internal/metrics" 32 | "github.com/docker/hub-tool/pkg/hub" 33 | ) 34 | 35 | const ( 36 | listName = "ls" 37 | ) 38 | 39 | var ( 40 | defaultColumns = []column{ 41 | {"NAMESPACE", func(o hub.Organization) (string, int) { 42 | return ansi.Link(fmt.Sprintf("https://hub.docker.com/orgs/%s", o.Namespace), o.Namespace), len(o.Namespace) 43 | }}, 44 | {"NAME", func(o hub.Organization) (string, int) { return o.FullName, len(o.FullName) }}, 45 | {"MY ROLE", func(o hub.Organization) (string, int) { return o.Role, len(o.Role) }}, 46 | {"TEAMS", func(o hub.Organization) (string, int) { 47 | s := fmt.Sprintf("%v", len(o.Teams)) 48 | return s, len(s) 49 | }}, 50 | {"MEMBERS", func(o hub.Organization) (string, int) { 51 | s := fmt.Sprintf("%v", len(o.Members)) 52 | return s, len(s) 53 | }}, 54 | } 55 | ) 56 | 57 | type column struct { 58 | header string 59 | value func(o hub.Organization) (string, int) 60 | } 61 | 62 | type listOptions struct { 63 | format.Option 64 | } 65 | 66 | func newListCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 67 | var opts listOptions 68 | cmd := &cobra.Command{ 69 | Use: listName, 70 | Aliases: []string{"list"}, 71 | Short: "List all the organizations", 72 | Args: cli.NoArgs, 73 | DisableFlagsInUseLine: true, 74 | PreRun: func(cmd *cobra.Command, args []string) { 75 | metrics.Send(parent, listName) 76 | }, 77 | RunE: func(cmd *cobra.Command, args []string) error { 78 | return runList(cmd.Context(), streams, hubClient, opts) 79 | }, 80 | } 81 | opts.AddFormatFlag(cmd.Flags()) 82 | return cmd 83 | } 84 | 85 | func runList(ctx context.Context, streams command.Streams, hubClient *hub.Client, opts listOptions) error { 86 | organizations, err := hubClient.GetOrganizations(ctx) 87 | if err != nil { 88 | return err 89 | } 90 | return opts.Print(streams.Out(), organizations, printOrganizations) 91 | } 92 | 93 | func printOrganizations(out io.Writer, values interface{}) error { 94 | organizations := values.([]hub.Organization) 95 | 96 | tw := tabwriter.New(out, " ") 97 | 98 | for _, c := range defaultColumns { 99 | tw.Column(ansi.Header(c.header), len(c.header)) 100 | } 101 | 102 | tw.Line() 103 | 104 | for _, org := range organizations { 105 | for _, column := range defaultColumns { 106 | value, width := column.value(org) 107 | tw.Column(value, width) 108 | } 109 | tw.Line() 110 | } 111 | 112 | return tw.Flush() 113 | } 114 | -------------------------------------------------------------------------------- /.github/workflows/release-weekly-build.yml: -------------------------------------------------------------------------------- 1 | name: Release and Weekly Build 2 | on: 3 | schedule: 4 | - cron: "0 0 * * SUN" 5 | workflow_dispatch: 6 | inputs: 7 | branch: 8 | description: 'Branch' 9 | required: true 10 | default: 'main' 11 | tag: 12 | description: 'Release Tag' 13 | 14 | jobs: 15 | lint: 16 | name: Lint 17 | timeout-minutes: 10 18 | runs-on: ubuntu-latest 19 | env: 20 | GO111MODULE: "on" 21 | steps: 22 | - name: Set up Go 1.22 23 | uses: actions/setup-go@v1 24 | with: 25 | go-version: 1.22 26 | id: go 27 | 28 | - name: Checkout code into the Go module directory 29 | uses: actions/checkout@v2 30 | 31 | - name: Check license headers 32 | run: make validate 33 | 34 | # - name: Run golangci-lint 35 | # run: make lint 36 | 37 | build: 38 | name: Cross compile 39 | timeout-minutes: 10 40 | runs-on: ubuntu-latest 41 | env: 42 | GO111MODULE: "on" 43 | steps: 44 | - name: Set up Go 1.22 45 | uses: actions/setup-go@v1 46 | with: 47 | go-version: 1.22 48 | id: go 49 | 50 | - name: Checkout code into the Go module directory 51 | uses: actions/checkout@v2 52 | 53 | - name: Unit test 54 | run: make test-unit 55 | 56 | - name: Cross compile 57 | run: make TAG_NAME=${{ github.event.inputs.tag }} package-cross 58 | 59 | - name: Upload binary artifact 60 | uses: actions/upload-artifact@v2 61 | with: 62 | name: hub-tool-packages 63 | path: ./dist/* 64 | 65 | test: 66 | name: Native e2e tests 67 | timeout-minutes: 10 68 | runs-on: ${{ matrix.os }} 69 | needs: [build] 70 | strategy: 71 | matrix: 72 | os: [windows-latest, macos-latest, ubuntu-latest] 73 | defaults: 74 | run: 75 | shell: bash 76 | env: 77 | GO111MODULE: "on" 78 | GITHUB_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} 79 | 80 | steps: 81 | - name: Set up Go 1.22 82 | uses: actions/setup-go@v2 83 | with: 84 | go-version: 1.22 85 | id: go 86 | 87 | - name: Checkout code into the Go module directory 88 | uses: actions/checkout@v2 89 | with: 90 | ref: ${{ github.event.inputs.branch }} 91 | 92 | - name: Download artifacts 93 | uses: actions/download-artifact@v2 94 | with: 95 | path: dist 96 | 97 | - name: Extract platform binary 98 | run: mv dist/hub-tool-packages/* dist/ && make -f builder.Makefile ci-extract 99 | 100 | # - name: Run e2e tests 101 | # env: 102 | # E2E_HUB_USERNAME: ${{ secrets.E2E_HUB_USERNAME }} 103 | # E2E_HUB_TOKEN: ${{ secrets.E2E_HUB_TOKEN }} 104 | # run: make TAG_NAME=${{ github.event.inputs.tag }} e2e 105 | 106 | release: 107 | name: Do GitHub release 108 | timeout-minutes: 30 109 | runs-on: ubuntu-latest 110 | needs: [lint, build, test] 111 | if: ${{ github.event.inputs.tag != '' }} # don't release if no tag is specified 112 | steps: 113 | - name: Download artifacts 114 | uses: actions/download-artifact@v2 115 | with: 116 | path: dist 117 | 118 | - name: Ship it 119 | uses: ncipollo/release-action@v1 120 | with: 121 | artifacts: "dist/*/*" 122 | prerelease: true 123 | draft: true 124 | token: ${{ secrets.GITHUB_TOKEN }} 125 | tag: ${{ github.event.inputs.tag }} 126 | -------------------------------------------------------------------------------- /internal/commands/token/inspect.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package token 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "strings" 23 | "time" 24 | 25 | "github.com/docker/cli/cli" 26 | "github.com/docker/cli/cli/command" 27 | "github.com/docker/go-units" 28 | "github.com/google/uuid" 29 | "github.com/spf13/cobra" 30 | 31 | "github.com/docker/hub-tool/internal/ansi" 32 | "github.com/docker/hub-tool/internal/format" 33 | "github.com/docker/hub-tool/internal/metrics" 34 | "github.com/docker/hub-tool/pkg/hub" 35 | ) 36 | 37 | const ( 38 | inspectName = "inspect" 39 | ) 40 | 41 | type inspectOptions struct { 42 | format.Option 43 | } 44 | 45 | func newInspectCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 46 | var opts inspectOptions 47 | cmd := &cobra.Command{ 48 | Use: inspectName + " [OPTIONS] TOKEN_UUID", 49 | Short: "Inspect a Personal Access Token", 50 | Args: cli.ExactArgs(1), 51 | DisableFlagsInUseLine: true, 52 | Annotations: map[string]string{ 53 | "sudo": "true", 54 | }, 55 | PreRun: func(cmd *cobra.Command, args []string) { 56 | metrics.Send(parent, inspectName) 57 | }, 58 | RunE: func(cmd *cobra.Command, args []string) error { 59 | return runInspect(streams, hubClient, opts, args[0]) 60 | }, 61 | } 62 | opts.AddFormatFlag(cmd.Flags()) 63 | return cmd 64 | } 65 | 66 | func runInspect(streams command.Streams, hubClient *hub.Client, opts inspectOptions, tokenUUID string) error { 67 | u, err := uuid.Parse(tokenUUID) 68 | if err != nil { 69 | return err 70 | } 71 | token, err := hubClient.GetToken(u.String()) 72 | if err != nil { 73 | return err 74 | } 75 | return opts.Print(streams.Out(), token, printInspectToken) 76 | } 77 | 78 | func printInspectToken(out io.Writer, value interface{}) error { 79 | token := value.(*hub.Token) 80 | 81 | fmt.Fprintf(out, ansi.Title("Token:")+"\n") 82 | fmt.Fprintf(out, ansi.Key("UUID:")+"\t%s\n", token.UUID) 83 | if token.Description != "" { 84 | fmt.Fprintf(out, ansi.Key("Description:")+"\t%s\n", token.Description) 85 | } 86 | fmt.Fprintf(out, ansi.Key("Is Active:")+"\t%v\n", token.IsActive) 87 | fmt.Fprintf(out, ansi.Key("Created:")+"\t%s\n", fmt.Sprintf("%s ago", units.HumanDuration(time.Since(token.CreatedAt)))) 88 | fmt.Fprintf(out, ansi.Key("Last Used:")+"\t%s\n", getLastUsed(token.LastUsed)) 89 | fmt.Fprintf(out, ansi.Key("Creator User Agent:")+"\t%s\n", token.CreatorUA) 90 | fmt.Fprintf(out, ansi.Key("Creator IP:")+"\t%s\n", token.CreatorIP) 91 | fmt.Fprintf(out, ansi.Key("Generated:")+"\t%s\n", getGeneratedBy(token)) 92 | return nil 93 | } 94 | 95 | func getLastUsed(t time.Time) string { 96 | if t.IsZero() { 97 | return "Never" 98 | } 99 | return fmt.Sprintf("%s ago", units.HumanDuration(time.Since(t))) 100 | } 101 | 102 | func getGeneratedBy(token *hub.Token) string { 103 | if strings.Contains(token.CreatorUA, "hub-tool") { 104 | return "By hub-tool" 105 | } 106 | return "By user via Web UI" 107 | } 108 | -------------------------------------------------------------------------------- /internal/commands/repo/rm.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package repo 18 | 19 | import ( 20 | "bufio" 21 | "context" 22 | "fmt" 23 | "strings" 24 | 25 | "github.com/pkg/errors" 26 | 27 | "github.com/docker/hub-tool/internal/errdef" 28 | 29 | "github.com/distribution/reference" 30 | "github.com/docker/cli/cli" 31 | "github.com/docker/cli/cli/command" 32 | "github.com/spf13/cobra" 33 | 34 | "github.com/docker/hub-tool/internal/ansi" 35 | "github.com/docker/hub-tool/internal/metrics" 36 | "github.com/docker/hub-tool/pkg/hub" 37 | ) 38 | 39 | const ( 40 | rmName = "rm" 41 | ) 42 | 43 | type rmOptions struct { 44 | force bool 45 | } 46 | 47 | func newRmCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 48 | var opts rmOptions 49 | cmd := &cobra.Command{ 50 | Use: rmName + " [OPTIONS] NAMESPACE/REPOSITORY", 51 | Short: "Delete a repository", 52 | Args: cli.ExactArgs(1), 53 | DisableFlagsInUseLine: true, 54 | PreRun: func(cmd *cobra.Command, args []string) { 55 | metrics.Send(parent, rmName) 56 | }, 57 | RunE: func(cmd *cobra.Command, args []string) error { 58 | err := runRm(cmd.Context(), streams, hubClient, opts, args[0]) 59 | if err == nil || errors.Is(err, errdef.ErrCanceled) { 60 | return nil 61 | } 62 | return err 63 | }, 64 | } 65 | cmd.Flags().BoolVarP(&opts.force, "force", "f", false, "Force deletion of the repository") 66 | return cmd 67 | } 68 | 69 | func runRm(ctx context.Context, streams command.Streams, hubClient *hub.Client, opts rmOptions, repository string) error { 70 | ref, err := reference.Parse(repository) 71 | if err != nil { 72 | return err 73 | } 74 | namedRef, ok := ref.(reference.Named) 75 | if !ok { 76 | return errors.New("invalid reference: repository not specified") 77 | } 78 | 79 | if !strings.Contains(repository, "/") { 80 | return fmt.Errorf("repository name must include username or organization name, example: hub-tool repo rm username/repository") 81 | } 82 | 83 | if !opts.force { 84 | _, count, err := hubClient.GetTags(namedRef.Name()) 85 | if err != nil { 86 | return err 87 | } 88 | fmt.Fprintln(streams.Out(), ansi.Warn(fmt.Sprintf("WARNING: You are about to permanently delete repository %q including %d tag(s)", namedRef.Name(), count))) 89 | fmt.Fprintln(streams.Out(), ansi.Warn(" This action is irreversible")) 90 | fmt.Fprintln(streams.Out(), ansi.Info("Enter the name of the repository to confirm deletion:"), namedRef.Name()) 91 | userIn := make(chan string, 1) 92 | go func() { 93 | reader := bufio.NewReader(streams.In()) 94 | input, _ := reader.ReadString('\n') 95 | userIn <- strings.ToLower(strings.TrimSpace(input)) 96 | }() 97 | input := "" 98 | select { 99 | case <-ctx.Done(): 100 | return errdef.ErrCanceled 101 | case input = <-userIn: 102 | } 103 | if input != namedRef.Name() { 104 | return fmt.Errorf("%q differs from your repository name, deletion aborted", input) 105 | } 106 | } 107 | 108 | if err := hubClient.RemoveRepository(namedRef.Name()); err != nil { 109 | return err 110 | } 111 | _, err = fmt.Fprintf(streams.Out(), "Repository %q was successfully deleted\n", repository) 112 | if err != nil { 113 | return err 114 | } 115 | return nil 116 | } 117 | -------------------------------------------------------------------------------- /internal/commands/token/list.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package token 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "time" 23 | 24 | "github.com/docker/cli/cli" 25 | "github.com/docker/cli/cli/command" 26 | "github.com/docker/go-units" 27 | "github.com/spf13/cobra" 28 | 29 | "github.com/docker/hub-tool/internal/ansi" 30 | "github.com/docker/hub-tool/internal/format" 31 | "github.com/docker/hub-tool/internal/format/tabwriter" 32 | "github.com/docker/hub-tool/internal/metrics" 33 | "github.com/docker/hub-tool/pkg/hub" 34 | ) 35 | 36 | const ( 37 | lsName = "ls" 38 | ) 39 | 40 | var ( 41 | defaultColumns = []column{ 42 | {"DESCRIPTION", func(t hub.Token) (string, int) { return t.Description, len(t.Description) }}, 43 | {"UUID", func(t hub.Token) (string, int) { return t.UUID.String(), len(t.UUID.String()) }}, 44 | {"LAST USED", func(t hub.Token) (string, int) { 45 | s := "Never" 46 | if !t.LastUsed.IsZero() { 47 | s = fmt.Sprintf("%s ago", units.HumanDuration(time.Since(t.LastUsed))) 48 | } 49 | return s, len(s) 50 | }}, 51 | {"CREATED", func(t hub.Token) (string, int) { 52 | s := units.HumanDuration(time.Since(t.CreatedAt)) 53 | return s, len(s) 54 | }}, 55 | {"ACTIVE", func(t hub.Token) (string, int) { 56 | s := fmt.Sprintf("%v", t.IsActive) 57 | return s, len(s) 58 | }}, 59 | } 60 | ) 61 | 62 | type column struct { 63 | header string 64 | value func(t hub.Token) (string, int) 65 | } 66 | 67 | type listOptions struct { 68 | format.Option 69 | all bool 70 | } 71 | 72 | func newListCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 73 | var opts listOptions 74 | cmd := &cobra.Command{ 75 | Use: lsName + " [OPTION]", 76 | Aliases: []string{"list"}, 77 | Short: "List all the Personal Access Tokens", 78 | Args: cli.NoArgs, 79 | DisableFlagsInUseLine: true, 80 | Annotations: map[string]string{ 81 | "sudo": "true", 82 | }, 83 | PreRun: func(cmd *cobra.Command, args []string) { 84 | metrics.Send(parent, lsName) 85 | }, 86 | RunE: func(_ *cobra.Command, args []string) error { 87 | return runList(streams, hubClient, opts) 88 | }, 89 | } 90 | cmd.Flags().BoolVar(&opts.all, "all", false, "Fetch all available tokens") 91 | opts.AddFormatFlag(cmd.Flags()) 92 | return cmd 93 | } 94 | 95 | func runList(streams command.Streams, hubClient *hub.Client, opts listOptions) error { 96 | if opts.all { 97 | if err := hubClient.Update(hub.WithAllElements()); err != nil { 98 | return err 99 | } 100 | } 101 | tokens, total, err := hubClient.GetTokens() 102 | if err != nil { 103 | return err 104 | } 105 | return opts.Print(streams.Out(), tokens, printTokens(total)) 106 | } 107 | 108 | func printTokens(total int) format.PrettyPrinter { 109 | return func(out io.Writer, values interface{}) error { 110 | tokens := values.([]hub.Token) 111 | tw := tabwriter.New(out, " ") 112 | for _, column := range defaultColumns { 113 | tw.Column(ansi.Header(column.header), len(column.header)) 114 | } 115 | 116 | tw.Line() 117 | for _, token := range tokens { 118 | for _, column := range defaultColumns { 119 | value, width := column.value(token) 120 | tw.Column(value, width) 121 | } 122 | tw.Line() 123 | } 124 | if err := tw.Flush(); err != nil { 125 | return err 126 | } 127 | 128 | if len(tokens) < total { 129 | fmt.Fprintln(out, ansi.Info(fmt.Sprintf("%v/%v listed, use --all flag to show all", len(tokens), total))) 130 | } 131 | return nil 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /pkg/credentials/store.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package credentials 18 | 19 | import ( 20 | "time" 21 | 22 | dockercredentials "github.com/docker/cli/cli/config/credentials" 23 | clitypes "github.com/docker/cli/cli/config/types" 24 | "gopkg.in/square/go-jose.v2/jwt" 25 | ) 26 | 27 | const ( 28 | hubToolKey = "hub-tool" 29 | hubToolTokenKey = "hub-tool-token" 30 | hubToolRefreshTokenKey = "hub-tool-refresh-token" 31 | expirationWindow = 1 * time.Minute 32 | ) 33 | 34 | // Store stores and retrieves user auth information 35 | // form the keystore 36 | type Store interface { 37 | GetAuth() (*Auth, error) 38 | Store(auth Auth) error 39 | Erase() error 40 | } 41 | 42 | // Auth represents user authentication 43 | type Auth struct { 44 | Username string 45 | Password string 46 | // Token is the 2FA token 47 | Token string 48 | // RefreshToken is used to refresh the token when 49 | // it expires 50 | RefreshToken string 51 | } 52 | 53 | // TokenExpired returns true if the token is malformed or is expired, 54 | // true otherwise 55 | func (a *Auth) TokenExpired() bool { 56 | parsedToken, err := jwt.ParseSigned(a.Token) 57 | if err != nil { 58 | return true 59 | } 60 | 61 | out := jwt.Claims{} 62 | if err := parsedToken.UnsafeClaimsWithoutVerification(&out); err != nil { 63 | return true 64 | } 65 | if err := out.ValidateWithLeeway(jwt.Expected{Time: time.Now().Add(expirationWindow)}, 0); err != nil { 66 | return true 67 | } 68 | 69 | return false 70 | } 71 | 72 | type store struct { 73 | s dockercredentials.Store 74 | } 75 | 76 | // NewStore creates a new credentials store 77 | func NewStore(provider func(string) dockercredentials.Store) Store { 78 | return &store{ 79 | s: provider(hubToolKey), 80 | } 81 | } 82 | 83 | func (s *store) GetAuth() (*Auth, error) { 84 | auth, err := s.s.Get(hubToolKey) 85 | if err != nil { 86 | return nil, err 87 | } 88 | token, err := s.s.Get(hubToolTokenKey) 89 | if err != nil { 90 | return nil, err 91 | } 92 | refreshToken, err := s.s.Get(hubToolRefreshTokenKey) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | return &Auth{ 98 | Username: auth.Username, 99 | Password: auth.Password, 100 | Token: token.IdentityToken, 101 | RefreshToken: refreshToken.IdentityToken, 102 | }, nil 103 | } 104 | 105 | func (s *store) Store(auth Auth) error { 106 | if err := s.s.Store(clitypes.AuthConfig{ 107 | Username: auth.Username, 108 | IdentityToken: auth.Token, 109 | ServerAddress: hubToolTokenKey, 110 | }); err != nil { 111 | return err 112 | } 113 | if err := s.s.Store((clitypes.AuthConfig{ 114 | Username: auth.Username, 115 | IdentityToken: auth.RefreshToken, 116 | ServerAddress: hubToolRefreshTokenKey, 117 | })); err != nil { 118 | return err 119 | } 120 | return s.s.Store(clitypes.AuthConfig{ 121 | Username: auth.Username, 122 | Password: auth.Password, 123 | ServerAddress: hubToolKey, 124 | }) 125 | } 126 | 127 | func (s *store) exists(serverAddress string) (bool, error) { 128 | authConfig, err := s.s.Get(serverAddress) 129 | if err != nil { 130 | return false, err 131 | } 132 | 133 | if (clitypes.AuthConfig{}) == authConfig { 134 | return false, nil 135 | } 136 | 137 | return true, nil 138 | } 139 | 140 | func (s *store) Erase() error { 141 | if err := s.s.Erase(hubToolKey); err != nil { 142 | if found, findErr := s.exists(hubToolKey); findErr == nil && !found { 143 | return nil 144 | } 145 | return err 146 | } 147 | if err := s.s.Erase(hubToolRefreshTokenKey); err != nil { 148 | return err 149 | } 150 | return s.s.Erase(hubToolTokenKey) 151 | } 152 | -------------------------------------------------------------------------------- /pkg/hub/teams.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import ( 20 | "context" 21 | "encoding/json" 22 | "fmt" 23 | "net/http" 24 | "net/url" 25 | "sort" 26 | 27 | "golang.org/x/sync/errgroup" 28 | ) 29 | 30 | const ( 31 | //GroupsURL path to the Hub API listing the groups in an organization 32 | GroupsURL = "/v2/orgs/%s/groups/" 33 | ) 34 | 35 | // Team represents a hub group in an organization 36 | type Team struct { 37 | Name string 38 | Description string 39 | Members []Member 40 | } 41 | 42 | // GetTeams lists all the teams in an organization 43 | func (c *Client) GetTeams(organization string) ([]Team, error) { 44 | u, err := url.Parse(c.domain + fmt.Sprintf(GroupsURL, organization)) 45 | if err != nil { 46 | return nil, err 47 | } 48 | q := url.Values{} 49 | q.Add("page_size", fmt.Sprintf("%v", itemsPerPage)) 50 | q.Add("page", "1") 51 | u.RawQuery = q.Encode() 52 | 53 | teams, next, err := c.getTeamsPage(u.String(), organization) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | for next != "" { 59 | pageTeams, n, err := c.getTeamsPage(next, organization) 60 | if err != nil { 61 | return nil, err 62 | } 63 | next = n 64 | teams = append(teams, pageTeams...) 65 | } 66 | 67 | return teams, nil 68 | } 69 | 70 | // GetTeamsCount returns the number of teams in an organization 71 | func (c *Client) GetTeamsCount(organization string) (int, error) { 72 | u, err := url.Parse(c.domain + fmt.Sprintf(GroupsURL, organization)) 73 | if err != nil { 74 | return 0, err 75 | } 76 | q := url.Values{} 77 | q.Add("page_size", "1") 78 | q.Add("page", "1") 79 | u.RawQuery = q.Encode() 80 | 81 | req, err := http.NewRequest("GET", u.String(), nil) 82 | if err != nil { 83 | return 0, err 84 | } 85 | response, err := c.doRequest(req, withHubToken(c.token)) 86 | if err != nil { 87 | return 0, err 88 | } 89 | var hubResponse hubGroupResponse 90 | if err := json.Unmarshal(response, &hubResponse); err != nil { 91 | return 0, err 92 | } 93 | return hubResponse.Count, nil 94 | } 95 | 96 | func (c *Client) getTeamsPage(url, organization string) ([]Team, string, error) { 97 | req, err := http.NewRequest("GET", url, nil) 98 | if err != nil { 99 | return nil, "", err 100 | } 101 | response, err := c.doRequest(req, withHubToken(c.token)) 102 | if err != nil { 103 | return nil, "", err 104 | } 105 | var hubResponse hubGroupResponse 106 | if err := json.Unmarshal(response, &hubResponse); err != nil { 107 | return nil, "", err 108 | } 109 | var teams []Team 110 | eg, _ := errgroup.WithContext(context.Background()) 111 | for _, result := range hubResponse.Results { 112 | result := result 113 | eg.Go(func() error { 114 | members, err := c.GetMembersPerTeam(organization, result.Name) 115 | if err != nil { 116 | return err 117 | } 118 | team := Team{ 119 | Name: result.Name, 120 | Description: result.Description, 121 | Members: members, 122 | } 123 | teams = append(teams, team) 124 | return nil 125 | }) 126 | } 127 | 128 | if err := eg.Wait(); err != nil { 129 | return []Team{}, "", err 130 | } 131 | 132 | sort.Slice(teams, func(i, j int) bool { 133 | return teams[i].Name < teams[j].Name 134 | }) 135 | 136 | return teams, hubResponse.Next, nil 137 | } 138 | 139 | type hubGroupResponse struct { 140 | Count int `json:"count"` 141 | Next string `json:"next,omitempty"` 142 | Previous string `json:"previous,omitempty"` 143 | Results []hubGroupResult `json:"results,omitempty"` 144 | } 145 | 146 | type hubGroupResult struct { 147 | Name string `json:"name"` 148 | Description string `json:"description"` 149 | ID int `json:"id"` 150 | } 151 | -------------------------------------------------------------------------------- /internal/commands/tag/inspect_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package tag 18 | 19 | import ( 20 | "bytes" 21 | "testing" 22 | "time" 23 | 24 | "github.com/opencontainers/image-spec/specs-go" 25 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 26 | "gotest.tools/v3/assert" 27 | "gotest.tools/v3/golden" 28 | ) 29 | 30 | func TestPrintImage(t *testing.T) { 31 | manifest := ocispec.Manifest{ 32 | Versioned: specs.Versioned{}, 33 | Config: ocispec.Descriptor{ 34 | MediaType: "mediatype/config", 35 | Digest: "sha256:beef", 36 | Size: 123, 37 | }, 38 | Layers: []ocispec.Descriptor{ 39 | { 40 | MediaType: "mediatype/layer", 41 | Digest: "sha256:c0ffee", 42 | Size: 456, 43 | }, 44 | }, 45 | Annotations: map[string]string{ 46 | "annotation1": "value1", 47 | "annotation2": "value2", 48 | }, 49 | } 50 | now := time.Now() 51 | config := ocispec.Image{ 52 | Created: &now, 53 | Author: "author", 54 | Platform: ocispec.Platform{Architecture: "arch", OS: "os"}, 55 | Config: ocispec.ImageConfig{ 56 | User: "user", 57 | ExposedPorts: map[string]struct{}{"80/tcp": {}}, 58 | Env: []string{"KEY=VALUE"}, 59 | Entrypoint: []string{"./entrypoint", "parameter"}, 60 | Cmd: []string{"./cmd", "parameter"}, 61 | Volumes: map[string]struct{}{"/volume": {}}, 62 | WorkingDir: "/workingdir", 63 | Labels: map[string]string{"label": "value"}, 64 | StopSignal: "SIGTERM", 65 | }, 66 | History: []ocispec.History{ 67 | // empty layer is ignored 68 | { 69 | Created: &now, 70 | CreatedBy: "created-by-ignored", 71 | Author: "author-ignored", 72 | Comment: "comment-ignored", 73 | EmptyLayer: true, 74 | }, 75 | { 76 | Created: &now, 77 | CreatedBy: "/bin/sh -c #(nop) apt-get install", 78 | Author: "author-history", 79 | Comment: "comment-history", 80 | EmptyLayer: false, 81 | }, 82 | }, 83 | } 84 | manifestDescriptor := ocispec.Descriptor{ 85 | MediaType: "mediatype/manifest", 86 | Digest: "sha256:abcdef", 87 | Size: 789, 88 | Platform: &ocispec.Platform{ 89 | Architecture: "arch", 90 | OS: "os", 91 | Variant: "variant", 92 | }, 93 | } 94 | image := Image{"image:latest", manifest, config, manifestDescriptor} 95 | 96 | out := bytes.NewBuffer(nil) 97 | err := printImage(out, &image) 98 | assert.NilError(t, err) 99 | golden.Assert(t, out.String(), "inspect-manifest.golden") 100 | } 101 | 102 | func TestPrintIndex(t *testing.T) { 103 | index := ocispec.Index{ 104 | Versioned: specs.Versioned{}, 105 | Manifests: []ocispec.Descriptor{ 106 | { 107 | MediaType: "mediatype/manifest", 108 | Digest: "sha256:abcdef", 109 | Annotations: nil, 110 | Platform: &ocispec.Platform{ 111 | Architecture: "arch", 112 | OS: "os", 113 | Variant: "variant", 114 | }, 115 | }, 116 | { 117 | MediaType: "mediatype/manifest", 118 | Digest: "sha256:beef", 119 | Annotations: nil, 120 | Platform: &ocispec.Platform{ 121 | Architecture: "arch2", 122 | OS: "os2", 123 | }, 124 | }, 125 | }, 126 | Annotations: map[string]string{ 127 | "annotation1": "value1", 128 | "annotation2": "value2", 129 | }, 130 | } 131 | indexDescriptor := ocispec.Descriptor{ 132 | MediaType: "mediatype/ociindex", 133 | Digest: "sha256:abcdef", 134 | Size: 789, 135 | } 136 | image := Index{"image:latest", index, indexDescriptor} 137 | 138 | out := bytes.NewBuffer(nil) 139 | err := printManifestList(out, image) 140 | assert.NilError(t, err) 141 | golden.Assert(t, out.String(), "inspect-manifest-list.golden") 142 | } 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🧪 Docker Hub Tool 2 | 3 | > :warning: This tool is a Docker experiment to build a Docker Hub CLI tool. 4 | > The intention of this project is to get user feedback and then to add this 5 | > functionality to the Docker CLI. 6 | 7 | The Docker Hub Tool is a CLI tool for interacting with the 8 | [Docker Hub](https://hub.docker.com). 9 | It makes it easy to get information about your images from the terminal and to 10 | perform Hub maintenance tasks. 11 | 12 | ## Get started 13 | 14 | ### Prerequisites 15 | 16 | - [Docker](https://www.docker.com/products/docker-desktop) installed on your 17 | system 18 | - [A Docker Hub account](https://hub.docker.com) 19 | 20 | ### Install 21 | 22 | - Download the latest release for your platform from 23 | [here](https://github.com/docker/hub-tool/releases) 24 | - Extract the package and place the `hub-tool` binary somewhere in your `PATH` 25 | 26 | OR 27 | 28 | - Install from sources: `GO111MODULE=on go get github.com/docker/hub-tool` 29 | 30 | ### Login to Docker Hub 31 | 32 | Login to the [Docker Hub](https://hub.docker.com) using your username and 33 | password: 34 | 35 | ```console 36 | hub-tool login yourusername 37 | ``` 38 | 39 | > **Note:** When using a 40 | > [personal access token (PAT)](https://docs.docker.com/docker-hub/access-tokens/), 41 | > not all functionality will be available. 42 | 43 | ### Listing tags 44 | 45 | ```console 46 | TAG DIGEST STATUS LAST UPDATE LAST PUSHED LAST PULLED SIZE 47 | docker:stable-dind-rootless sha256:c96432c62569526fc710854c4d8441dae22907119c8987a5e82a2868bd509fd4 stale 3 days ago 3 days 96.55MB 48 | docker:stable-dind sha256:f998921d365053bf7e3f98794f6c23ca44e6809832d78105bc4d2da6bb8521ed stale 3 days ago 3 days 274.6MB 49 | docker:rc-git sha256:2c4980f5700c775634dd997484834ba0c6f63c5e2384d22c23c067afec8f2596 stale 3 days ago 3 days 302.6MB 50 | docker:rc-dind-rootless sha256:ed25cf41ad0d739e26e2416fb97858758f3cfd1c6345a11c2d386bff567e4060 stale 3 days ago 3 days 103.5MB 51 | docker:rc-dind sha256:a1e9f065ea4b31de9aeed07048cf820a64b8637262393b24a4216450da46b7d6 stale 3 days ago 3 days 288.9MB 52 | docker:rc sha256:f8ecea9dc16c9f6471448a78d3e101a3f864be71bfe3b8b27cac6df83f6f0970 stale 3 days ago 3 days 270.9MB 53 | ... 54 | 25/957 listed, use --all flag to show all 55 | ``` 56 | 57 | ## Contributing 58 | 59 | Docker wants to work with the community to make a tool that is useful and to 60 | ensure that its UX is good. Remember that this is an experiment with the goal of 61 | incorporating the learnings into the Docker CLI so it has some rough edges and 62 | it's not meant to be a final product. 63 | 64 | ### Feedback 65 | 66 | Please leave your feedback in the 67 | [issue tracker](https://github.com/docker/hub-tool/issues)! 68 | We'd love to know how you're using this tool and what features you'd like to see 69 | us add. 70 | 71 | ### Code 72 | 73 | At this stage of the project, we're mostly looking for feedback. We will accept 74 | pull requests but these should be limited to minor improvements and fixes. 75 | Anything larger should first be discussed as an issue. 76 | If you spot a bug or see a typo, please feel free to fix it by putting up a 77 | [pull request](https://github.com/docker/hub-tool/pulls)! 78 | 79 | ## Building 80 | 81 | ### Prerequisites 82 | 83 | - [Docker](https://www.docker.com/products/docker-desktop) 84 | - `make` 85 | 86 | ### Compiling 87 | 88 | To build for your current platform, simply run `make` and the tool will be 89 | output into the `./bin` directory: 90 | 91 | ```console 92 | $ make 93 | docker build --build-arg GO_VERSION=1.16.3 --build-arg ALPINE_VERSION=3.12.0 --build-arg GOLANGCI_LINT_VERSION=v1.31.0-alpine --build-arg TAG_NAME= --build-arg GOTESTSUM_VERSION=0.5.2 --build-arg BINARY_NAME=hub-tool --build-arg BINARY=hub-tool . \ 94 | --output type=local,dest=./bin \ 95 | --platform local \ 96 | --target hub 97 | [+] Building 3.7s (6/13) 98 | ... 99 | => => copying files 22.10MB 100 | 101 | $ ls bin/ 102 | hub-tool 103 | ``` 104 | -------------------------------------------------------------------------------- /internal/login/login.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package login 18 | 19 | import ( 20 | "bufio" 21 | "context" 22 | "fmt" 23 | "io" 24 | "os" 25 | "runtime" 26 | "strings" 27 | 28 | "github.com/docker/cli/cli/command" 29 | dockerstreams "github.com/docker/cli/cli/streams" 30 | "github.com/moby/term" 31 | "github.com/pkg/errors" 32 | 33 | "github.com/docker/hub-tool/internal/ansi" 34 | "github.com/docker/hub-tool/internal/errdef" 35 | "github.com/docker/hub-tool/pkg/credentials" 36 | "github.com/docker/hub-tool/pkg/hub" 37 | ) 38 | 39 | // RunLogin logs the user and asks for the 2FA code if needed 40 | func RunLogin(ctx context.Context, streams command.Streams, hubClient *hub.Client, store credentials.Store, candidateUsername string) error { 41 | username := candidateUsername 42 | if username == "" { 43 | var err error 44 | if username, err = readClearText(ctx, streams, "Username: "); err != nil { 45 | return err 46 | } 47 | } 48 | password, err := readPassword(streams) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | token, refreshToken, err := Login(ctx, streams, hubClient, username, password) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | if err := hubClient.Update(hub.WithHubToken(token)); err != nil { 59 | return err 60 | } 61 | 62 | return store.Store(credentials.Auth{ 63 | Username: username, 64 | Password: password, 65 | Token: token, 66 | RefreshToken: refreshToken, 67 | }) 68 | } 69 | 70 | // Login runs login and optionnaly the 2FA 71 | func Login(ctx context.Context, streams command.Streams, hubClient *hub.Client, username string, password string) (string, string, error) { 72 | return hubClient.Login(username, password, func() (string, error) { 73 | return readClearText(ctx, streams, "2FA required, please provide the 6 digit code: ") 74 | }) 75 | } 76 | 77 | func readClearText(ctx context.Context, streams command.Streams, prompt string) (string, error) { 78 | userIn := make(chan string, 1) 79 | go func() { 80 | fmt.Fprint(streams.Out(), ansi.Info(prompt)) 81 | reader := bufio.NewReader(streams.In()) 82 | input, _ := reader.ReadString('\n') 83 | userIn <- strings.TrimSpace(input) 84 | }() 85 | input := "" 86 | select { 87 | case <-ctx.Done(): 88 | return "", errdef.ErrCanceled 89 | case input = <-userIn: 90 | } 91 | return input, nil 92 | } 93 | 94 | func readPassword(streams command.Streams) (string, error) { 95 | in := streams.In() 96 | // On Windows, force the use of the regular OS stdin stream. Fixes #14336/#14210 97 | if runtime.GOOS == "windows" { 98 | in = dockerstreams.NewIn(os.Stdin) 99 | } 100 | 101 | // Some links documenting this: 102 | // - https://code.google.com/archive/p/mintty/issues/56 103 | // - https://github.com/docker/docker/issues/15272 104 | // - https://mintty.github.io/ (compatibility) 105 | // Linux will hit this if you attempt `cat | docker login`, and Windows 106 | // will hit this if you attempt docker login from mintty where stdin 107 | // is a pipe, not a character based console. 108 | if !streams.In().IsTerminal() { 109 | return "", errors.Errorf("cannot perform an interactive login from a non TTY device") 110 | } 111 | 112 | oldState, err := term.SaveState(in.FD()) 113 | if err != nil { 114 | return "", err 115 | } 116 | fmt.Fprint(streams.Out(), ansi.Info("Password: ")) 117 | if err := term.DisableEcho(in.FD(), oldState); err != nil { 118 | return "", err 119 | } 120 | 121 | password := readInput(in, streams.Out()) 122 | fmt.Fprint(streams.Out(), "\n") 123 | 124 | if err := term.RestoreTerminal(in.FD(), oldState); err != nil { 125 | return "", err 126 | } 127 | if password == "" { 128 | return "", errors.Errorf("password required") 129 | } 130 | 131 | return password, nil 132 | } 133 | 134 | func readInput(in io.Reader, out io.Writer) string { 135 | reader := bufio.NewReader(in) 136 | line, _, err := reader.ReadLine() 137 | if err != nil { 138 | fmt.Fprintln(out, err.Error()) 139 | os.Exit(1) 140 | } 141 | return string(line) 142 | } 143 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Docker Hub Tool authors 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | include vars.mk 15 | export DOCKER_BUILDKIT=1 16 | 17 | DOCKER_BUILD:=docker buildx build 18 | 19 | BUILD_ARGS:=--build-arg GO_VERSION=$(GO_VERSION) \ 20 | --build-arg CLI_VERSION=$(CLI_VERSION) \ 21 | --build-arg ALPINE_VERSION=$(ALPINE_VERSION) \ 22 | --build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \ 23 | --build-arg TAG_NAME=$(TAG_NAME) \ 24 | --build-arg GOTESTSUM_VERSION=$(GOTESTSUM_VERSION) \ 25 | --build-arg BINARY_NAME=$(BINARY_NAME) \ 26 | --build-arg BINARY=$(BINARY) 27 | 28 | E2E_ENV:=--env E2E_HUB_USERNAME \ 29 | --env E2E_HUB_TOKEN \ 30 | --env E2E_TEST_NAME 31 | 32 | UNIX_PLATFORMS:=linux/amd64 linux/arm linux/arm64 darwin/amd64 darwin/arm64 33 | 34 | TMPDIR_WIN_PKG:=$(shell mktemp -d) 35 | 36 | .PHONY: all 37 | all: build 38 | 39 | .PHONY: build 40 | build: ## Build the tool in a container 41 | $(DOCKER_BUILD) $(BUILD_ARGS) . \ 42 | --output type=local,dest=./bin \ 43 | --platform local \ 44 | --target hub 45 | 46 | .PHONY: mod-tidy 47 | mod-tidy: ## Update go.mod and go.sum 48 | $(DOCKER_BUILD) $(BUILD_ARGS) . \ 49 | --output type=local,dest=. \ 50 | --platform local \ 51 | --target go-mod-tidy 52 | 53 | .PHONY: cross 54 | cross: ## Cross compile the tool binaries in a container 55 | $(DOCKER_BUILD) $(BUILD_ARGS) . \ 56 | --output type=local,dest=./bin \ 57 | --target cross 58 | 59 | .PHONY: package-cross 60 | package-cross: cross ## Package the cross compiled binaries in tarballs for *nix and a zip for Windows 61 | mkdir -p dist 62 | $(foreach plat,$(UNIX_PLATFORMS),$(DOCKER_BUILD) $(BUILD_ARGS) . \ 63 | --platform $(plat) \ 64 | --output type=tar,dest=- \ 65 | --target package | gzip -9 > dist/$(BINARY_NAME)-$(subst /,-,$(plat)).tar.gz ;) 66 | cp bin/$(BINARY_NAME)_windows_amd64.exe $(TMPDIR_WIN_PKG)/$(BINARY_NAME).exe 67 | rm -f dist/$(BINARY_NAME)-windows-amd64.zip && zip dist/$(BINARY_NAME)-windows-amd64.zip -j packaging/LICENSE $(TMPDIR_WIN_PKG)/$(BINARY_NAME).exe 68 | cp bin/$(BINARY_NAME)_windows_arm64.exe $(TMPDIR_WIN_PKG)/$(BINARY_NAME).exe 69 | rm -f dist/$(BINARY_NAME)-windows-arm64.zip && zip dist/$(BINARY_NAME)-windows-arm64.zip -j packaging/LICENSE $(TMPDIR_WIN_PKG)/$(BINARY_NAME).exe 70 | rm -r $(TMPDIR_WIN_PKG) 71 | 72 | .PHONY: install 73 | install: build ## Install the tool to your /usr/local/bin/ 74 | cp bin/$(BINARY_NAME) /usr/local/bin/$(BINARY) 75 | 76 | .PHONY: test ## Run unit tests then end-to-end tests 77 | test: test-unit e2e 78 | 79 | .PHONY: e2e-build 80 | e2e-build: 81 | $(DOCKER_BUILD) $(BUILD_ARGS) . --target e2e -t $(BINARY_NAME):e2e 82 | 83 | .PHONY: e2e 84 | e2e: e2e-build ## Run the end-to-end tests 85 | docker run $(E2E_ENV) --rm \ 86 | -v /var/run/docker.sock:/var/run/docker.sock \ 87 | -v $(shell go env GOCACHE):/root/.cache/go-build \ 88 | -v $(shell go env GOMODCACHE):/go/pkg/mod \ 89 | $(BINARY_NAME):e2e 90 | 91 | .PHONY: test-unit-build 92 | test-unit-build: 93 | $(DOCKER_BUILD) $(BUILD_ARGS) . --target test-unit -t $(BINARY_NAME):test-unit 94 | 95 | .PHONY: test-unit 96 | test-unit: test-unit-build ## Run unit tests 97 | docker run --rm \ 98 | -v $(shell go env GOCACHE):/root/.cache/go-build \ 99 | -v $(shell go env GOMODCACHE):/go/pkg/mod \ 100 | $(BINARY_NAME):test-unit 101 | 102 | .PHONY: lint 103 | lint: ## Run the go linter 104 | @$(DOCKER_BUILD) $(BUILD_ARGS) . --target lint 105 | 106 | .PHONY: validate-headers 107 | validate-headers: ## Validate files license header 108 | @$(DOCKER_BUILD) $(BUILD_ARGS) . --target validate-headers 109 | 110 | .PHONY: validate-go-mod 111 | validate-go-mod: ## Validate go.mod and go.sum are up-to-date 112 | @$(DOCKER_BUILD) $(BUILD_ARGS) . --target check-go-mod 113 | 114 | .PHONY: validate 115 | validate: validate-go-mod validate-headers ## Validate sources 116 | 117 | .PHONY: help 118 | help: ## Show help 119 | @echo Please specify a build target. The choices are: 120 | @grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":| ## "}; {printf "\033[36m%-30s\033[0m %s\n", $$2, $$NF}' 121 | -------------------------------------------------------------------------------- /internal/commands/repo/list.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package repo 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "time" 23 | 24 | "github.com/docker/cli/cli" 25 | "github.com/docker/cli/cli/command" 26 | "github.com/docker/go-units" 27 | "github.com/spf13/cobra" 28 | 29 | "github.com/docker/hub-tool/internal/ansi" 30 | "github.com/docker/hub-tool/internal/format" 31 | "github.com/docker/hub-tool/internal/format/tabwriter" 32 | "github.com/docker/hub-tool/internal/metrics" 33 | "github.com/docker/hub-tool/pkg/hub" 34 | ) 35 | 36 | const ( 37 | listName = "ls" 38 | ) 39 | 40 | var ( 41 | defaultColumns = []column{ 42 | {"REPOSITORY", func(r hub.Repository) (string, int) { 43 | return ansi.Link(fmt.Sprintf("https://hub.docker.com/repository/docker/%s", r.Name), r.Name), len(r.Name) 44 | }}, 45 | {"DESCRIPTION", func(r hub.Repository) (string, int) { return r.Description, len(r.Description) }}, 46 | {"LAST UPDATE", func(r hub.Repository) (string, int) { 47 | if r.LastUpdated.Nanosecond() == 0 { 48 | return "", 0 49 | } 50 | s := fmt.Sprintf("%s ago", units.HumanDuration(time.Since(r.LastUpdated))) 51 | return s, len(s) 52 | }}, 53 | {"PULLS", func(r hub.Repository) (string, int) { 54 | s := fmt.Sprintf("%d", r.PullCount) 55 | return s, len(s) 56 | }}, 57 | {"STARS", func(r hub.Repository) (string, int) { 58 | s := fmt.Sprintf("%d", r.StarCount) 59 | return s, len(s) 60 | }}, 61 | {"PRIVATE", func(r hub.Repository) (string, int) { 62 | s := fmt.Sprintf("%v", r.IsPrivate) 63 | return s, len(s) 64 | }}, 65 | } 66 | ) 67 | 68 | type column struct { 69 | header string 70 | value func(t hub.Repository) (string, int) 71 | } 72 | 73 | type listOptions struct { 74 | format.Option 75 | all bool 76 | } 77 | 78 | func newListCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 79 | var opts listOptions 80 | cmd := &cobra.Command{ 81 | Use: listName + " [OPTIONS] [ORGANIZATION]", 82 | Aliases: []string{"list"}, 83 | Short: "List all the repositories from your account or an organization", 84 | Args: cli.RequiresMaxArgs(1), 85 | DisableFlagsInUseLine: true, 86 | PreRun: func(cmd *cobra.Command, args []string) { 87 | metrics.Send(parent, listName) 88 | }, 89 | RunE: func(cmd *cobra.Command, args []string) error { 90 | return runList(streams, hubClient, opts, args) 91 | }, 92 | } 93 | cmd.Flags().BoolVar(&opts.all, "all", false, "Fetch all available repositories") 94 | opts.AddFormatFlag(cmd.Flags()) 95 | return cmd 96 | } 97 | 98 | func runList(streams command.Streams, hubClient *hub.Client, opts listOptions, args []string) error { 99 | account := hubClient.AuthConfig.Username 100 | if opts.all { 101 | if err := hubClient.Update(hub.WithAllElements()); err != nil { 102 | return err 103 | } 104 | } 105 | if len(args) > 0 { 106 | account = args[0] 107 | } 108 | repositories, total, err := hubClient.GetRepositories(account) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | return opts.Print(streams.Out(), repositories, printRepositories(total)) 114 | } 115 | 116 | func printRepositories(total int) format.PrettyPrinter { 117 | return func(out io.Writer, values interface{}) error { 118 | repositories := values.([]hub.Repository) 119 | tw := tabwriter.New(out, " ") 120 | 121 | for _, column := range defaultColumns { 122 | tw.Column(ansi.Header(column.header), len(column.header)) 123 | } 124 | 125 | tw.Line() 126 | 127 | for _, repository := range repositories { 128 | for _, column := range defaultColumns { 129 | value, width := column.value(repository) 130 | tw.Column(value, width) 131 | } 132 | tw.Line() 133 | } 134 | if err := tw.Flush(); err != nil { 135 | return err 136 | } 137 | 138 | if len(repositories) < total { 139 | fmt.Fprintln(out, ansi.Info(fmt.Sprintf("%v/%v listed, use --all flag to show all", len(repositories), total))) 140 | } 141 | return nil 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/docker/hub-tool 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/cli/cli v1.14.0 7 | github.com/containerd/containerd v1.7.13 8 | github.com/distribution/reference v0.5.0 9 | github.com/docker/cli v25.0.3+incompatible 10 | github.com/docker/compose-cli v1.0.35 11 | github.com/docker/docker v25.0.3+incompatible 12 | github.com/docker/go-units v0.5.0 13 | github.com/google/uuid v1.6.0 14 | github.com/mattn/go-isatty v0.0.14 15 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d 16 | github.com/moby/term v0.5.0 17 | github.com/opencontainers/image-spec v1.1.0 18 | github.com/pkg/errors v0.9.1 19 | github.com/sirupsen/logrus v1.9.3 20 | github.com/spf13/cobra v1.8.0 21 | github.com/spf13/pflag v1.0.5 22 | golang.org/x/sync v0.6.0 23 | gopkg.in/square/go-jose.v2 v2.6.0 24 | gotest.tools/v3 v3.5.1 25 | ) 26 | 27 | require ( 28 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect 29 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 30 | github.com/Microsoft/go-winio v0.6.1 // indirect 31 | github.com/Microsoft/hcsshim v0.11.4 // indirect 32 | github.com/Shopify/logrus-bugsnag v0.0.0-20230117174420-439a4b8ba167 // indirect 33 | github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 // indirect 34 | github.com/beorn7/perks v1.0.1 // indirect 35 | github.com/briandowns/spinner v1.11.1 // indirect 36 | github.com/bugsnag/bugsnag-go v1.5.3 // indirect 37 | github.com/cenkalti/backoff v2.2.1+incompatible // indirect 38 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 39 | github.com/cli/safeexec v1.0.0 // indirect 40 | github.com/cloudflare/cfssl v1.4.1 // indirect 41 | github.com/containerd/log v0.1.0 // indirect 42 | github.com/docker/compose/v2 v2.2.0 // indirect 43 | github.com/docker/distribution v2.8.3+incompatible // indirect 44 | github.com/docker/docker-credential-helpers v0.7.0 // indirect 45 | github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect 46 | github.com/docker/go-connections v0.4.0 // indirect 47 | github.com/docker/go-metrics v0.0.1 // indirect 48 | github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect 49 | github.com/fatih/color v1.9.0 // indirect 50 | github.com/felixge/httpsnoop v1.0.3 // indirect 51 | github.com/fvbommel/sortorder v1.0.2 // indirect 52 | github.com/go-logr/logr v1.2.4 // indirect 53 | github.com/go-logr/stdr v1.2.2 // indirect 54 | github.com/go-sql-driver/mysql v1.5.0 // indirect 55 | github.com/gofrs/uuid v3.3.0+incompatible // indirect 56 | github.com/gogo/protobuf v1.3.2 // indirect 57 | github.com/golang/protobuf v1.5.3 // indirect 58 | github.com/google/go-cmp v0.5.9 // indirect 59 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 60 | github.com/gorilla/mux v1.8.0 // indirect 61 | github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect 62 | github.com/hashicorp/go-version v1.6.0 // indirect 63 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 64 | github.com/jinzhu/gorm v1.9.16 // indirect 65 | github.com/klauspost/compress v1.16.0 // indirect 66 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 67 | github.com/mattn/go-colorable v0.1.11 // indirect 68 | github.com/mattn/go-runewidth v0.0.10 // indirect 69 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 70 | github.com/miekg/pkcs11 v1.1.1 // indirect 71 | github.com/moby/locker v1.0.1 // indirect 72 | github.com/moby/sys/sequential v0.5.0 // indirect 73 | github.com/morikuni/aec v1.0.0 // indirect 74 | github.com/muesli/termenv v0.8.1 // indirect 75 | github.com/opencontainers/go-digest v1.0.0 // indirect 76 | github.com/prometheus/client_golang v1.14.0 // indirect 77 | github.com/prometheus/client_model v0.3.0 // indirect 78 | github.com/prometheus/common v0.37.0 // indirect 79 | github.com/prometheus/procfs v0.8.0 // indirect 80 | github.com/rivo/uniseg v0.2.0 // indirect 81 | github.com/theupdateframework/notary v0.6.1 // indirect 82 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0 // indirect 83 | go.opentelemetry.io/otel v1.19.0 // indirect 84 | go.opentelemetry.io/otel/metric v1.19.0 // indirect 85 | go.opentelemetry.io/otel/trace v1.19.0 // indirect 86 | golang.org/x/crypto v0.14.0 // indirect 87 | golang.org/x/mod v0.11.0 // indirect 88 | golang.org/x/net v0.17.0 // indirect 89 | golang.org/x/sys v0.13.0 // indirect 90 | golang.org/x/term v0.13.0 // indirect 91 | golang.org/x/tools v0.10.0 // indirect 92 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect 93 | google.golang.org/grpc v1.58.3 // indirect 94 | google.golang.org/protobuf v1.31.0 // indirect 95 | gopkg.in/dancannon/gorethink.v3 v3.0.5 // indirect 96 | gopkg.in/fatih/pool.v2 v2.0.0 // indirect 97 | gopkg.in/gorethink/gorethink.v3 v3.0.5 // indirect 98 | ) 99 | -------------------------------------------------------------------------------- /pkg/hub/members.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "net/http" 23 | "net/url" 24 | "time" 25 | ) 26 | 27 | const ( 28 | //MembersURL path to the Hub API listing the members in an organization 29 | MembersURL = "/v2/orgs/%s/members/" 30 | //MembersPerTeamURL path to the Hub API listing the members in a team 31 | MembersPerTeamURL = "/v2/orgs/%s/groups/%s/members/" 32 | ) 33 | 34 | // Member is a user part of an organization 35 | type Member struct { 36 | Username string `json:"username"` 37 | FullName string `json:"full_name"` 38 | } 39 | 40 | // GetMembers lists all the members in an organization 41 | func (c *Client) GetMembers(organization string) ([]Member, error) { 42 | u, err := url.Parse(c.domain + fmt.Sprintf(MembersURL, organization)) 43 | if err != nil { 44 | return nil, err 45 | } 46 | q := url.Values{} 47 | q.Add("page_size", fmt.Sprintf("%v", itemsPerPage)) 48 | q.Add("page", "1") 49 | u.RawQuery = q.Encode() 50 | 51 | members, next, err := c.getMembersPage(u.String()) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | for next != "" { 57 | pageMembers, n, err := c.getMembersPage(next) 58 | if err != nil { 59 | return nil, err 60 | } 61 | next = n 62 | members = append(members, pageMembers...) 63 | } 64 | 65 | return members, nil 66 | } 67 | 68 | // GetMembersCount return the number of members in an organization 69 | func (c *Client) GetMembersCount(organization string) (int, error) { 70 | u, err := url.Parse(c.domain + fmt.Sprintf(MembersURL, organization)) 71 | if err != nil { 72 | return 0, err 73 | } 74 | q := url.Values{} 75 | q.Add("page_size", "1") 76 | q.Add("page", "1") 77 | u.RawQuery = q.Encode() 78 | 79 | req, err := http.NewRequest("GET", u.String(), nil) 80 | if err != nil { 81 | return 0, err 82 | } 83 | response, err := c.doRequest(req, withHubToken(c.token)) 84 | if err != nil { 85 | return 0, err 86 | } 87 | var hubResponse hubMemberResponse 88 | if err := json.Unmarshal(response, &hubResponse); err != nil { 89 | return 0, err 90 | } 91 | return hubResponse.Count, nil 92 | } 93 | 94 | // GetMembersPerTeam returns the members of a team in an organization 95 | func (c *Client) GetMembersPerTeam(organization, team string) ([]Member, error) { 96 | u := c.domain + fmt.Sprintf(MembersPerTeamURL, organization, team) 97 | req, err := http.NewRequest("GET", u, nil) 98 | if err != nil { 99 | return nil, err 100 | } 101 | response, err := c.doRequest(req, withHubToken(c.token)) 102 | if err != nil { 103 | return nil, err 104 | } 105 | var members []Member 106 | if err := json.Unmarshal(response, &members); err != nil { 107 | return nil, err 108 | } 109 | return members, nil 110 | } 111 | 112 | func (c *Client) getMembersPage(url string) ([]Member, string, error) { 113 | req, err := http.NewRequest("GET", url, nil) 114 | if err != nil { 115 | return nil, "", err 116 | } 117 | response, err := c.doRequest(req, withHubToken(c.token)) 118 | if err != nil { 119 | return nil, "", err 120 | } 121 | var hubResponse hubMemberResponse 122 | if err := json.Unmarshal(response, &hubResponse); err != nil { 123 | return nil, "", err 124 | } 125 | var members []Member 126 | for _, result := range hubResponse.Results { 127 | member := Member{ 128 | Username: result.UserName, 129 | FullName: result.FullName, 130 | } 131 | members = append(members, member) 132 | } 133 | return members, hubResponse.Next, nil 134 | } 135 | 136 | type hubMemberResponse struct { 137 | Count int `json:"count"` 138 | Next string `json:"next,omitempty"` 139 | Previous string `json:"previous,omitempty"` 140 | Results []hubMemberResult `json:"results,omitempty"` 141 | } 142 | 143 | type hubMemberResult struct { 144 | UserName string `json:"username"` 145 | GravatarURL string `json:"gravatar_url"` 146 | FullName string `json:"full_name"` 147 | Company string `json:"company"` 148 | Type string `json:"type"` 149 | Location string `json:"location"` 150 | DateJoined time.Time `json:"date_joined"` 151 | ID string `json:"id"` 152 | ProfileURL string `json:"profile_url"` 153 | } 154 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:experimental 2 | 3 | 4 | # Copyright 2020 Docker Hub Tool authors 5 | 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | 18 | 19 | ARG GO_VERSION=1.22.0-alpine3.19 20 | ARG CLI_VERSION=20.10.2 21 | ARG ALPINE_VERSION=3.19.0 22 | ARG GOLANGCI_LINT_VERSION=v1.56.2-alpine 23 | 24 | #### 25 | # BUILDER 26 | #### 27 | FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION} AS builder 28 | WORKDIR /go/src/github.com/docker/hub-tool 29 | RUN apk add --no-cache \ 30 | bash \ 31 | git \ 32 | make 33 | 34 | # cache go vendoring 35 | COPY go.* ./ 36 | RUN --mount=type=cache,target=/go/pkg \ 37 | go mod download -x 38 | COPY . . 39 | 40 | #### 41 | # LINT-BASE 42 | #### 43 | FROM golangci/golangci-lint:${GOLANGCI_LINT_VERSION} AS lint-base 44 | 45 | #### 46 | # LINT 47 | #### 48 | FROM builder AS lint 49 | COPY --from=lint-base /usr/bin/golangci-lint /usr/bin/golangci-lint 50 | RUN --mount=type=cache,target=/root/.cache/go-build \ 51 | --mount=type=cache,target=/go/pkg \ 52 | --mount=type=cache,target=/root/.cache/golangci-lint \ 53 | make -f builder.Makefile lint 54 | 55 | #### 56 | # VALIDATE HEADERS 57 | #### 58 | FROM builder AS validate-headers 59 | RUN --mount=type=cache,target=/root/.cache/go-build \ 60 | --mount=type=cache,target=/go/pkg \ 61 | go install github.com/kunalkushwaha/ltag@latest && ./scripts/validate/fileheader 62 | 63 | #### 64 | # CHECK GO MOD 65 | #### 66 | FROM builder AS check-go-mod 67 | RUN scripts/validate/check-go-mod 68 | 69 | #### 70 | # GO MOD TIDY 71 | #### 72 | FROM builder as go-mod-tidy-run 73 | RUN --mount=type=cache,target=/root/.cache/go-build \ 74 | --mount=type=cache,target=/go/pkg \ 75 | go mod tidy 76 | 77 | FROM scratch AS go-mod-tidy 78 | COPY --from=go-mod-tidy-run /go/src/github.com/docker/hub-tool/go.mod / 79 | COPY --from=go-mod-tidy-run /go/src/github.com/docker/hub-tool/go.sum / 80 | 81 | #### 82 | # BUILD 83 | #### 84 | FROM builder AS build 85 | ARG TARGETOS 86 | ARG TARGETARCH 87 | RUN --mount=type=cache,target=/root/.cache/go-build \ 88 | --mount=type=cache,target=/go/pkg \ 89 | GOOS=${TARGETOS} \ 90 | GOARCH=${TARGETARCH} \ 91 | make -f builder.Makefile build 92 | 93 | #### 94 | # HUB 95 | #### 96 | FROM scratch AS hub 97 | ARG BINARY_NAME 98 | COPY --from=build /go/src/github.com/docker/hub-tool/bin/${BINARY_NAME} /${BINARY_NAME} 99 | 100 | #### 101 | # CROSS_BUILD 102 | #### 103 | FROM builder AS cross-build 104 | ARG TAG_NAME 105 | ENV TAG_NAME=$TAG_NAME 106 | RUN --mount=type=cache,target=/root/.cache/go-build \ 107 | --mount=type=cache,target=/go/pkg \ 108 | make -f builder.Makefile cross 109 | 110 | #### 111 | # CROSS 112 | #### 113 | FROM scratch AS cross 114 | COPY --from=cross-build /go/src/github.com/docker/hub-tool/bin/* / 115 | 116 | #### 117 | # PACKAGE 118 | #### 119 | FROM scratch AS package 120 | ARG BINARY_NAME 121 | ARG TARGETOS 122 | ARG TARGETARCH 123 | COPY --from=builder /go/src/github.com/docker/hub-tool/packaging/LICENSE /${BINARY_NAME}/LICENSE 124 | COPY --from=cross /${BINARY_NAME}_${TARGETOS}_${TARGETARCH} /${BINARY_NAME}/${BINARY_NAME} 125 | 126 | #### 127 | # GOTESTSUM 128 | #### 129 | FROM alpine:${ALPINE_VERSION} AS gotestsum 130 | RUN apk add --no-cache \ 131 | tar \ 132 | wget 133 | # install gotestsum 134 | WORKDIR /root 135 | ARG GOTESTSUM_VERSION=0.6.0 136 | RUN wget https://github.com/gotestyourself/gotestsum/releases/download/v${GOTESTSUM_VERSION}/gotestsum_${GOTESTSUM_VERSION}_linux_amd64.tar.gz -nv -O - | tar -xz 137 | 138 | #### 139 | # TEST-UNIT 140 | #### 141 | FROM builder AS test-unit 142 | ARG TAG_NAME 143 | ENV TAG_NAME=$TAG_NAME 144 | COPY --from=gotestsum /root/gotestsum /usr/local/bin/gotestsum 145 | CMD ["make", "-f", "builder.Makefile", "test-unit"] 146 | 147 | #### 148 | # DOWNLOAD 149 | #### 150 | FROM golang:${GO_VERSION} AS download 151 | COPY builder.Makefile vars.mk ./ 152 | RUN make -f builder.Makefile download 153 | 154 | #### 155 | # E2E 156 | #### 157 | FROM builder AS e2e 158 | ARG TAG_NAME 159 | ARG BINARY 160 | ENV TAG_NAME=$TAG_NAME 161 | ENV BINARY=$BINARY 162 | ENV DOCKER_CONFIG="/root/.docker" 163 | # install hub tool 164 | COPY --from=build /go/src/github.com/docker/hub-tool/bin/${BINARY} ./bin/${BINARY} 165 | RUN chmod +x ./bin/${BINARY} 166 | CMD ["make", "-f", "builder.Makefile", "e2e"] 167 | -------------------------------------------------------------------------------- /pkg/hub/repositories.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "net/http" 23 | "net/url" 24 | "time" 25 | ) 26 | 27 | const ( 28 | // RepositoriesURL is the Hub API base URL 29 | RepositoriesURL = "/v2/repositories/" 30 | ) 31 | 32 | // Repository represents a Docker Hub repository 33 | type Repository struct { 34 | Name string 35 | Description string 36 | LastUpdated time.Time 37 | PullCount int 38 | StarCount int 39 | IsPrivate bool 40 | } 41 | 42 | // GetRepositories lists all the repositories a user can access 43 | func (c *Client) GetRepositories(account string) ([]Repository, int, error) { 44 | if account == "" { 45 | account = c.account 46 | } 47 | repositoriesURL := fmt.Sprintf("%s%s%s", c.domain, RepositoriesURL, account) 48 | u, err := url.Parse(repositoriesURL) 49 | if err != nil { 50 | return nil, 0, err 51 | } 52 | q := url.Values{} 53 | q.Add("page_size", fmt.Sprintf("%v", itemsPerPage)) 54 | q.Add("page", "1") 55 | q.Add("ordering", "last_updated") 56 | u.RawQuery = q.Encode() 57 | 58 | repos, total, next, err := c.getRepositoriesPage(u.String(), account) 59 | if err != nil { 60 | return nil, 0, err 61 | } 62 | 63 | if c.fetchAllElements { 64 | for next != "" { 65 | pageRepos, _, n, err := c.getRepositoriesPage(next, account) 66 | if err != nil { 67 | return nil, 0, err 68 | } 69 | next = n 70 | repos = append(repos, pageRepos...) 71 | } 72 | } 73 | 74 | return repos, total, nil 75 | } 76 | 77 | // RemoveRepository removes a repository on Hub 78 | func (c *Client) RemoveRepository(repository string) error { 79 | repositoryURL := fmt.Sprintf("%s%s%s/", c.domain, RepositoriesURL, repository) 80 | req, err := http.NewRequest(http.MethodDelete, repositoryURL, nil) 81 | if err != nil { 82 | return err 83 | } 84 | _, err = c.doRequest(req, withHubToken(c.token)) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | return nil 90 | } 91 | 92 | func (c *Client) getRepositoriesPage(url, account string) ([]Repository, int, string, error) { 93 | req, err := http.NewRequest("GET", url, nil) 94 | if err != nil { 95 | return nil, 0, "", err 96 | } 97 | response, err := c.doRequest(req, withHubToken(c.token)) 98 | if err != nil { 99 | return nil, 0, "", err 100 | } 101 | var hubResponse hubRepositoryResponse 102 | if err := json.Unmarshal(response, &hubResponse); err != nil { 103 | return nil, 0, "", err 104 | } 105 | var repos []Repository 106 | for _, result := range hubResponse.Results { 107 | repo := Repository{ 108 | Name: fmt.Sprintf("%s/%s", account, result.Name), 109 | Description: result.Description, 110 | LastUpdated: result.LastUpdated, 111 | PullCount: result.PullCount, 112 | StarCount: result.StarCount, 113 | IsPrivate: result.IsPrivate, 114 | } 115 | repos = append(repos, repo) 116 | } 117 | return repos, hubResponse.Count, hubResponse.Next, nil 118 | } 119 | 120 | type hubRepositoryResponse struct { 121 | Count int `json:"count"` 122 | Next string `json:"next,omitempty"` 123 | Previous string `json:"previous,omitempty"` 124 | Results []hubRepositoryResult `json:"results,omitempty"` 125 | } 126 | 127 | type hubRepositoryResult struct { 128 | Name string `json:"name"` 129 | Namespace string `json:"namespace"` 130 | PullCount int `json:"pull_count"` 131 | StarCount int `json:"star_count"` 132 | RepositoryType RepositoryType `json:"repository_type"` 133 | CanEdit bool `json:"can_edit"` 134 | Description string `json:"description,omitempty"` 135 | IsAutomated bool `json:"is_automated"` 136 | IsMigrated bool `json:"is_migrated"` 137 | IsPrivate bool `json:"is_private"` 138 | LastUpdated time.Time `json:"last_updated"` 139 | Status int `json:"status"` 140 | User string `json:"user"` 141 | } 142 | 143 | // RepositoryType lists all the different repository types handled by the Docker Hub 144 | type RepositoryType string 145 | 146 | const ( 147 | //ImageType is the classic image type 148 | ImageType = RepositoryType("image") 149 | ) 150 | -------------------------------------------------------------------------------- /pkg/hub/ratelimiting.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import ( 20 | "encoding/base64" 21 | "encoding/json" 22 | "errors" 23 | "fmt" 24 | "io" 25 | "net/http" 26 | "strconv" 27 | "strings" 28 | ) 29 | 30 | // RateLimits ... 31 | type RateLimits struct { 32 | Limit *int `json:",omitempty"` 33 | LimitWindow *int `json:",omitempty"` 34 | Remaining *int `json:",omitempty"` 35 | RemainingWindow *int `json:",omitempty"` 36 | Source *string `json:",omitempty"` 37 | } 38 | 39 | var ( 40 | first = "https://auth.docker.io/token?service=registry.docker.io&scope=repository:ratelimitpreview/test:pull" 41 | second = "https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest" 42 | defaultValue = -1 43 | ) 44 | 45 | // SetURLs change the base urls used to check ratelimiting values 46 | func SetURLs(newFirst, newSecond string) { 47 | first = newFirst 48 | second = newSecond 49 | } 50 | 51 | // GetRateLimits returns the rate limits for the user 52 | func (c *Client) GetRateLimits() (*RateLimits, error) { 53 | token, err := tryGetToken(c) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | req, err := http.NewRequest("HEAD", second, nil) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | resp, err := c.doRawRequest(req, withHubToken(token)) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | limitHeader := resp.Header.Get("Ratelimit-Limit") 69 | remainingHeader := resp.Header.Get("Ratelimit-Remaining") 70 | source := resp.Header.Get("docker-Ratelimit-Source") 71 | 72 | if limitHeader == "" || remainingHeader == "" { 73 | return &RateLimits{ 74 | Limit: &defaultValue, 75 | LimitWindow: &defaultValue, 76 | Remaining: &defaultValue, 77 | RemainingWindow: &defaultValue, 78 | Source: &source, 79 | }, nil 80 | } 81 | 82 | limit, limitWindow, err := parseLimitHeader(limitHeader) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | remaining, remainingWindow, err := parseLimitHeader(remainingHeader) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | return &RateLimits{ 93 | Limit: &limit, 94 | LimitWindow: &limitWindow, 95 | Remaining: &remaining, 96 | RemainingWindow: &remainingWindow, 97 | Source: &source, 98 | }, nil 99 | } 100 | 101 | func tryGetToken(c *Client) (string, error) { 102 | token, err := c.getToken("", true) 103 | if err != nil { 104 | token, err = c.getToken(c.password, false) 105 | if err != nil { 106 | token, err = c.getToken(c.refreshToken, false) 107 | if err != nil { 108 | token, err = c.getToken(c.token, false) 109 | if err != nil { 110 | return "", err 111 | } 112 | } 113 | } 114 | } 115 | return token, nil 116 | } 117 | 118 | func (c *Client) getToken(password string, anonymous bool) (string, error) { 119 | req, err := http.NewRequest("GET", first, nil) 120 | if err != nil { 121 | return "", err 122 | } 123 | 124 | if !anonymous { 125 | req.Header.Add("Authorization", "Basic "+basicAuth(c.account, password)) 126 | } 127 | resp, err := c.doRawRequest(req) 128 | if err != nil { 129 | return "", err 130 | } 131 | if resp.Body != nil { 132 | defer resp.Body.Close() //nolint:errcheck 133 | } 134 | 135 | if resp.StatusCode != http.StatusOK { 136 | return "", errors.New("unable to get authorization token") 137 | } 138 | 139 | buf, err := io.ReadAll(resp.Body) 140 | if err != nil { 141 | return "", err 142 | } 143 | var t tokenResponse 144 | if err := json.Unmarshal(buf, &t); err != nil { 145 | return "", err 146 | } 147 | 148 | return t.Token, nil 149 | } 150 | 151 | func parseLimitHeader(value string) (int, int, error) { 152 | parts := strings.Split(value, ";") 153 | if len(parts) != 2 { 154 | return 0, 0, fmt.Errorf("bad limit header %s", value) 155 | } 156 | 157 | v, err := strconv.Atoi(parts[0]) 158 | if err != nil { 159 | return 0, 0, err 160 | } 161 | 162 | windowParts := strings.Split(parts[1], "=") 163 | if len(windowParts) != 2 { 164 | return 0, 0, fmt.Errorf("bad limit header %s", value) 165 | } 166 | w, err := strconv.Atoi(windowParts[1]) 167 | if err != nil { 168 | return 0, 0, err 169 | } 170 | 171 | return v, w, nil 172 | } 173 | 174 | func basicAuth(username, password string) string { 175 | auth := username + ":" + password 176 | return base64.StdEncoding.EncodeToString([]byte(auth)) 177 | } 178 | -------------------------------------------------------------------------------- /internal/commands/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package commands 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | "github.com/docker/cli/cli" 24 | "github.com/docker/cli/cli/command" 25 | log "github.com/sirupsen/logrus" 26 | "github.com/spf13/cobra" 27 | 28 | "github.com/docker/hub-tool/internal" 29 | "github.com/docker/hub-tool/internal/ansi" 30 | "github.com/docker/hub-tool/internal/commands/account" 31 | "github.com/docker/hub-tool/internal/commands/org" 32 | "github.com/docker/hub-tool/internal/commands/repo" 33 | "github.com/docker/hub-tool/internal/commands/tag" 34 | "github.com/docker/hub-tool/internal/commands/token" 35 | "github.com/docker/hub-tool/internal/login" 36 | "github.com/docker/hub-tool/pkg/credentials" 37 | "github.com/docker/hub-tool/pkg/hub" 38 | ) 39 | 40 | type options struct { 41 | showVersion bool 42 | trace bool 43 | verbose bool 44 | } 45 | 46 | var ( 47 | anonCmds = []string{"version", "help", "login", "logout"} 48 | ) 49 | 50 | // NewRootCmd returns the main command 51 | func NewRootCmd(streams command.Streams, hubClient *hub.Client, store credentials.Store, name string) *cobra.Command { 52 | var flags options 53 | cmd := &cobra.Command{ 54 | Use: name, 55 | Short: "Docker Hub Tool", 56 | Long: `A tool to manage your Docker Hub images`, 57 | Annotations: map[string]string{}, 58 | SilenceUsage: true, 59 | DisableFlagsInUseLine: true, 60 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 61 | if flags.trace { 62 | log.SetLevel(log.TraceLevel) 63 | } else if flags.verbose { 64 | log.SetLevel(log.DebugLevel) 65 | } 66 | if flags.showVersion { 67 | return nil 68 | } 69 | if contains(anonCmds, cmd.Name()) { 70 | return nil 71 | } 72 | 73 | ac, err := store.GetAuth() 74 | if err != nil { 75 | return err 76 | } 77 | 78 | if ac.Username == "" { 79 | log.Fatal(ansi.Error(`You need to be logged in to Docker Hub to use this tool. 80 | Please login to Docker Hub using the "hub-tool login" command.`)) 81 | } 82 | 83 | if cmd.Annotations["sudo"] == "true" { 84 | if err := tryLogin(cmd.Context(), streams, hubClient, ac, store); err != nil { 85 | return err 86 | } 87 | return nil 88 | } 89 | 90 | if ac.TokenExpired() { 91 | return tryLogin(cmd.Context(), streams, hubClient, ac, store) 92 | } 93 | return nil 94 | }, 95 | RunE: func(cmd *cobra.Command, args []string) error { 96 | if flags.showVersion { 97 | fmt.Fprintf(streams.Out(), "Docker Hub Tool %s, build %s\n", internal.Version, internal.GitCommit[:7]) 98 | return nil 99 | } 100 | return cmd.Help() 101 | }, 102 | } 103 | cmd.Flags().BoolVar(&flags.showVersion, "version", false, "Display the version of this tool") 104 | cmd.PersistentFlags().BoolVar(&flags.verbose, "verbose", false, "Print logs") 105 | cmd.PersistentFlags().BoolVar(&flags.trace, "trace", false, "Print trace logs") 106 | _ = cmd.PersistentFlags().MarkHidden("trace") 107 | 108 | cmd.AddCommand( 109 | newLoginCmd(streams, store, hubClient), 110 | newLogoutCmd(streams, store), 111 | account.NewAccountCmd(streams, hubClient), 112 | token.NewTokenCmd(streams, hubClient), 113 | org.NewOrgCmd(streams, hubClient), 114 | repo.NewRepoCmd(streams, hubClient), 115 | tag.NewTagCmd(streams, hubClient), 116 | newVersionCmd(streams), 117 | ) 118 | return cmd 119 | } 120 | 121 | func contains(haystack []string, needle string) bool { 122 | for _, v := range haystack { 123 | if needle == v { 124 | return true 125 | } 126 | } 127 | return false 128 | } 129 | 130 | func newVersionCmd(streams command.Streams) *cobra.Command { 131 | return &cobra.Command{ 132 | Use: "version", 133 | Short: "Version information about this tool", 134 | Args: cli.NoArgs, 135 | RunE: func(cmd *cobra.Command, _ []string) error { 136 | _, err := fmt.Fprintf(streams.Out(), "Version: %s\nGit commit: %s\n", internal.Version, internal.GitCommit) 137 | return err 138 | }, 139 | } 140 | } 141 | 142 | func tryLogin(ctx context.Context, streams command.Streams, hubClient *hub.Client, ac *credentials.Auth, store credentials.Store) error { 143 | token, refreshToken, err := login.Login(ctx, streams, hubClient, ac.Username, ac.Password) 144 | if err != nil { 145 | return err 146 | } 147 | if err := hubClient.Update(hub.WithHubToken(token)); err != nil { 148 | return err 149 | } 150 | 151 | return store.Store(credentials.Auth{ 152 | Username: ac.Username, 153 | Password: ac.Password, 154 | Token: token, 155 | RefreshToken: refreshToken, 156 | }) 157 | } 158 | -------------------------------------------------------------------------------- /internal/commands/account/info.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package account 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "time" 23 | 24 | "github.com/docker/cli/cli" 25 | "github.com/docker/cli/cli/command" 26 | "github.com/docker/go-units" 27 | "github.com/spf13/cobra" 28 | "golang.org/x/sync/errgroup" 29 | 30 | "github.com/docker/hub-tool/internal/ansi" 31 | "github.com/docker/hub-tool/internal/format" 32 | "github.com/docker/hub-tool/internal/metrics" 33 | "github.com/docker/hub-tool/pkg/hub" 34 | ) 35 | 36 | const ( 37 | infoName = "info" 38 | ) 39 | 40 | type infoOptions struct { 41 | format.Option 42 | } 43 | 44 | func newInfoCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 45 | var opts infoOptions 46 | cmd := &cobra.Command{ 47 | Use: infoName + " [OPTIONS] [ORGANIZATION]", 48 | Short: "Print the account information", 49 | Args: cli.RequiresMaxArgs(1), 50 | DisableFlagsInUseLine: true, 51 | Annotations: map[string]string{ 52 | "sudo": "true", 53 | }, 54 | PreRun: func(cmd *cobra.Command, args []string) { 55 | metrics.Send(parent, infoName) 56 | }, 57 | RunE: func(cmd *cobra.Command, args []string) error { 58 | if len(args) > 0 { 59 | return runOrgInfo(streams, hubClient, opts, args[0]) 60 | } 61 | return runUserInfo(streams, hubClient, opts) 62 | }, 63 | } 64 | opts.AddFormatFlag(cmd.Flags()) 65 | return cmd 66 | } 67 | 68 | func runOrgInfo(streams command.Streams, hubClient *hub.Client, opts infoOptions, orgName string) error { 69 | var ( 70 | org *hub.Account 71 | consumption *hub.Consumption 72 | ) 73 | 74 | g := errgroup.Group{} 75 | g.Go(func() error { 76 | var err error 77 | org, err = hubClient.GetOrganizationInfo(orgName) 78 | return checkForbiddenError(err) 79 | }) 80 | g.Go(func() error { 81 | var err error 82 | consumption, err = hubClient.GetOrgConsumption(orgName) 83 | return checkForbiddenError(err) 84 | }) 85 | if err := g.Wait(); err != nil { 86 | return err 87 | } 88 | 89 | plan, err := hubClient.GetHubPlan(org.ID) 90 | if err != nil { 91 | return checkForbiddenError(err) 92 | } 93 | 94 | return opts.Print(streams.Out(), account{org, plan, consumption}, printAccount) 95 | } 96 | 97 | func runUserInfo(streams command.Streams, hubClient *hub.Client, opts infoOptions) error { 98 | user, err := hubClient.GetUserInfo() 99 | if err != nil { 100 | return checkForbiddenError(err) 101 | } 102 | consumption, err := hubClient.GetUserConsumption(user.Name) 103 | if err != nil { 104 | return checkForbiddenError(err) 105 | } 106 | plan, err := hubClient.GetHubPlan(user.ID) 107 | if err != nil { 108 | return checkForbiddenError(err) 109 | } 110 | 111 | return opts.Print(streams.Out(), account{user, plan, consumption}, printAccount) 112 | } 113 | 114 | func checkForbiddenError(err error) error { 115 | if hub.IsForbiddenError(err) { 116 | return fmt.Errorf(ansi.Error("failed to get organization information, you need to be the organization Owner")) 117 | } 118 | return err 119 | } 120 | 121 | func printAccount(out io.Writer, value interface{}) error { 122 | account := value.(account) 123 | 124 | // print user info 125 | fmt.Fprintf(out, ansi.Key("Name:")+"\t\t%s\n", account.Account.Name) 126 | fmt.Fprintf(out, ansi.Key("Full name:")+"\t%s\n", account.Account.FullName) 127 | fmt.Fprintf(out, ansi.Key("Company:")+"\t%s\n", account.Account.Company) 128 | fmt.Fprintf(out, ansi.Key("Location:")+"\t%s\n", account.Account.Location) 129 | fmt.Fprintf(out, ansi.Key("Joined:")+"\t\t%s ago\n", units.HumanDuration(time.Since(account.Account.Joined))) 130 | fmt.Fprintf(out, ansi.Key("Plan:")+"\t\t%s\n", ansi.Emphasise(account.Plan.Name)) 131 | 132 | // print plan info 133 | fmt.Fprintf(out, ansi.Key("Limits:")+"\n") 134 | fmt.Fprintf(out, ansi.Key(" Seats:")+"\t\t%v\n", getCurrentLimit(account.Consumption.Seats, account.Plan.Limits.Seats)) 135 | fmt.Fprintf(out, ansi.Key(" Private repositories:")+"\t%v\n", getCurrentLimit(account.Consumption.PrivateRepositories, account.Plan.Limits.PrivateRepos)) 136 | fmt.Fprintf(out, ansi.Key(" Teams:")+"\t\t%v\n", getCurrentLimit(account.Consumption.Teams, account.Plan.Limits.Teams)) 137 | fmt.Fprintf(out, ansi.Key(" Collaborators:")+"\t%v\n", getLimit(account.Plan.Limits.Collaborators)) 138 | fmt.Fprintf(out, ansi.Key(" Parallel builds:")+"\t%v\n", getLimit(account.Plan.Limits.ParallelBuilds)) 139 | 140 | return nil 141 | } 142 | 143 | func getCurrentLimit(current, limit int) string { 144 | if limit == 9999 { 145 | return ansi.Emphasise("unlimited") 146 | } 147 | return fmt.Sprintf("%v/%v", current, limit) 148 | } 149 | 150 | func getLimit(limit int) string { 151 | if limit == 9999 { 152 | return ansi.Emphasise("unlimited") 153 | } 154 | return fmt.Sprintf("%v", limit) 155 | } 156 | 157 | type account struct { 158 | Account *hub.Account 159 | Plan *hub.Plan 160 | Consumption *hub.Consumption 161 | } 162 | -------------------------------------------------------------------------------- /pkg/hub/organizations.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import ( 20 | "context" 21 | "encoding/json" 22 | "fmt" 23 | "net/http" 24 | "net/url" 25 | "sort" 26 | "time" 27 | 28 | "golang.org/x/sync/errgroup" 29 | ) 30 | 31 | const ( 32 | // OrganizationsURL path to the Hub API listing the organizations 33 | OrganizationsURL = "/v2/user/orgs/" 34 | // OrganizationInfoURL path to the Hub API returning organization info 35 | OrganizationInfoURL = "/v2/orgs/%s" 36 | ) 37 | 38 | // Organization represents a Docker Hub organization 39 | type Organization struct { 40 | Namespace string 41 | FullName string 42 | Role string 43 | Teams []Team 44 | Members []Member 45 | } 46 | 47 | // GetOrganizations lists all the organizations a user has joined 48 | func (c *Client) GetOrganizations(ctx context.Context) ([]Organization, error) { 49 | u, err := url.Parse(c.domain + OrganizationsURL) 50 | if err != nil { 51 | return nil, err 52 | } 53 | q := url.Values{} 54 | q.Add("page_size", fmt.Sprintf("%v", itemsPerPage)) 55 | q.Add("page", "1") 56 | u.RawQuery = q.Encode() 57 | 58 | organizations, next, err := c.getOrganizationsPage(ctx, u.String()) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | for next != "" { 64 | pageOrganizations, n, err := c.getOrganizationsPage(ctx, next) 65 | if err != nil { 66 | return nil, err 67 | } 68 | next = n 69 | organizations = append(organizations, pageOrganizations...) 70 | } 71 | 72 | return organizations, nil 73 | } 74 | 75 | // GetOrganizationInfo returns organization info 76 | func (c *Client) GetOrganizationInfo(orgname string) (*Account, error) { 77 | u, err := url.Parse(c.domain + fmt.Sprintf(OrganizationInfoURL, orgname)) 78 | if err != nil { 79 | return nil, err 80 | } 81 | req, err := http.NewRequest("GET", u.String(), nil) 82 | if err != nil { 83 | return nil, err 84 | } 85 | response, err := c.doRequest(req, withHubToken(c.token)) 86 | if err != nil { 87 | return nil, err 88 | } 89 | var hubResponse hubOrgInfoResponse 90 | if err := json.Unmarshal(response, &hubResponse); err != nil { 91 | return nil, err 92 | } 93 | 94 | return &Account{ 95 | ID: hubResponse.ID, 96 | Name: hubResponse.OrgName, 97 | FullName: hubResponse.FullName, 98 | Location: hubResponse.Location, 99 | Company: hubResponse.Company, 100 | Joined: hubResponse.DateJoined, 101 | }, nil 102 | } 103 | 104 | func (c *Client) getOrganizationsPage(ctx context.Context, url string) ([]Organization, string, error) { 105 | req, err := http.NewRequest("GET", url, nil) 106 | if err != nil { 107 | return nil, "", err 108 | } 109 | req = req.WithContext(ctx) 110 | response, err := c.doRequest(req, withHubToken(c.token)) 111 | if err != nil { 112 | return nil, "", err 113 | } 114 | var hubResponse hubOrganizationResponse 115 | if err := json.Unmarshal(response, &hubResponse); err != nil { 116 | return nil, "", err 117 | } 118 | 119 | var organizations []Organization 120 | eg, _ := errgroup.WithContext(ctx) 121 | 122 | for _, result := range hubResponse.Results { 123 | result := result 124 | eg.Go(func() error { 125 | var ( 126 | teams []Team 127 | members []Member 128 | ) 129 | subeg, _ := errgroup.WithContext(ctx) 130 | 131 | subeg.Go(func() error { 132 | teams, err = c.GetTeams(result.OrgName) 133 | return err 134 | }) 135 | subeg.Go(func() error { 136 | members, err = c.GetMembers(result.OrgName) 137 | return err 138 | }) 139 | 140 | if err := subeg.Wait(); err != nil { 141 | return err 142 | } 143 | organization := Organization{ 144 | Namespace: result.OrgName, 145 | FullName: result.FullName, 146 | Role: getRole(teams), 147 | Teams: teams, 148 | Members: members, 149 | } 150 | organizations = append(organizations, organization) 151 | 152 | return nil 153 | }) 154 | } 155 | 156 | if err := eg.Wait(); err != nil { 157 | return []Organization{}, "", err 158 | } 159 | 160 | sort.Slice(organizations, func(i, j int) bool { 161 | return organizations[i].Namespace < organizations[j].Namespace 162 | }) 163 | return organizations, hubResponse.Next, nil 164 | } 165 | 166 | func getRole(teams []Team) string { 167 | for _, t := range teams { 168 | if t.Name == "owners" { 169 | return "Owner" 170 | } 171 | } 172 | return "Member" 173 | } 174 | 175 | type hubOrganizationResponse struct { 176 | Count int `json:"count"` 177 | Next string `json:"next,omitempty"` 178 | Previous string `json:"previous,omitempty"` 179 | Results []hubOrganizationResult `json:"results,omitempty"` 180 | } 181 | 182 | type hubOrganizationResult struct { 183 | OrgName string `json:"orgname"` 184 | FullName string `json:"full_name"` 185 | Company string `json:"company"` 186 | Location string `json:"location"` 187 | Type string `json:"type"` 188 | DateJoined time.Time `json:"date_joined"` 189 | GravatarEmail string `json:"gravatar_email"` 190 | GravatarURL string `json:"gravatar_url"` 191 | ProfileURL string `json:"profile_url"` 192 | ID string `json:"id"` 193 | } 194 | 195 | type hubOrgInfoResponse struct { 196 | ID string `json:"id"` 197 | OrgName string `json:"orgname"` 198 | FullName string `json:"full_name"` 199 | Location string `json:"location"` 200 | Company string `json:"company"` 201 | GravatarEmail string `json:"gravatar_email"` 202 | GravatarURL string `json:"gravatar_url"` 203 | ProfileURL string `json:"profile_url"` 204 | DateJoined time.Time `json:"date_joined"` 205 | Type string `json:"type"` 206 | } 207 | -------------------------------------------------------------------------------- /internal/commands/tag/list.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package tag 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "strings" 23 | "time" 24 | 25 | "github.com/docker/cli/cli" 26 | "github.com/docker/cli/cli/command" 27 | "github.com/docker/go-units" 28 | "github.com/spf13/cobra" 29 | 30 | "github.com/docker/hub-tool/internal/ansi" 31 | "github.com/docker/hub-tool/internal/format" 32 | "github.com/docker/hub-tool/internal/format/tabwriter" 33 | "github.com/docker/hub-tool/internal/metrics" 34 | "github.com/docker/hub-tool/pkg/hub" 35 | ) 36 | 37 | const ( 38 | lsName = "ls" 39 | ) 40 | 41 | var ( 42 | defaultColumns = []column{ 43 | {"TAG", func(t hub.Tag) (string, int) { return t.Name, len(t.Name) }}, 44 | {"DIGEST", func(t hub.Tag) (string, int) { 45 | if len(t.Images) > 0 { 46 | return t.Images[0].Digest, len(t.Images[0].Digest) 47 | } 48 | return "", 0 49 | }}, 50 | {"STATUS", func(t hub.Tag) (string, int) { 51 | return t.Status, len(t.Status) 52 | }}, 53 | {"LAST UPDATE", func(t hub.Tag) (string, int) { 54 | if t.LastUpdated.Nanosecond() == 0 { 55 | return "", 0 56 | } 57 | s := fmt.Sprintf("%s ago", units.HumanDuration(time.Since(t.LastUpdated))) 58 | return s, len(s) 59 | }}, 60 | {"LAST PUSHED", func(t hub.Tag) (string, int) { 61 | if t.LastPushed.Nanosecond() == 0 { 62 | return "", 0 63 | } 64 | s := units.HumanDuration(time.Since(t.LastPushed)) 65 | return s, len(s) 66 | }}, 67 | {"LAST PULLED", func(t hub.Tag) (string, int) { 68 | if t.LastPulled.Nanosecond() == 0 { 69 | return "", 0 70 | } 71 | s := units.HumanDuration(time.Since(t.LastPulled)) 72 | return s, len(s) 73 | }}, 74 | {"SIZE", func(t hub.Tag) (string, int) { 75 | size := t.FullSize 76 | if len(t.Images) > 0 { 77 | size = 0 78 | for _, image := range t.Images { 79 | size += image.Size 80 | } 81 | } 82 | s := units.HumanSize(float64(size)) 83 | return s, len(s) 84 | }}, 85 | } 86 | platformColumn = column{ 87 | "OS/ARCH", 88 | func(t hub.Tag) (string, int) { 89 | var platforms []string 90 | for _, image := range t.Images { 91 | platform := fmt.Sprintf("%s/%s", image.Os, image.Architecture) 92 | if image.Variant != "" { 93 | platform += "/" + image.Variant 94 | } 95 | platforms = append(platforms, platform) 96 | } 97 | s := strings.Join(platforms, ",") 98 | return s, len(s) 99 | }, 100 | } 101 | ) 102 | 103 | type column struct { 104 | header string 105 | value func(t hub.Tag) (string, int) 106 | } 107 | 108 | type listOptions struct { 109 | format.Option 110 | platforms bool 111 | all bool 112 | sort string 113 | } 114 | 115 | func newListCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command { 116 | var opts listOptions 117 | cmd := &cobra.Command{ 118 | Use: lsName + " [OPTION] REPOSITORY", 119 | Aliases: []string{"list"}, 120 | Short: "List all the images in a repository", 121 | Args: cli.ExactArgs(1), 122 | DisableFlagsInUseLine: true, 123 | PreRun: func(cmd *cobra.Command, args []string) { 124 | metrics.Send(parent, lsName) 125 | }, 126 | RunE: func(_ *cobra.Command, args []string) error { 127 | return runList(streams, hubClient, opts, args[0]) 128 | }, 129 | } 130 | cmd.Flags().BoolVar(&opts.platforms, "platforms", false, "List all available platforms per tag") 131 | cmd.Flags().BoolVar(&opts.all, "all", false, "Fetch all available tags") 132 | cmd.Flags().StringVar(&opts.sort, "sort", "", "Sort tags by (updated|name)[=(asc|desc)] (e.g.: --sort updated or --sort name=desc)") 133 | opts.AddFormatFlag(cmd.Flags()) 134 | return cmd 135 | } 136 | 137 | func runList(streams command.Streams, hubClient *hub.Client, opts listOptions, repository string) error { 138 | ordering, err := mapOrdering(opts.sort) 139 | if err != nil { 140 | return err 141 | } 142 | if opts.all { 143 | if err := hubClient.Update(hub.WithAllElements()); err != nil { 144 | return err 145 | } 146 | } 147 | 148 | var reqOps []hub.RequestOp 149 | if ordering != "" { 150 | reqOps = append(reqOps, hub.WithSortingOrder(ordering)) 151 | } 152 | tags, total, err := hubClient.GetTags(repository, reqOps...) 153 | if err != nil { 154 | return err 155 | } 156 | 157 | if opts.platforms { 158 | defaultColumns = append(defaultColumns, platformColumn) 159 | } 160 | 161 | return opts.Print(streams.Out(), tags, printTags(total)) 162 | } 163 | 164 | func printTags(total int) format.PrettyPrinter { 165 | return func(out io.Writer, values interface{}) error { 166 | tags := values.([]hub.Tag) 167 | tw := tabwriter.New(out, " ") 168 | for _, column := range defaultColumns { 169 | tw.Column(ansi.Header(column.header), len(column.header)) 170 | } 171 | 172 | tw.Line() 173 | 174 | for _, tag := range tags { 175 | for _, column := range defaultColumns { 176 | value, width := column.value(tag) 177 | tw.Column(value, width) 178 | } 179 | tw.Line() 180 | } 181 | if err := tw.Flush(); err != nil { 182 | return err 183 | } 184 | 185 | if len(tags) < total { 186 | fmt.Fprintln(out, ansi.Info(fmt.Sprintf("%v/%v listed, use --all flag to show all", len(tags), total))) 187 | } 188 | 189 | return nil 190 | } 191 | } 192 | 193 | const ( 194 | sortAsc = "asc" 195 | sortDesc = "desc" 196 | ) 197 | 198 | func mapOrdering(order string) (string, error) { 199 | if order == "" { 200 | return "", nil 201 | } 202 | name := "-name" 203 | update := "last_updated" 204 | fields := strings.SplitN(order, "=", 2) 205 | if len(fields) == 2 { 206 | switch fields[1] { 207 | case sortDesc: 208 | name = "name" 209 | update = "-last_updated" 210 | case sortAsc: 211 | default: 212 | return "", fmt.Errorf(`invalid sorting direction %q: should be either "asc" or "desc"`, fields[1]) 213 | } 214 | } 215 | switch fields[0] { 216 | case "updated": 217 | return update, nil 218 | case "name": 219 | return name, nil 220 | default: 221 | return "", fmt.Errorf(`unknown sorting column %q: should be either "name" or "updated"`, fields[0]) 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /pkg/hub/tags.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "net/http" 23 | "net/url" 24 | "time" 25 | 26 | "github.com/distribution/reference" 27 | ) 28 | 29 | const ( 30 | // TagsURL path to the Hub API listing the tags 31 | TagsURL = "/v2/repositories/%s/tags/" 32 | // DeleteTagURL path to the Hub API to remove a tag 33 | DeleteTagURL = "/v2/repositories/%s/tags/%s/" 34 | ) 35 | 36 | // Tag can point to a manifest or manifest list 37 | type Tag struct { 38 | Name string 39 | FullSize int 40 | LastUpdated time.Time 41 | LastUpdaterUserName string 42 | Images []Image 43 | LastPulled time.Time 44 | LastPushed time.Time 45 | Status string 46 | } 47 | 48 | // Image represents the metadata of a manifest 49 | type Image struct { 50 | Digest string 51 | Architecture string 52 | Os string 53 | Variant string 54 | Size int 55 | LastPulled time.Time 56 | LastPushed time.Time 57 | Status string 58 | } 59 | 60 | // GetTags calls the hub repo API and returns all the information on all tags 61 | func (c *Client) GetTags(repository string, reqOps ...RequestOp) ([]Tag, int, error) { 62 | repoPath, err := getRepoPath(repository) 63 | if err != nil { 64 | return nil, 0, err 65 | } 66 | u, err := url.Parse(c.domain + fmt.Sprintf(TagsURL, repoPath)) 67 | if err != nil { 68 | return nil, 0, err 69 | } 70 | q := url.Values{} 71 | q.Add("page_size", fmt.Sprintf("%v", itemsPerPage)) 72 | q.Add("page", "1") 73 | u.RawQuery = q.Encode() 74 | 75 | tags, total, next, err := c.getTagsPage(u.String(), repository, reqOps...) 76 | if err != nil { 77 | return nil, 0, err 78 | } 79 | if c.fetchAllElements { 80 | for next != "" { 81 | pageTags, _, n, err := c.getTagsPage(next, repository, reqOps...) 82 | if err != nil { 83 | return nil, 0, err 84 | } 85 | next = n 86 | tags = append(tags, pageTags...) 87 | } 88 | } 89 | 90 | return tags, total, nil 91 | } 92 | 93 | // RemoveTag removes a tag in a repository on Hub 94 | func (c *Client) RemoveTag(repository, tag string) error { 95 | req, err := http.NewRequest("DELETE", c.domain+fmt.Sprintf(DeleteTagURL, repository, tag), nil) 96 | if err != nil { 97 | return err 98 | } 99 | _, err = c.doRequest(req, withHubToken(c.token)) 100 | return err 101 | } 102 | 103 | func (c *Client) getTagsPage(url, repository string, reqOps ...RequestOp) ([]Tag, int, string, error) { 104 | req, err := http.NewRequest("GET", url, nil) 105 | if err != nil { 106 | return nil, 0, "", err 107 | } 108 | response, err := c.doRequest(req, append(reqOps, withHubToken(c.token))...) 109 | if err != nil { 110 | return nil, 0, "", err 111 | } 112 | var hubResponse hubTagResponse 113 | if err := json.Unmarshal(response, &hubResponse); err != nil { 114 | return nil, 0, "", err 115 | } 116 | var tags []Tag 117 | for _, result := range hubResponse.Results { 118 | tag := Tag{ 119 | Name: fmt.Sprintf("%s:%s", repository, result.Name), 120 | FullSize: result.FullSize, 121 | LastUpdated: result.LastUpdated, 122 | LastUpdaterUserName: result.LastUpdaterUserName, 123 | Images: toImages(result.Images), 124 | Status: result.Status, 125 | LastPulled: result.LastPulled, 126 | LastPushed: result.LastPushed, 127 | } 128 | tags = append(tags, tag) 129 | } 130 | return tags, hubResponse.Count, hubResponse.Next, nil 131 | } 132 | 133 | type hubTagResponse struct { 134 | Count int `json:"count"` 135 | Next string `json:"next,omitempty"` 136 | Previous string `json:"previous,omitempty"` 137 | Results []hubTagResult `json:"results,omitempty"` 138 | } 139 | 140 | type hubTagResult struct { 141 | Creator int `json:"creator"` 142 | ID int `json:"id"` 143 | Name string `json:"name"` 144 | ImageID string `json:"image_id,omitempty"` 145 | LastUpdated time.Time `json:"last_updated"` 146 | LastUpdater int `json:"last_updater"` 147 | LastUpdaterUserName string `json:"last_updater_username"` 148 | Images []hubTagImage `json:"images,omitempty"` 149 | Repository int `json:"repository"` 150 | FullSize int `json:"full_size"` 151 | V2 bool `json:"v2"` 152 | LastPulled time.Time `json:"tag_last_pulled,omitempty"` 153 | LastPushed time.Time `json:"tag_last_pushed,omitempty"` 154 | Status string `json:"tag_status,omitempty"` 155 | } 156 | 157 | type hubTagImage struct { 158 | Architecture string `json:"architecture"` 159 | Os string `json:"os"` 160 | Features string `json:"features,omitempty"` 161 | Variant string `json:"variant,omitempty"` 162 | Digest string `json:"digest"` 163 | OsFeatures string `json:"os_features,omitempty"` 164 | OsVersion string `json:"os_version,omitempty"` 165 | Size int `json:"size"` 166 | LastPulled time.Time `json:"last_pulled,omitempty"` 167 | LastPushed time.Time `json:"last_pushed,omitempty"` 168 | Status string `json:"status,omitempty"` 169 | } 170 | 171 | func getRepoPath(s string) (string, error) { 172 | ref, err := reference.ParseNormalizedNamed(s) 173 | if err != nil { 174 | return "", err 175 | } 176 | ref = reference.TagNameOnly(ref) 177 | ref = reference.TrimNamed(ref) 178 | return reference.Path(ref), nil 179 | } 180 | 181 | func toImages(result []hubTagImage) []Image { 182 | images := make([]Image, len(result)) 183 | for i := range result { 184 | images[i] = Image{ 185 | Digest: result[i].Digest, 186 | Architecture: result[i].Architecture, 187 | Os: result[i].Os, 188 | Variant: result[i].Variant, 189 | Size: result[i].Size, 190 | Status: result[i].Status, 191 | LastPulled: result[i].LastPulled, 192 | LastPushed: result[i].LastPushed, 193 | } 194 | } 195 | return images 196 | } 197 | -------------------------------------------------------------------------------- /pkg/hub/tokens.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import ( 20 | "bytes" 21 | "encoding/json" 22 | "fmt" 23 | "net/http" 24 | "net/url" 25 | "time" 26 | 27 | "github.com/google/uuid" 28 | ) 29 | 30 | const ( 31 | // TokensURL path to the Hub API listing the Personal Access Tokens 32 | TokensURL = "/v2/api_tokens" 33 | // TokenURL path to the Hub API Personal Access Token 34 | TokenURL = "/v2/api_tokens/%s" 35 | ) 36 | 37 | // Token is a personal access token. The token field will only be filled at creation and can never been accessed again. 38 | type Token struct { 39 | UUID uuid.UUID 40 | ClientID string 41 | CreatorIP string 42 | CreatorUA string 43 | CreatedAt time.Time 44 | LastUsed time.Time 45 | GeneratedBy string 46 | IsActive bool 47 | Token string 48 | Description string 49 | } 50 | 51 | // CreateToken creates a Personal Access Token and returns the token field only once 52 | func (c *Client) CreateToken(description string) (*Token, error) { 53 | data, err := json.Marshal(hubTokenRequest{Description: description}) 54 | if err != nil { 55 | return nil, err 56 | } 57 | body := bytes.NewBuffer(data) 58 | req, err := http.NewRequest("POST", c.domain+TokensURL, body) 59 | if err != nil { 60 | return nil, err 61 | } 62 | response, err := c.doRequest(req, withHubToken(c.token)) 63 | if err != nil { 64 | return nil, err 65 | } 66 | var tokenResponse hubTokenResult 67 | if err := json.Unmarshal(response, &tokenResponse); err != nil { 68 | return nil, err 69 | } 70 | token, err := convertToken(tokenResponse) 71 | if err != nil { 72 | return nil, err 73 | } 74 | return &token, nil 75 | } 76 | 77 | // GetTokens calls the hub repo API and returns all the information on all tokens 78 | func (c *Client) GetTokens() ([]Token, int, error) { 79 | u, err := url.Parse(c.domain + TokensURL) 80 | if err != nil { 81 | return nil, 0, err 82 | } 83 | q := url.Values{} 84 | q.Add("page_size", fmt.Sprintf("%v", itemsPerPage)) 85 | q.Add("page", "1") 86 | u.RawQuery = q.Encode() 87 | 88 | tokens, total, next, err := c.getTokensPage(u.String()) 89 | if err != nil { 90 | return nil, 0, err 91 | } 92 | if c.fetchAllElements { 93 | for next != "" { 94 | pageTokens, _, n, err := c.getTokensPage(next) 95 | if err != nil { 96 | return nil, 0, err 97 | } 98 | next = n 99 | tokens = append(tokens, pageTokens...) 100 | } 101 | } 102 | 103 | return tokens, total, nil 104 | } 105 | 106 | // GetToken calls the hub repo API and returns the information on one token 107 | func (c *Client) GetToken(tokenUUID string) (*Token, error) { 108 | req, err := http.NewRequest("GET", c.domain+fmt.Sprintf(TokenURL, tokenUUID), nil) 109 | if err != nil { 110 | return nil, err 111 | } 112 | response, err := c.doRequest(req, withHubToken(c.token)) 113 | if err != nil { 114 | return nil, err 115 | } 116 | var tokenResponse hubTokenResult 117 | if err := json.Unmarshal(response, &tokenResponse); err != nil { 118 | return nil, err 119 | } 120 | token, err := convertToken(tokenResponse) 121 | if err != nil { 122 | return nil, err 123 | } 124 | return &token, nil 125 | } 126 | 127 | // UpdateToken updates a token's description and activeness 128 | func (c *Client) UpdateToken(tokenUUID, description string, isActive bool) (*Token, error) { 129 | tokenRequest := hubTokenRequest{IsActive: isActive} 130 | if description != "" { 131 | tokenRequest.Description = description 132 | } 133 | data, err := json.Marshal(tokenRequest) 134 | if err != nil { 135 | return nil, err 136 | } 137 | body := bytes.NewBuffer(data) 138 | req, err := http.NewRequest("PATCH", c.domain+fmt.Sprintf(TokenURL, tokenUUID), body) 139 | if err != nil { 140 | return nil, err 141 | } 142 | response, err := c.doRequest(req, withHubToken(c.token)) 143 | if err != nil { 144 | return nil, err 145 | } 146 | var tokenResponse hubTokenResult 147 | if err := json.Unmarshal(response, &tokenResponse); err != nil { 148 | return nil, err 149 | } 150 | token, err := convertToken(tokenResponse) 151 | if err != nil { 152 | return nil, err 153 | } 154 | return &token, nil 155 | } 156 | 157 | // RemoveToken deletes a token from personal access token 158 | func (c *Client) RemoveToken(tokenUUID string) error { 159 | //DELETE https://hub.docker.com/v2/api_tokens/8208674e-d08a-426f-b6f4-e3aba7058459 => 202 160 | req, err := http.NewRequest("DELETE", c.domain+fmt.Sprintf(TokenURL, tokenUUID), nil) 161 | if err != nil { 162 | return err 163 | } 164 | _, err = c.doRequest(req, withHubToken(c.token)) 165 | return err 166 | } 167 | 168 | func (c *Client) getTokensPage(url string) ([]Token, int, string, error) { 169 | req, err := http.NewRequest("GET", url, nil) 170 | if err != nil { 171 | return nil, 0, "", err 172 | } 173 | response, err := c.doRequest(req, withHubToken(c.token)) 174 | if err != nil { 175 | return nil, 0, "", err 176 | } 177 | var hubResponse hubTokenResponse 178 | if err := json.Unmarshal(response, &hubResponse); err != nil { 179 | return nil, 0, "", err 180 | } 181 | var tokens []Token 182 | for _, result := range hubResponse.Results { 183 | token, err := convertToken(result) 184 | if err != nil { 185 | return nil, 0, "", err 186 | } 187 | tokens = append(tokens, token) 188 | } 189 | return tokens, hubResponse.Count, hubResponse.Next, nil 190 | } 191 | 192 | type hubTokenRequest struct { 193 | Description string `json:"token_label,omitempty"` 194 | IsActive bool `json:"is_active"` 195 | } 196 | 197 | type hubTokenResponse struct { 198 | Count int `json:"count"` 199 | Next string `json:"next,omitempty"` 200 | Previous string `json:"previous,omitempty"` 201 | Results []hubTokenResult `json:"results,omitempty"` 202 | } 203 | 204 | type hubTokenResult struct { 205 | UUID string `json:"uuid"` 206 | ClientID string `json:"client_id"` 207 | CreatorIP string `json:"creator_ip"` 208 | CreatorUA string `json:"creator_ua"` 209 | CreatedAt time.Time `json:"created_at"` 210 | LastUsed time.Time `json:"last_used,omitempty"` 211 | GeneratedBy string `json:"generated_by"` 212 | IsActive bool `json:"is_active"` 213 | Token string `json:"token"` 214 | TokenLabel string `json:"token_label"` 215 | } 216 | 217 | func convertToken(response hubTokenResult) (Token, error) { 218 | u, err := uuid.Parse(response.UUID) 219 | if err != nil { 220 | return Token{}, err 221 | } 222 | return Token{ 223 | UUID: u, 224 | ClientID: response.ClientID, 225 | CreatorIP: response.CreatorIP, 226 | CreatorUA: response.CreatorUA, 227 | CreatedAt: response.CreatedAt, 228 | LastUsed: response.LastUsed, 229 | GeneratedBy: response.GeneratedBy, 230 | IsActive: response.IsActive, 231 | Token: response.Token, 232 | Description: response.TokenLabel, 233 | }, nil 234 | } 235 | -------------------------------------------------------------------------------- /pkg/hub/client.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Docker Hub Tool authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package hub 18 | 19 | import ( 20 | "bytes" 21 | "context" 22 | "encoding/json" 23 | "fmt" 24 | "io" 25 | "net/http" 26 | "net/url" 27 | 28 | "github.com/docker/cli/cli/config/types" 29 | "github.com/docker/docker/api/types/registry" 30 | log "github.com/sirupsen/logrus" 31 | 32 | "github.com/docker/hub-tool/internal" 33 | ) 34 | 35 | const ( 36 | // LoginURL path to the Hub login URL 37 | LoginURL = "/v2/users/login?refresh_token=true" 38 | // TwoFactorLoginURL path to the 2FA 39 | TwoFactorLoginURL = "/v2/users/2fa-login?refresh_token=true" 40 | // SecondFactorDetailMessage returned by login if 2FA is enabled 41 | SecondFactorDetailMessage = "Require secondary authentication on MFA enabled account" 42 | 43 | itemsPerPage = 100 44 | ) 45 | 46 | // Client sends authenticated calls to the Hub API 47 | type Client struct { 48 | AuthConfig registry.AuthConfig 49 | Ctx context.Context 50 | 51 | client *http.Client 52 | domain string 53 | token string 54 | refreshToken string 55 | password string 56 | account string 57 | fetchAllElements bool 58 | in io.Reader 59 | out io.Writer 60 | } 61 | 62 | type twoFactorResponse struct { 63 | Detail string `json:"detail"` 64 | Login2FAToken string `json:"login_2fa_token"` 65 | } 66 | 67 | type twoFactorRequest struct { 68 | Code string `json:"code"` 69 | Login2FAToken string `json:"login_2fa_token"` 70 | } 71 | 72 | type tokenResponse struct { 73 | Detail string `json:"detail"` 74 | Token string `json:"token"` 75 | RefreshToken string `json:"refresh_token"` 76 | } 77 | 78 | // ClientOp represents an option given to NewClient constructor to customize client behavior. 79 | type ClientOp func(*Client) error 80 | 81 | // RequestOp represents an option to customize the request sent to the Hub API 82 | type RequestOp func(r *http.Request) error 83 | 84 | // NewClient logs the user to the hub and returns a client which can send authenticated requests 85 | // to the Hub API 86 | func NewClient(ops ...ClientOp) (*Client, error) { 87 | hubInstance := getInstance() 88 | 89 | client := &Client{ 90 | client: http.DefaultClient, 91 | domain: hubInstance.APIHubBaseURL, 92 | } 93 | for _, op := range ops { 94 | if err := op(client); err != nil { 95 | return nil, err 96 | } 97 | } 98 | 99 | return client, nil 100 | } 101 | 102 | // Update changes client behavior using ClientOp 103 | func (c *Client) Update(ops ...ClientOp) error { 104 | for _, op := range ops { 105 | if err := op(c); err != nil { 106 | return err 107 | } 108 | } 109 | return nil 110 | } 111 | 112 | // WithAllElements makes the client fetch all the elements it can find, enabling pagination. 113 | func WithAllElements() ClientOp { 114 | return func(c *Client) error { 115 | c.fetchAllElements = true 116 | return nil 117 | } 118 | } 119 | 120 | // WithContext set the client context 121 | func WithContext(ctx context.Context) ClientOp { 122 | return func(c *Client) error { 123 | c.Ctx = ctx 124 | return nil 125 | } 126 | } 127 | 128 | // WithInStream sets the input stream 129 | func WithInStream(in io.Reader) ClientOp { 130 | return func(c *Client) error { 131 | c.in = in 132 | return nil 133 | } 134 | } 135 | 136 | // WithOutStream sets the output stream 137 | func WithOutStream(out io.Writer) ClientOp { 138 | return func(c *Client) error { 139 | c.out = out 140 | return nil 141 | } 142 | } 143 | 144 | // WithHubAccount sets the current account name 145 | func WithHubAccount(account string) ClientOp { 146 | return func(c *Client) error { 147 | c.AuthConfig.Username = account 148 | c.account = account 149 | return nil 150 | } 151 | } 152 | 153 | // WithHubToken sets the bearer token to the client 154 | func WithHubToken(token string) ClientOp { 155 | return func(c *Client) error { 156 | c.token = token 157 | return nil 158 | } 159 | } 160 | 161 | // WithRefreshToken sets the refresh token to the client 162 | func WithRefreshToken(refreshToken string) ClientOp { 163 | return func(c *Client) error { 164 | c.refreshToken = refreshToken 165 | return nil 166 | } 167 | } 168 | 169 | // WithPassword sets the password to the client 170 | func WithPassword(password string) ClientOp { 171 | return func(c *Client) error { 172 | c.password = password 173 | return nil 174 | } 175 | } 176 | 177 | // WithHTTPClient sets the *http.Client for the client 178 | func WithHTTPClient(client *http.Client) ClientOp { 179 | return func(c *Client) error { 180 | c.client = client 181 | return nil 182 | } 183 | } 184 | 185 | func withHubToken(token string) RequestOp { 186 | return func(req *http.Request) error { 187 | req.Header["Authorization"] = []string{fmt.Sprintf("Bearer %s", token)} 188 | return nil 189 | } 190 | } 191 | 192 | // WithSortingOrder adds a sorting order query parameter to the request 193 | func WithSortingOrder(order string) RequestOp { 194 | return func(req *http.Request) error { 195 | values, err := url.ParseQuery(req.URL.RawQuery) 196 | if err != nil { 197 | return err 198 | } 199 | values.Add("ordering", order) 200 | req.URL.RawQuery = values.Encode() 201 | return nil 202 | } 203 | } 204 | 205 | // Login tries to authenticate, it will call the twoFactorCodeProvider if the 206 | // user has 2FA activated 207 | func (c *Client) Login(username string, password string, twoFactorCodeProvider func() (string, error)) (string, string, error) { 208 | data, err := json.Marshal(types.AuthConfig{ 209 | Username: username, 210 | Password: password, 211 | }) 212 | if err != nil { 213 | return "", "", err 214 | } 215 | body := bytes.NewBuffer(data) 216 | 217 | // Login on the Docker Hub 218 | req, err := http.NewRequest("POST", c.domain+LoginURL, body) 219 | if err != nil { 220 | return "", "", err 221 | } 222 | resp, err := c.doRawRequest(req) 223 | if err != nil { 224 | return "", "", err 225 | } 226 | defer func() { _ = resp.Body.Close() }() 227 | buf, err := io.ReadAll(resp.Body) 228 | if err != nil { 229 | return "", "", err 230 | } 231 | 232 | // Login is OK, return the token 233 | if resp.StatusCode == http.StatusOK { 234 | creds := tokenResponse{} 235 | if err := json.Unmarshal(buf, &creds); err != nil { 236 | return "", "", err 237 | } 238 | return creds.Token, "", nil 239 | } else if resp.StatusCode == http.StatusUnauthorized { 240 | response2FA := twoFactorResponse{} 241 | if err := json.Unmarshal(buf, &response2FA); err != nil { 242 | return "", "", err 243 | } 244 | // Check if 2FA is enabled and needs a second authentication 245 | if response2FA.Detail != SecondFactorDetailMessage { 246 | return "", "", fmt.Errorf(response2FA.Detail) 247 | } 248 | return c.getTwoFactorToken(response2FA.Login2FAToken, twoFactorCodeProvider) 249 | } 250 | if ok, err := extractError(buf, resp); ok { 251 | return "", "", err 252 | } 253 | return "", "", fmt.Errorf("failed to authenticate: bad status code %q: %s", resp.Status, string(buf)) 254 | } 255 | 256 | func (c *Client) getTwoFactorToken(token string, twoFactorCodeProvider func() (string, error)) (string, string, error) { 257 | code, err := twoFactorCodeProvider() 258 | if err != nil { 259 | return "", "", err 260 | } 261 | 262 | body2FA := twoFactorRequest{ 263 | Code: code, 264 | Login2FAToken: token, 265 | } 266 | data, err := json.Marshal(body2FA) 267 | if err != nil { 268 | return "", "", err 269 | } 270 | 271 | body := bytes.NewBuffer(data) 272 | 273 | // Request 2FA on the Docker Hub 274 | req, err := http.NewRequest("POST", c.domain+TwoFactorLoginURL, body) 275 | if err != nil { 276 | return "", "", err 277 | } 278 | resp, err := c.doRawRequest(req) 279 | if err != nil { 280 | return "", "", err 281 | } 282 | defer func() { _ = resp.Body.Close() }() 283 | 284 | buf, err := io.ReadAll(resp.Body) 285 | if err != nil { 286 | return "", "", err 287 | } 288 | 289 | // Login is OK, return the token 290 | if resp.StatusCode == http.StatusOK { 291 | creds := tokenResponse{} 292 | if err := json.Unmarshal(buf, &creds); err != nil { 293 | return "", "", err 294 | } 295 | 296 | return creds.Token, creds.RefreshToken, nil 297 | } 298 | 299 | return "", "", fmt.Errorf("failed to authenticate: bad status code %q: %s", resp.Status, string(buf)) 300 | } 301 | 302 | func (c *Client) doRequest(req *http.Request, reqOps ...RequestOp) ([]byte, error) { 303 | log.Debugf("HTTP %s on: %s", req.Method, req.URL) 304 | log.Tracef("HTTP request: %+v", req) 305 | resp, err := c.doRawRequest(req, reqOps...) 306 | if err != nil { 307 | return nil, err 308 | } 309 | if resp.Body != nil { 310 | defer resp.Body.Close() //nolint:errcheck 311 | } 312 | log.Tracef("HTTP response: %+v", resp) 313 | 314 | if resp.StatusCode == http.StatusNotFound { 315 | return nil, ¬FoundError{} 316 | } 317 | 318 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 319 | if resp.StatusCode == http.StatusForbidden { 320 | return nil, &forbiddenError{} 321 | } 322 | buf, err := io.ReadAll(resp.Body) 323 | log.Debugf("bad status code %q: %s", resp.Status, buf) 324 | if err == nil { 325 | if ok, err := extractError(buf, resp); ok { 326 | return nil, err 327 | } 328 | } 329 | return nil, fmt.Errorf("bad status code %q", resp.Status) 330 | } 331 | buf, err := io.ReadAll(resp.Body) 332 | log.Tracef("HTTP response body: %s", buf) 333 | if err != nil { 334 | return nil, err 335 | } 336 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 337 | return nil, fmt.Errorf("bad status code %q: %s", resp.Status, string(buf)) 338 | } 339 | 340 | return buf, nil 341 | } 342 | 343 | func (c *Client) doRawRequest(req *http.Request, reqOps ...RequestOp) (*http.Response, error) { 344 | req.Header["Accept"] = []string{"application/json"} 345 | req.Header["Content-Type"] = []string{"application/json"} 346 | req.Header["User-Agent"] = []string{fmt.Sprintf("hub-tool/%s", internal.Version)} 347 | for _, op := range reqOps { 348 | if err := op(req); err != nil { 349 | return nil, err 350 | } 351 | } 352 | if c.Ctx != nil { 353 | req = req.WithContext(c.Ctx) 354 | } 355 | return c.client.Do(req) 356 | } 357 | 358 | func extractError(buf []byte, resp *http.Response) (bool, error) { 359 | var responseBody map[string]string 360 | if err := json.Unmarshal(buf, &responseBody); err == nil { 361 | for _, k := range []string{"message", "detail"} { 362 | if msg, ok := responseBody[k]; ok { 363 | return true, fmt.Errorf("failed to authenticate: bad status code %q: %s", resp.Status, msg) 364 | } 365 | } 366 | } 367 | return false, nil 368 | } 369 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------