Skip to content

Commit 0f5fa44

Browse files
committed
host-ctr: replace amazon-ecr-containerd-resolver with Docker resolver
Replace the amazon-ecr-containerd-resolver dependency with direct implementation using containerd's Docker resolver. Signed-off-by: Kyle Sessions <kssessio@amazon.com>
1 parent b731cdf commit 0f5fa44

5 files changed

Lines changed: 484 additions & 538 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"errors"
7+
"fmt"
8+
"regexp"
9+
"strings"
10+
11+
"github.com/aws/aws-sdk-go-v2/aws"
12+
"github.com/aws/aws-sdk-go-v2/config"
13+
"github.com/aws/aws-sdk-go-v2/service/ecr"
14+
"github.com/aws/aws-sdk-go-v2/service/ecrpublic"
15+
"github.com/containerd/containerd"
16+
"github.com/containerd/containerd/reference"
17+
"github.com/containerd/containerd/remotes/docker"
18+
"github.com/containerd/log"
19+
)
20+
21+
// specialRegionEndpoints supports regions not yet included in the AWS GO SDK.
22+
// ap-southeast-7 is currently in the SDK but persisted here for future region builds.
23+
var specialRegionEndpoints = map[string]string{
24+
"ap-southeast-7": "https://api.ecr.ap-southeast-7.amazonaws.com",
25+
}
26+
27+
// ecrRegex matches ECR image names of the form:
28+
//
29+
// Example 1: 777777777777.dkr.ecr.us-west-2.amazonaws.com/my_image:latest
30+
// Example 2: 777777777777.dkr.ecr.cn-north-1.amazonaws.com.cn/my_image:latest
31+
// Example 3: 777777777777.dkr.ecr.eu-isoe-west-1.cloud.adc-e.uk/my_image:latest
32+
// Example 4: 777777777777.dkr.ecr-fips.us-west-2.amazonaws.com/my_image:latest
33+
//
34+
// Capture groups: [1] = account ID, [2] = "-fips" or empty, [3] = region
35+
//
36+
// ECR hostname pattern also used in the ecr-credential-provider:
37+
// https://github.com/kubernetes/cloud-provider-aws/blob/212135d0d7b448cd34e2e11e5e81f59e3e6c2d7a/cmd/ecr-credential-provider/main.go#L45
38+
var ecrRegex = regexp.MustCompile(`^(\d{12})\.dkr[\.\-]ecr(-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.(amazonaws(\.com(?:\.cn)?|\.eu)|on\.(?:aws|amazonwebservices\.com\.cn)|sc2s\.sgov\.gov|c2s\.ic\.gov|cloud\.adc-e\.uk|csp\.hci\.ic\.gov).*$`)
39+
40+
const ecrPublicHost = "public.ecr.aws"
41+
const ecrPublicRegion = "us-east-1"
42+
43+
// A set of the currently supported FIPS regions for ECR: https://docs.aws.amazon.com/general/latest/gr/ecr.html
44+
// FIPS-supported ECR regions: https://docs.aws.amazon.com/general/latest/gr/ecr.html
45+
var fipsSupportedEcrRegionSet = map[string]bool{
46+
"us-east-1": true,
47+
"us-east-2": true,
48+
"us-west-1": true,
49+
"us-west-2": true,
50+
"us-gov-east-1": true,
51+
"us-gov-west-1": true,
52+
}
53+
54+
// parsedECR contains the parsed components of an ECR image URI.
55+
type parsedECR struct {
56+
Region string
57+
Account string
58+
RepoPath string
59+
Fips bool
60+
}
61+
62+
// extractHostFromRef extracts the registry hostname from an image reference.
63+
func extractHostFromRef(ref string) (string, error) {
64+
parsed, err := reference.Parse(ref)
65+
if err != nil {
66+
return "", fmt.Errorf("failed to parse reference: %w", err)
67+
}
68+
return parsed.Hostname(), nil
69+
}
70+
71+
// parseImageURIAsECR parses an ECR image URI and extracts metadata including
72+
// the region, account, repository path, and whether it's a FIPS endpoint.
73+
func parseImageURIAsECR(input string) (*parsedECR, error) {
74+
matches := ecrRegex.FindStringSubmatch(input)
75+
76+
if len(matches) == 0 {
77+
return nil, fmt.Errorf("invalid image URI: %s", input)
78+
}
79+
account := matches[1]
80+
81+
// Need to include the full repository path and the imageID (e.g. /eks/image-name:tag)
82+
tokens := strings.SplitN(input, "/", 2)
83+
if len(tokens) != 2 {
84+
return nil, fmt.Errorf("invalid image URI: %s", input)
85+
}
86+
fullRepoPath := tokens[len(tokens)-1]
87+
// Run simple checks on the provided repository.
88+
switch {
89+
case
90+
// Must not be empty
91+
fullRepoPath == "",
92+
// Must not have a partial/unsupplied label
93+
strings.HasSuffix(fullRepoPath, ":"),
94+
// Must not have a partial/unsupplied digest specifier
95+
strings.HasSuffix(fullRepoPath, "@"):
96+
return nil, errors.New("incomplete reference provided")
97+
}
98+
99+
isFips := matches[2] == "-fips"
100+
region := matches[3]
101+
102+
// Validate FIPS region is supported
103+
if isFips {
104+
if _, ok := fipsSupportedEcrRegionSet[region]; !ok {
105+
return nil, fmt.Errorf("invalid FIPS region: %s", region)
106+
}
107+
}
108+
109+
return &parsedECR{
110+
Region: region,
111+
Account: account,
112+
RepoPath: fullRepoPath,
113+
Fips: isFips,
114+
}, nil
115+
}
116+
117+
// decodeECRToken decodes a base64 ECR token and returns username and password.
118+
func decodeECRToken(token *string) (string, string, error) {
119+
if token == nil {
120+
return "", "", errors.New("missing authorization token")
121+
}
122+
123+
authToken, err := base64.StdEncoding.DecodeString(*token)
124+
if err != nil {
125+
return "", "", fmt.Errorf("failed to decode authorization token: %w", err)
126+
}
127+
128+
if len(authToken) == 0 {
129+
return "", "", errors.New("authorization token is empty after base64 decoding")
130+
}
131+
132+
tokens := strings.SplitN(string(authToken), ":", 2)
133+
if len(tokens) != 2 {
134+
return "", "", errors.New("invalid authorization token format")
135+
}
136+
137+
return tokens[0], tokens[1], nil
138+
}
139+
140+
// getECRPrivateCredentials fetches authorization credentials for private ECR registries.
141+
func getECRPrivateCredentials(ctx context.Context, region string, useFIPS bool) (string, string, error) {
142+
cfgOpts := []func(*config.LoadOptions) error{config.WithRegion(region)}
143+
144+
if useFIPS {
145+
cfgOpts = append(cfgOpts, config.WithUseFIPSEndpoint(aws.FIPSEndpointStateEnabled))
146+
}
147+
148+
cfg, err := config.LoadDefaultConfig(ctx, cfgOpts...)
149+
if err != nil {
150+
return "", "", fmt.Errorf("failed to load AWS config for region %s: %w", region, err)
151+
}
152+
153+
log.G(ctx).WithField("region", region).WithField("fips", useFIPS).Info("setting up ECR client")
154+
155+
var client *ecr.Client
156+
if endpoint, ok := specialRegionEndpoints[region]; ok {
157+
log.G(ctx).WithField("region", region).WithField("endpoint", endpoint).Info("using special region endpoint")
158+
client = ecr.NewFromConfig(cfg, func(o *ecr.Options) {
159+
o.BaseEndpoint = aws.String(endpoint)
160+
})
161+
} else {
162+
client = ecr.NewFromConfig(cfg)
163+
}
164+
165+
output, err := client.GetAuthorizationToken(ctx, &ecr.GetAuthorizationTokenInput{})
166+
if err != nil {
167+
return "", "", fmt.Errorf("failed to get ECR authorization token: %w", err)
168+
}
169+
170+
if output == nil || len(output.AuthorizationData) == 0 {
171+
return "", "", fmt.Errorf("no authorization data returned")
172+
}
173+
174+
return decodeECRToken(output.AuthorizationData[0].AuthorizationToken)
175+
}
176+
177+
// getECRPublicCredentials fetches authorization credentials for ECR Public registries using us-east-1.
178+
func getECRPublicCredentials(ctx context.Context) (string, string, error) {
179+
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(ecrPublicRegion))
180+
if err != nil {
181+
return "", "", fmt.Errorf("failed to load AWS config for ECR Public (%s): %w", ecrPublicRegion, err)
182+
}
183+
184+
client := ecrpublic.NewFromConfig(cfg)
185+
output, err := client.GetAuthorizationToken(ctx, &ecrpublic.GetAuthorizationTokenInput{})
186+
if err != nil {
187+
return "", "", fmt.Errorf("failed to get ECR Public authorization token: %w", err)
188+
}
189+
190+
if output == nil || output.AuthorizationData == nil {
191+
return "", "", errors.New("missing authorization data")
192+
}
193+
194+
return decodeECRToken(output.AuthorizationData.AuthorizationToken)
195+
}
196+
197+
// withECRPrivateResolver creates a resolver for private ECR registries.
198+
// Returns an error if credentials cannot be obtained - private ECR requires
199+
// authentication.
200+
func withECRPrivateResolver(ctx context.Context, ref string) containerd.RemoteOpt {
201+
return func(_ *containerd.Client, c *containerd.RemoteContext) error {
202+
parsedECR, err := parseImageURIAsECR(ref)
203+
if err != nil {
204+
return fmt.Errorf("failed to parse ECR reference: %w", err)
205+
}
206+
207+
username, password, err := getECRPrivateCredentials(ctx, parsedECR.Region, parsedECR.Fips)
208+
if err != nil {
209+
return fmt.Errorf("failed to get private ECR credentials for region %s: %w", parsedECR.Region, err)
210+
}
211+
212+
ecrHost, err := extractHostFromRef(ref)
213+
if err != nil {
214+
return fmt.Errorf("failed to extract host from reference: %w", err)
215+
}
216+
217+
authOpt := docker.WithAuthCreds(func(host string) (string, string, error) {
218+
if host != ecrHost {
219+
return "", "", fmt.Errorf("ecr-private: unexpected host %s, expected %s", host, ecrHost)
220+
}
221+
return username, password, nil
222+
})
223+
authorizer := docker.NewDockerAuthorizer(authOpt)
224+
c.Resolver = docker.NewResolver(docker.ResolverOptions{
225+
// TODO: Consider adding support for user-provided credentials with registryConfig as fallback,
226+
// similar to the ECRPublicResolver, however this behavior is getting deprecated soon in containerd
227+
Hosts: registryHosts(nil, &authorizer),
228+
})
229+
230+
log.G(ctx).WithField("ref", ref).WithField("region", parsedECR.Region).Info("pulling private ECR image")
231+
return nil
232+
}
233+
}
234+
235+
// withECRPublicResolver creates a resolver for ECR Public registries.
236+
// Falls back to unauthenticated pull if credentials cannot be obtained since
237+
// ECR Public supports anonymous access.
238+
func withECRPublicResolver(ctx context.Context, ref string, registryConfig *RegistryConfig, defaultResolver containerd.RemoteOpt) containerd.RemoteOpt {
239+
if registryConfig != nil {
240+
if _, found := registryConfig.Credentials[ecrPublicHost]; found {
241+
return defaultResolver
242+
}
243+
}
244+
245+
username, password, err := getECRPublicCredentials(ctx)
246+
if err != nil {
247+
log.G(ctx).WithError(err).Warn("ecr-public: failed to get credentials, falling back to unauthenticated pull")
248+
return defaultResolver
249+
}
250+
251+
authOpt := docker.WithAuthCreds(func(host string) (string, string, error) {
252+
if host != ecrPublicHost {
253+
return "", "", fmt.Errorf("ecr-public: unexpected host %s, expected %s", host, ecrPublicHost)
254+
}
255+
return username, password, nil
256+
})
257+
authorizer := docker.NewDockerAuthorizer(authOpt)
258+
259+
return func(_ *containerd.Client, c *containerd.RemoteContext) error {
260+
c.Resolver = docker.NewResolver(docker.ResolverOptions{
261+
Hosts: registryHosts(registryConfig, &authorizer),
262+
})
263+
log.G(ctx).WithField("ref", ref).Info("pulling from ECR Public")
264+
return nil
265+
}
266+
}

0 commit comments

Comments
 (0)