Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

add support for specifying container image platforms to search for #14

Merged
merged 2 commits into from
Jan 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ changelog:
use: github-native
source:
enabled: true
rlcp: true
sboms:
- id: archive
artifacts: archive
Expand Down
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,32 @@ go install github.com/Hsn723/container-tag-exists@latest
## Usage

```sh
container-tag-exists IMAGE TAG
Usage:
container-tag-exists IMAGE TAG [flags]
container-tag-exists [command]

Available Commands:
completion Generate the autocompletion script for the specified shell
help Help about any command
version show version

Flags:
-h, --help help for container-tag-exists
-p, --platform strings specify platforms in the format os/arch to look for in container images. Default behavior is to look for any platform.
```

If `IMAGE:TAG` exists, this simply outputs `found`. This is intended to be used in CI environments to automate checking for existing container images before pushing.
If `IMAGE:TAG` exists, this simply outputs `found`. This is intended to be used in CI environments to automate checking for existing container images before pushing. By default, `container-tag-exists` looks for any existing container image with the given tag.

```sh
container-tag-exists ghcr.io/example 0.0.0
```

If you additionally need to check for specific platforms, specify platform strings, in the format `os/arch`, to check for.

```sh
container-tag-exists ghcr.io/example 0.0.0 -p linux/amd64,linux/arm64
container-tag-exists ghcr.io/example 0.0.0 -p linux/amd64 -p linux/arm64
```

## Configuration

Expand Down
4 changes: 4 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ var (
Args: cobra.ExactArgs(2),
RunE: runRoot,
}

platforms []string
)

func init() {
_ = rootCmd.LocalFlags().MarkHidden("logfile")
_ = rootCmd.LocalFlags().MarkHidden("loglevel")
_ = rootCmd.LocalFlags().MarkHidden("logformat")
rootCmd.Flags().StringSliceVarP(&platforms, "platform", "p", nil, "specify platforms in the format os/arch to look for in container images. Default behavior is to look for any platform.")
}

func runRoot(cmd *cobra.Command, args []string) error {
Expand All @@ -45,6 +48,7 @@ func runRoot(cmd *cobra.Command, args []string) error {
TLSHandshakeTimeout: 10 * time.Second,
},
},
Platforms: platforms,
}
hasTag, err := registryClient.IsTagExist(args[1])
if err != nil {
Expand Down
53 changes: 49 additions & 4 deletions pkg/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"net/http"
"os"
"strings"
)

var (
Expand All @@ -24,12 +25,26 @@ type RegistryClient struct {
RegistryURL string
ImagePath string
HttpClient *http.Client
Platforms []string
}

type tokenResponse struct {
Token string `json:"token"`
}

type manifestResponse struct {
Manifests []manifest `json:"manifests"`
}

type manifest struct {
Platform platform `json:"platform"`
}

type platform struct {
Architecture string `json:"architecture"`
Os string `json:"os"`
}

func (r RegistryClient) retrieve(method, endpoint, auth string) (int, []byte, error) {
req, err := http.NewRequest(method, endpoint, nil)
if err != nil {
Expand Down Expand Up @@ -69,22 +84,52 @@ func (r RegistryClient) retrieveBearerToken(auth string) (string, error) {
return token.Token, nil
}

func (r RegistryClient) hasPlatform(platform string, manifests []manifest) bool {
for _, m := range manifests {
p := fmt.Sprintf("%s/%s", m.Platform.Os, m.Platform.Architecture)
if strings.EqualFold(p, platform) {
return true
}
}
return false
}

func (r RegistryClient) hasPlatforms(res []byte) (bool, error) {
var manifests manifestResponse
if err := json.Unmarshal(res, &manifests); err != nil {
return false, err
}
for _, p := range r.Platforms {
if !r.hasPlatform(p, manifests.Manifests) {
return false, nil
}
}
return true, nil
}

func (r RegistryClient) checkManifestForTag(bearer, tag string) (bool, error) {
endpoint := fmt.Sprintf(manifestAPI, r.RegistryURL, r.ImagePath, tag)
auth := ""
if bearer != "" {
auth = fmt.Sprintf("Bearer %s", bearer)
}
status, _, err := r.retrieve(http.MethodHead, endpoint, auth)
method := http.MethodHead
if r.Platforms != nil {
method = http.MethodGet
}
status, res, err := r.retrieve(method, endpoint, auth)
if err != nil {
return false, err
}
if status == http.StatusOK {
return true, nil
}
if status == http.StatusNotFound {
return false, nil
}
if status == http.StatusOK {
if r.Platforms == nil {
return true, nil
}
return r.hasPlatforms(res)
}
return false, fmt.Errorf("unexpected response registry API: %d", status)
}

Expand Down
51 changes: 51 additions & 0 deletions pkg/registry_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package pkg

import (
_ "embed"
"encoding/json"
"fmt"
"net/http"
Expand All @@ -12,6 +13,11 @@ import (
"github.com/stretchr/testify/assert"
)

var (
//go:embed t/sample.json
sampleManifest []byte
)

type mockRegistry struct {
t *testing.T
tags []string
Expand Down Expand Up @@ -496,3 +502,48 @@ func TestIsTagExist(t *testing.T) {
})
}
}

func TestHasPlatforms(t *testing.T) {
t.Parallel()
cases := []struct {
title string
platforms []string
response []byte
expected bool
isErr bool
}{
{
title: "SinglePlatform",
platforms: []string{"linux/amd64"},
response: sampleManifest,
expected: true,
},
{
title: "MultiPlatform",
platforms: []string{"linux/amd64", "linux/arm64"},
response: sampleManifest,
expected: true,
},
{
title: "MissingPlatform",
platforms: []string{"linux/amd64", "darwin/arm64"},
response: sampleManifest,
},
{
title: "UnmarshalError",
platforms: []string{"linux/amd64"},
response: []byte("{hoge}"),
isErr: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.title, func(t *testing.T) {
t.Helper()
client := RegistryClient{Platforms: tc.platforms}
actual, err := client.hasPlatforms(tc.response)
assertExpectedErr(t, err, tc.isErr)
assert.Equal(t, tc.expected, actual)
})
}
}
24 changes: 24 additions & 0 deletions pkg/t/sample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
"schemaVersion": 2,
"manifests": [
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"digest": "sha256:232479a01040fd2b02f10c568eb3860b52843f6a0c23a96e843ee80f22f3fdc7",
"size": 1786,
"platform": {
"architecture": "amd64",
"os": "linux"
}
},
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"digest": "sha256:9b6ce0b6aac841b356d19ebaad2860a849cf4b69b35a564f523eb1c3d07b3dea",
"size": 1786,
"platform": {
"architecture": "arm64",
"os": "linux"
}
}
]
}