-
Notifications
You must be signed in to change notification settings - Fork 18
Add container registry mirroring #315
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
Merged
Merged
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
a9a5bfb
Initial implementation
johnstairs 52a9c9a
hardening
johnstairs 0589ac5
Helm logs fix
johnstairs 8936df2
Fix digest-pinned imports
johnstairs ec2332b
Fixes
johnstairs 6eabe8a
Add preflight check
johnstairs ee45863
Rename field
johnstairs a2badb3
Add to privatelink config
johnstairs 95645fe
Add original registry to repo
johnstairs 78f0d5f
Update preflight
johnstairs 273adcf
Harden exchangeAcrRefreshToken
johnstairs 2316d2b
Cleanup
johnstairs a47ddd5
go mod tidy
johnstairs 4c66047
Update notice
johnstairs da0c629
Do not enable extensionmanager
johnstairs 8a5a058
Fix incorrect comment
johnstairs 3ecee48
copilot feedback
johnstairs d1871d4
Allow individual Helm charts to of out of mirroring
johnstairs c6330fb
Do not mirror helm charts
johnstairs e178132
Harden TestHttpProxy
johnstairs 3d131ae
Add health check for Squid service in integration tests
johnstairs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| 92d6f614b26f27126cc196c7cc613612f372968955dfeb2d1c55d5092ea8f7a2 cli/go.sum | ||
| c7bdd90b22fa3a3837dcb7451aaf5f5796036b0a81dfb8432d1cfa41891a2786 cli/go.sum | ||
| 2445cdf7f0165c1dccbe3ad8e8652b868b1e7901bc629c1d8e33c4d9890ad7c7 server/ControlPlane/packages.lock.json | ||
| 8a61e6fadd8f336d262161674d0ada617ba9e295047d097adddd4cab3e036f76 server/DataPlane/packages.lock.json | ||
| 201d533615b833962d871e998a69367e6ec64aaa3871555054451f760a6bfd18 scripts/generate-notice.sh | ||
| dcec8a3ad1500b77af0811f64ef252352bf0ab486e987020438d6d679992ff6e NOTICE.txt | ||
| c08e9948e8eb49b0ac81d88b73ff6aec662d6681647ff14983638c4b98ecdd44 NOTICE.txt |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,229 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| package cloudinstall | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "net/url" | ||
| "strings" | ||
|
|
||
| "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" | ||
| "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry" | ||
| "github.com/microsoft/tyger/cli/internal/client" | ||
| helmclient "github.com/mittwald/go-helm-client" | ||
| helmregistry "helm.sh/helm/v3/pkg/registry" | ||
| ) | ||
|
|
||
| // Returns the *.azurecr.io host of an OCI chart reference (e.g. for | ||
| // "oci://foo.azurecr.io/helm/bar" returns "foo.azurecr.io", true). Returns | ||
| // false for non-OCI refs or registries that aren't ACRs. | ||
| func acrHostFromOciRef(ref string) (string, bool) { | ||
| if !strings.HasPrefix(ref, "oci://") { | ||
| return "", false | ||
| } | ||
| rest := strings.TrimPrefix(ref, "oci://") | ||
| host, _, _ := strings.Cut(rest, "/") | ||
| if !strings.HasSuffix(host, ".azurecr.io") { | ||
| return "", false | ||
| } | ||
| return host, true | ||
| } | ||
|
|
||
| // Captures the runtime-resolved properties of an Azure Container Registry: | ||
| // its short name, fully-qualified login server, and the resource group it | ||
| // lives in. | ||
| type ResolvedAcr struct { | ||
| Name string | ||
| LoginServer string | ||
| ResourceGroup string | ||
| } | ||
|
|
||
| type acrImportPaths struct { | ||
| SourceImage string | ||
| // Exactly one target mode is set. ACR's ImportImage API accepts repo[:tag] | ||
| // values in TargetTags, so tagged sources can be copied directly to the | ||
| // mirrored tag. Digest-pinned sources are different: the rendered manifests | ||
| // keep using repo@sha256:..., but TargetTags cannot contain a digest. For | ||
| // those, request a manifest-only copy into the target repository so the same | ||
| // digest is addressable from the mirror without inventing a tag. | ||
| TargetTag string | ||
| TargetRepositoryForDigest string | ||
| } | ||
|
|
||
| // Looks up the login server FQDN and resource group of the named ACR. | ||
| func (inst *Installer) resolveAcr(ctx context.Context, acrName string) (*ResolvedAcr, error) { | ||
| resourceID, err := getContainerRegistryId(ctx, acrName, inst.Config.Cloud.SubscriptionID, inst.Credential) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to find ACR '%s': %w", acrName, err) | ||
| } | ||
|
|
||
| // Resource ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/... | ||
| parts := strings.Split(resourceID, "/") | ||
| var resourceGroup string | ||
| for i, p := range parts { | ||
| if strings.EqualFold(p, "resourceGroups") && i+1 < len(parts) { | ||
| resourceGroup = parts[i+1] | ||
| break | ||
| } | ||
| } | ||
| if resourceGroup == "" { | ||
| return nil, fmt.Errorf("failed to parse resource group from ACR resource ID: %s", resourceID) | ||
| } | ||
|
|
||
| client, err := armcontainerregistry.NewRegistriesClient(inst.Config.Cloud.SubscriptionID, inst.Credential, nil) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| resp, err := client.Get(ctx, resourceGroup, acrName, nil) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to get ACR '%s': %w", acrName, err) | ||
| } | ||
| if resp.Properties == nil || resp.Properties.LoginServer == nil { | ||
| return nil, fmt.Errorf("ACR '%s' has no login server", acrName) | ||
| } | ||
|
|
||
| return &ResolvedAcr{ | ||
| Name: acrName, | ||
| LoginServer: *resp.Properties.LoginServer, | ||
| ResourceGroup: resourceGroup, | ||
| }, nil | ||
| } | ||
|
|
||
| // Imports an image into target using the ARM ImportImage API. | ||
| func (inst *Installer) importImageToAcr(ctx context.Context, target *ResolvedAcr, sourceRegistryHost string, paths acrImportPaths) error { | ||
| client, err := armcontainerregistry.NewRegistriesClient(inst.Config.Cloud.SubscriptionID, inst.Credential, nil) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to create container registry client: %w", err) | ||
| } | ||
|
|
||
| source, err := inst.makeImportSource(ctx, sourceRegistryHost, paths.SourceImage) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| parameters := armcontainerregistry.ImportImageParameters{ | ||
| Source: source, | ||
| Mode: Ptr(armcontainerregistry.ImportModeForce), | ||
| } | ||
| switch { | ||
| case paths.TargetTag != "" && paths.TargetRepositoryForDigest != "": | ||
| return fmt.Errorf("invalid ACR import target for %s: target tag and digest target repository are mutually exclusive", paths.SourceImage) | ||
| case paths.TargetTag != "": | ||
| parameters.TargetTags = []*string{Ptr(paths.TargetTag)} | ||
| case paths.TargetRepositoryForDigest != "": | ||
| parameters.UntaggedTargetRepositories = []*string{Ptr(paths.TargetRepositoryForDigest)} | ||
| default: | ||
| return fmt.Errorf("invalid ACR import target for %s: target tag or digest target repository is required", paths.SourceImage) | ||
| } | ||
|
|
||
| poller, err := client.BeginImportImage(ctx, target.ResourceGroup, target.Name, parameters, nil) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to start import of %s/%s into '%s': %w", sourceRegistryHost, paths.SourceImage, target.Name, err) | ||
| } | ||
| if _, err := poller.PollUntilDone(ctx, nil); err != nil { | ||
| return fmt.Errorf("failed to import %s/%s into '%s': %w", sourceRegistryHost, paths.SourceImage, target.Name, err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // Builds an ImportSource, using ResourceID for private ACRs (*.azurecr.io) | ||
| // and RegistryURI for public registries. | ||
| func (inst *Installer) makeImportSource(ctx context.Context, registryHost, sourceImage string) (*armcontainerregistry.ImportSource, error) { | ||
| if strings.HasSuffix(registryHost, ".azurecr.io") { | ||
| acrName := strings.TrimSuffix(registryHost, ".azurecr.io") | ||
| resourceID, err := getContainerRegistryId(ctx, acrName, inst.Config.Cloud.SubscriptionID, inst.Credential) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to get resource ID for ACR '%s': %w", acrName, err) | ||
| } | ||
| return &armcontainerregistry.ImportSource{ | ||
| ResourceID: Ptr(resourceID), | ||
| SourceImage: Ptr(sourceImage), | ||
| }, nil | ||
| } | ||
| return &armcontainerregistry.ImportSource{ | ||
| RegistryURI: Ptr(registryHost), | ||
| SourceImage: Ptr(sourceImage), | ||
| }, nil | ||
| } | ||
|
|
||
| // Logs the registry client embedded in a helmclient.Client in to the given | ||
| // ACR using an exchanged ACR refresh token. | ||
| func (inst *Installer) loginHelmClientToAcr(ctx context.Context, helmClient helmclient.Client, acrFqdn string) error { | ||
| hc, ok := helmClient.(*helmclient.HelmClient) | ||
| if !ok { | ||
| return fmt.Errorf("unable to access helm registry client for ACR login") | ||
| } | ||
| refreshToken, err := inst.getAcrRefreshToken(ctx, acrFqdn) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to get ACR refresh token: %w", err) | ||
| } | ||
| if err := hc.ActionConfig.RegistryClient.Login(acrFqdn, | ||
| helmregistry.LoginOptBasicAuth("00000000-0000-0000-0000-000000000000", refreshToken)); err != nil { | ||
| return fmt.Errorf("failed to login to ACR '%s': %w", acrFqdn, err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // Exchanges an AAD token for an ACR refresh token via the /oauth2/exchange | ||
| // endpoint. | ||
| func (inst *Installer) getAcrRefreshToken(ctx context.Context, acrFqdn string) (string, error) { | ||
| aadToken, err := inst.Credential.GetToken(ctx, policy.TokenRequestOptions{ | ||
| Scopes: []string{"https://management.azure.com/.default"}, | ||
| }) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to get AAD token: %w", err) | ||
| } | ||
|
|
||
| exchangeURL := fmt.Sprintf("https://%s/oauth2/exchange", acrFqdn) | ||
| return exchangeAcrRefreshToken(ctx, client.DefaultRetryableClient.HTTPClient, exchangeURL, acrFqdn, inst.Config.Cloud.TenantID, aadToken.Token) | ||
| } | ||
|
|
||
| type httpDoer interface { | ||
| Do(*http.Request) (*http.Response, error) | ||
| } | ||
|
|
||
| func exchangeAcrRefreshToken(ctx context.Context, client httpDoer, exchangeURL, acrFqdn, tenantID, aadAccessToken string) (string, error) { | ||
| formData := url.Values{} | ||
| formData.Set("grant_type", "access_token") | ||
| formData.Set("service", acrFqdn) | ||
| formData.Set("tenant", tenantID) | ||
| formData.Set("access_token", aadAccessToken) | ||
|
|
||
| req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL, strings.NewReader(formData.Encode())) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | ||
|
|
||
| resp, err := client.Do(req) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to exchange ACR token: %w", err) | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| if resp.StatusCode != http.StatusOK { | ||
| body, err := io.ReadAll(resp.Body) | ||
| if err != nil { | ||
| return "", fmt.Errorf("ACR token exchange failed (status %d) and failed to read response body: %w", resp.StatusCode, err) | ||
| } | ||
| return "", fmt.Errorf("ACR token exchange failed (status %d): %s", resp.StatusCode, string(body)) | ||
| } | ||
|
|
||
| var result struct { | ||
| RefreshToken string `json:"refresh_token"` | ||
| } | ||
| if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | ||
| return "", fmt.Errorf("failed to decode ACR exchange response: %w", err) | ||
| } | ||
| if result.RefreshToken == "" { | ||
| return "", fmt.Errorf("ACR token exchange response did not include a refresh token") | ||
| } | ||
| return result.RefreshToken, nil | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.