Skip to content

Commit 9f148c1

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 <[email protected]>
1 parent 7ae7116 commit 9f148c1

File tree

5 files changed

+462
-522
lines changed

5 files changed

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

0 commit comments

Comments
 (0)