Skip to content

Commit 62e80f1

Browse files
chingor13codyoss
andauthored
feat: add ability to find the latest docker image SHA (#2539)
Towards #2342 Fixes #2545 --------- Signed-off-by: Jeff Ching <[email protected]> Co-authored-by: Cody Oss <[email protected]>
1 parent 23b8ffe commit 62e80f1

File tree

5 files changed

+315
-0
lines changed

5 files changed

+315
-0
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/googleapis/librarian
33
go 1.25.0
44

55
require (
6+
cloud.google.com/go/artifactregistry v1.17.2
67
cloud.google.com/go/cloudbuild v1.23.0
78
cloud.google.com/go/iam v1.5.2
89
cloud.google.com/go/longrunning v0.6.7

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
cloud.google.com/go v0.122.0 h1:0JTLGrcSIs3HIGsgVPvTx3cfyFSP/k9CI8vLPHTd6Wc=
22
cloud.google.com/go v0.122.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
3+
cloud.google.com/go/artifactregistry v1.17.2 h1:Gx5vsnIFEx+obM1VdtMF2AuTraYESRCrBxvc9+6jBZg=
4+
cloud.google.com/go/artifactregistry v1.17.2/go.mod h1:h4CIl9TJZskg9c9u1gC9vTsOTo1PrAnnxntprqS3AjM=
35
cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI=
46
cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ=
57
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=

internal/images/images.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright 2025 Google LLC
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+
// https://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+
// Package images provides operations around docker images.
16+
package images
17+
18+
import (
19+
"context"
20+
"fmt"
21+
"log/slog"
22+
"strings"
23+
24+
artifactregistry "cloud.google.com/go/artifactregistry/apiv1"
25+
artifactregistrypb "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb"
26+
)
27+
28+
// ArtifactRegistryClient is the implementation of ImageRegistryClient
29+
// to interact with Artifact Registry.
30+
type ArtifactRegistryClient struct {
31+
client *artifactregistry.Client
32+
}
33+
34+
// containerImage is a data structure for parsing Docker image parameters.
35+
type containerImage struct {
36+
// Name is the short name of the docker image.
37+
Name string
38+
// Tag is the named tag of the docker image.
39+
Tag string
40+
// SHA is the SHA256 (e.g. `sha256:abcd1234`).
41+
SHA string
42+
// Location is the Artifact Registry location (e.g. `us-central1`).
43+
Location string
44+
// Project is the name of the GCP project that holds the AR repository.
45+
Project string
46+
// Repository is the name of the AR repository.
47+
Repository string
48+
}
49+
50+
// BaseName returns the image name without a pinned SHA or tag.
51+
func (i *containerImage) BaseName() string {
52+
return fmt.Sprintf("%s-docker.pkg.dev/%s/%s/%s", i.Location, i.Project, i.Repository, i.Name)
53+
}
54+
55+
// String returns the image name with pinned SHA or tag.
56+
func (i *containerImage) String() string {
57+
var b strings.Builder
58+
b.WriteString(i.BaseName())
59+
if i.SHA != "" {
60+
b.WriteString("@")
61+
b.WriteString(i.SHA)
62+
} else if i.Tag != "" {
63+
b.WriteString(":")
64+
b.WriteString(i.Tag)
65+
}
66+
return b.String()
67+
}
68+
69+
// NewArtifactRegistryClient creates a new ArtifactRegistryClient.
70+
func NewArtifactRegistryClient(ctx context.Context) (*ArtifactRegistryClient, error) {
71+
client, err := artifactregistry.NewClient(ctx)
72+
if err != nil {
73+
return nil, err
74+
}
75+
return &ArtifactRegistryClient{
76+
client: client,
77+
}, nil
78+
}
79+
80+
// Close cleans up any open resources.
81+
func (c *ArtifactRegistryClient) Close() error {
82+
return c.client.Close()
83+
}
84+
85+
// FindLatest returns the latest docker image given a current image.
86+
func (c *ArtifactRegistryClient) FindLatest(ctx context.Context, imageName string) (string, error) {
87+
image, err := parseImage(imageName)
88+
if err != nil {
89+
return "", err
90+
}
91+
92+
if c.client == nil {
93+
return "", fmt.Errorf("no client configured")
94+
}
95+
96+
it := c.client.ListVersions(ctx, &artifactregistrypb.ListVersionsRequest{
97+
Parent: fmt.Sprintf("projects/%s/locations/%s/repositories/%s/packages/%s", image.Project, image.Location, image.Repository, image.Name),
98+
View: artifactregistrypb.VersionView_FULL,
99+
OrderBy: "create_time DESC",
100+
})
101+
version, err := it.Next()
102+
if err != nil {
103+
return "", err
104+
}
105+
slog.Info("Found packages version", "version", version.GetName())
106+
107+
// latest SHA is found as the "subjectDigest" metadata field
108+
latestSha := ""
109+
for key, field := range version.GetMetadata().GetFields() {
110+
if key == "subjectDigest" {
111+
slog.Info("Found SHA", "sha", field.GetStringValue())
112+
latestSha = field.GetStringValue()
113+
break
114+
}
115+
}
116+
117+
if latestSha == "" {
118+
return "", fmt.Errorf("failed to find updated SHA for latest version: %s", version.GetName())
119+
}
120+
121+
newImage := &containerImage{
122+
Name: image.Name,
123+
Location: image.Location,
124+
Repository: image.Repository,
125+
Project: image.Project,
126+
SHA: latestSha,
127+
}
128+
return newImage.String(), nil
129+
}
130+
131+
func parseImage(pinnedImage string) (*containerImage, error) {
132+
parsedImage := &containerImage{}
133+
baseName := ""
134+
135+
atParts := strings.Split(pinnedImage, "@")
136+
colonParts := strings.Split(pinnedImage, ":")
137+
if len(atParts) == 2 {
138+
baseName = atParts[0]
139+
parsedImage.SHA = atParts[1]
140+
} else if len(colonParts) == 2 {
141+
baseName = colonParts[0]
142+
parsedImage.Tag = colonParts[1]
143+
}
144+
145+
if baseName == "" {
146+
slog.Info("image does not appear to be pinned")
147+
baseName = pinnedImage
148+
}
149+
150+
parts := strings.Split(baseName, "/")
151+
if len(parts) < 4 {
152+
return nil, fmt.Errorf("unexpected image format %q, expected an AR formatted image", baseName)
153+
}
154+
155+
host := parts[0]
156+
if strings.HasSuffix(host, "-docker.pkg.dev") {
157+
parsedImage.Location = strings.TrimSuffix(host, "-docker.pkg.dev")
158+
} else {
159+
return nil, fmt.Errorf("unexpected host format %q, expected AR formatted host with -docker.pkg.dev suffix", host)
160+
}
161+
162+
parsedImage.Project = parts[1]
163+
parsedImage.Repository = parts[2]
164+
parsedImage.Name = parts[3]
165+
166+
return parsedImage, nil
167+
}

internal/images/images_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright 2025 Google LLC
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+
// https://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+
// Package images provides operations around docker images.
16+
package images
17+
18+
import (
19+
"testing"
20+
21+
"github.com/google/go-cmp/cmp"
22+
)
23+
24+
func TestParseImage(t *testing.T) {
25+
for _, test := range []struct {
26+
name string
27+
image string
28+
want *containerImage
29+
wantErr bool
30+
}{
31+
{
32+
name: "AR unpinned",
33+
image: "us-central1-docker.pkg.dev/some-project/some-repo/some-image",
34+
want: &containerImage{
35+
Name: "some-image",
36+
Location: "us-central1",
37+
Project: "some-project",
38+
Repository: "some-repo",
39+
},
40+
},
41+
{
42+
name: "AR pinned SHA",
43+
image: "us-central1-docker.pkg.dev/some-project/some-repo/some-image@sha256:abcdef",
44+
want: &containerImage{
45+
Name: "some-image",
46+
Location: "us-central1",
47+
Project: "some-project",
48+
Repository: "some-repo",
49+
SHA: "sha256:abcdef",
50+
},
51+
},
52+
{
53+
name: "AR tagged",
54+
image: "us-central1-docker.pkg.dev/some-project/some-repo/some-image:1.2.3",
55+
want: &containerImage{
56+
Name: "some-image",
57+
Location: "us-central1",
58+
Project: "some-project",
59+
Repository: "some-repo",
60+
Tag: "1.2.3",
61+
},
62+
},
63+
} {
64+
t.Run(test.name, func(t *testing.T) {
65+
t.Parallel()
66+
got, err := parseImage(test.image)
67+
if (err != nil) != test.wantErr {
68+
t.Errorf("parseImage() error = %v, wantErr %v", err, test.wantErr)
69+
return
70+
}
71+
if diff := cmp.Diff(test.want, got); diff != "" {
72+
t.Errorf("parseImage() mismatch (-want +got):\n%s", diff)
73+
}
74+
str := got.String()
75+
if diff := cmp.Diff(str, test.image); diff != "" {
76+
t.Errorf("image.String() mismatch (-want +got):\n%s", diff)
77+
}
78+
})
79+
}
80+
}

system_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ import (
2828
"github.com/google/go-cmp/cmp"
2929
"github.com/googleapis/librarian/internal/github"
3030
"github.com/googleapis/librarian/internal/gitrepo"
31+
"github.com/googleapis/librarian/internal/images"
3132
)
3233

3334
var testToken = os.Getenv("TEST_GITHUB_TOKEN")
35+
var githubAction = os.Getenv("GITHUB_ACTION")
3436

3537
func TestGetRawContentSystem(t *testing.T) {
3638
if testToken == "" {
@@ -440,6 +442,69 @@ func TestCreateRelease(t *testing.T) {
440442
}
441443
}
442444

445+
func TestFindLatestImage(t *testing.T) {
446+
// If we are able to configure system tests on GitHub actions, then update this
447+
// guard clause.
448+
if githubAction != "" {
449+
t.Skip("skipping on GitHub actions")
450+
}
451+
for _, test := range []struct {
452+
name string
453+
image string
454+
wantDiff bool
455+
wantErr bool
456+
}{
457+
{
458+
name: "AR unpinned",
459+
image: "us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/librarian-go@sha256:dea280223eca5a0041fb5310635cec9daba2f01617dbfb1e47b90c77368b5620",
460+
wantDiff: true,
461+
},
462+
{
463+
name: "AR pinned",
464+
image: "us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/librarian-go@sha256:dea280223eca5a0041fb5310635cec9daba2f01617dbfb1e47b90c77368b5620",
465+
wantDiff: true,
466+
},
467+
{
468+
name: "invalid image",
469+
image: "gcr.io/some-project/some-name",
470+
wantErr: true,
471+
},
472+
} {
473+
t.Run(test.name, func(t *testing.T) {
474+
t.Parallel()
475+
client, err := images.NewArtifactRegistryClient(t.Context())
476+
if err != nil {
477+
t.Fatalf("unexpected error in NewArtifactRegistryClient() %v", err)
478+
}
479+
defer client.Close()
480+
got, err := client.FindLatest(t.Context(), test.image)
481+
if test.wantErr {
482+
if err == nil {
483+
t.Errorf("FindLatestImage() error = %v, wantErr %v", err, test.wantErr)
484+
}
485+
return
486+
}
487+
488+
if err != nil {
489+
t.Fatalf("FindLatestImage() error = %v", err)
490+
}
491+
492+
if !strings.HasPrefix(got, "us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/librarian-go@sha256:") {
493+
t.Fatalf("FindLatestImage() unexpected image format")
494+
}
495+
if test.wantDiff {
496+
if got == test.image {
497+
t.Fatalf("FindLatestImage() expected to change")
498+
}
499+
} else {
500+
if got != test.image {
501+
t.Fatalf("FindLatestImage() expected to stay the same")
502+
}
503+
}
504+
})
505+
}
506+
}
507+
443508
func TestGitCheckout(t *testing.T) {
444509
t.Parallel()
445510
for _, test := range []struct {

0 commit comments

Comments
 (0)