Skip to content

Commit 41b1caf

Browse files
committed
azure: download assets from the OCI registry on nodes
The bootstrap script and nodeup's asset store fetch OCI blobs by digest, authenticating with the instance's managed identity. Pod and sandbox images are pulled through the acr-credential-provider kubelet plugin.
1 parent eda69d3 commit 41b1caf

8 files changed

Lines changed: 404 additions & 7 deletions

File tree

nodeup/pkg/model/context.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,8 @@ func (c *NodeupModelContext) UseExternalKubeletCredentialProvider() bool {
343343
return true
344344
case kops.CloudProviderAWS:
345345
return true
346+
case kops.CloudProviderAzure:
347+
return c.NodeupConfig.UseACRCredentialProvider
346348
default:
347349
return false
348350
}

nodeup/pkg/model/kubelet.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ func (b *KubeletBuilder) Build(c *fi.NodeupModelBuilderContext) error {
224224
if err := b.addECRCredentialProvider(c); err != nil {
225225
return fmt.Errorf("failed to add the %s kubelet credential provider: %w", b.CloudProvider(), err)
226226
}
227+
case kops.CloudProviderAzure:
228+
if err := b.addACRCredentialProvider(c); err != nil {
229+
return fmt.Errorf("failed to add the %s kubelet credential provider: %w", b.CloudProvider(), err)
230+
}
227231
}
228232
}
229233

@@ -443,6 +447,12 @@ func (b *KubeletBuilder) getGCPCredentialProviderPath() string {
443447
return b.binaryPath() + "/gcp-credential-provider"
444448
}
445449

450+
// getACRCredentialProviderPath returns the path of the ACR Credentials Provider based on distro and archiecture
451+
func (b *KubeletBuilder) getACRCredentialProviderPath() string {
452+
// The binary name must match the provider name in the CredentialProviderConfig.
453+
return b.binaryPath() + "/acr-credential-provider"
454+
}
455+
446456
// buildManifestDirectory creates the directory where kubelet expects static manifests to reside
447457
func (b *KubeletBuilder) buildManifestDirectory(kubeletConfig *kops.KubeletConfigSpec) (*nodetasks.File, error) {
448458
if kubeletConfig.PodManifestPath == "" {
@@ -731,6 +741,74 @@ providers:
731741
return nil
732742
}
733743

744+
// addACRCredentialProvider installs the Azure Container Registry Kubelet Credential Provider
745+
func (b *KubeletBuilder) addACRCredentialProvider(c *fi.NodeupModelBuilderContext) error {
746+
azureConfigFilePath := "/etc/kubernetes/azure.json"
747+
748+
{
749+
assetName := "azure-acr-credential-provider-linux-" + string(b.Architecture)
750+
assetPath := ""
751+
asset, err := b.Assets.Find(assetName, assetPath)
752+
if err != nil {
753+
return fmt.Errorf("trying to locate asset %q: %v", assetName, err)
754+
}
755+
if asset == nil {
756+
return fmt.Errorf("unable to locate asset %q", assetName)
757+
}
758+
759+
t := &nodetasks.File{
760+
Path: b.getACRCredentialProviderPath(),
761+
Contents: asset,
762+
Type: nodetasks.FileType_File,
763+
Mode: s("0755"),
764+
}
765+
c.AddTask(t)
766+
}
767+
768+
{
769+
// The credential provider authenticates with the instance's managed identity;
770+
// kOps Azure nodes have no other cloud config file, so write a minimal one.
771+
azureConfig := `{
772+
"cloud": "AzurePublicCloud",
773+
"useManagedIdentityExtension": true
774+
}
775+
`
776+
t := &nodetasks.File{
777+
Path: azureConfigFilePath,
778+
Contents: fi.NewStringResource(azureConfig),
779+
Type: nodetasks.FileType_File,
780+
Mode: s("0644"),
781+
}
782+
c.AddTask(t)
783+
}
784+
785+
{
786+
configContent := `apiVersion: kubelet.config.k8s.io/v1
787+
kind: CredentialProviderConfig
788+
providers:
789+
- apiVersion: credentialprovider.kubelet.k8s.io/v1
790+
name: acr-credential-provider
791+
matchImages:
792+
- "*.azurecr.io"
793+
- "*.azurecr.cn"
794+
- "*.azurecr.de"
795+
- "*.azurecr.us"
796+
defaultCacheDuration: "10m"
797+
args:
798+
- ` + azureConfigFilePath + `
799+
`
800+
801+
t := &nodetasks.File{
802+
Path: credentialProviderConfigFilePath,
803+
Contents: fi.NewStringResource(configContent),
804+
Type: nodetasks.FileType_File,
805+
Mode: s("0644"),
806+
}
807+
c.AddTask(t)
808+
}
809+
return nil
810+
}
811+
734812
// NodeLabels are defined in the InstanceGroup, but set flags on the kubelet config.
735813
// We have a conflict here: on the one hand we want an easy to use abstract specification
736814
// for the cluster, on the other hand we don't want two fields that do the same thing.

pkg/model/resources/nodeup.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ validate-hash() {
107107
fi
108108
}
109109
110+
{{- OCIDownloadFunctions }}
111+
110112
function download-release() {
111113
case "$(uname -m)" in
112114
x86_64*|i?86_64*|amd64*)
@@ -169,6 +171,19 @@ type NodeUpScript struct {
169171
EnvironmentVariables func() (string, error)
170172
}
171173

174+
// usesOCIAssetRegistry is true when nodeup is downloaded from an OCI registry
175+
// (an oci:// assets.fileRepository), authenticating with the instance identity.
176+
func (b *NodeUpScript) usesOCIAssetRegistry() bool {
177+
for _, asset := range b.NodeUpAssets {
178+
for _, location := range asset.Locations {
179+
if strings.HasPrefix(location, "oci://") {
180+
return true
181+
}
182+
}
183+
}
184+
return false
185+
}
186+
172187
func funcEmptyString() (string, error) {
173188
return "", nil
174189
}
@@ -233,11 +248,54 @@ func (b *NodeUpScript) Build() (fi.Resource, error) {
233248
"ProxyEnv": b.ProxyEnv,
234249
"EnvironmentVariables": b.EnvironmentVariables,
235250
"CopyCheckUrlBlock": b.copyCheckUrlBlock,
251+
"OCIDownloadFunctions": b.ociDownloadFunctions,
236252
}
237253

238254
return newTemplateResource("nodeup", nodeUpTemplate, functions, nil)
239255
}
240256

257+
// ociDownloadFunctions returns a shell function that downloads a blob from an OCI
258+
// registry, authenticating with the instance identity. Assets stored in OCI
259+
// registries are addressed by digest, which is the sha256 hash of their content.
260+
func (b *NodeUpScript) ociDownloadFunctions() (string, error) {
261+
if !b.usesOCIAssetRegistry() {
262+
return "", nil
263+
}
264+
if b.CloudProvider != string(kops.CloudProviderAzure) {
265+
return "", fmt.Errorf("OCI asset registry is not supported on cloud provider %q", b.CloudProvider)
266+
}
267+
268+
// Azure Container Registry: exchange a managed-identity token from the instance
269+
// metadata service for a registry refresh token, then for a pull-scoped access token.
270+
return `
271+
# Download an OCI blob by digest. args: file, hash, url (oci://<registry>/<repository>)
272+
download-oci() {
273+
local -r file="$1"
274+
local -r hash="$2"
275+
local -r url="$3"
276+
277+
local -r stripped="${url#oci://}"
278+
local -r registry="${stripped%%/*}"
279+
local -r repository="${stripped#*/}"
280+
local aad_token refresh_token registry_token
281+
282+
if ! aad_token=$(curl -fsS -H "Metadata: true" "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F" | sed -e 's/.*"access_token":"//' -e 's/".*//'); then
283+
echo "== Failed to get an identity token from the instance metadata service =="
284+
return 1
285+
fi
286+
if ! refresh_token=$(curl -fsS "https://${registry}/oauth2/exchange" --data-urlencode "grant_type=access_token" --data-urlencode "service=${registry}" --data-urlencode "access_token=${aad_token}" | sed -e 's/.*"refresh_token":"//' -e 's/".*//'); then
287+
echo "== Failed to exchange the identity token for a registry refresh token =="
288+
return 1
289+
fi
290+
if ! registry_token=$(curl -fsS "https://${registry}/oauth2/token" --data-urlencode "grant_type=refresh_token" --data-urlencode "service=${registry}" --data-urlencode "scope=repository:${repository}:pull" --data-urlencode "refresh_token=${refresh_token}" | sed -e 's/.*"access_token":"//' -e 's/".*//'); then
291+
echo "== Failed to get a registry access token =="
292+
return 1
293+
fi
294+
295+
curl -f -Lo "${file}" --connect-timeout 20 --retry 6 --retry-delay 10 -H "Authorization: Bearer ${registry_token}" "https://${registry}/v2/${repository}/blobs/sha256:${hash}"
296+
}`, nil
297+
}
298+
241299
func (b *NodeUpScript) copyCheckUrlBlock() (string, error) {
242300
if b.CloudProvider == string(kops.CloudProviderGCE) {
243301
return `commands=(
@@ -261,6 +319,19 @@ func (b *NodeUpScript) copyCheckUrlBlock() (string, error) {
261319
return 0
262320
fi
263321
done`, nil
322+
} else if b.usesOCIAssetRegistry() {
323+
// All file assets are remapped to the OCI registry, so all urls are oci:// URLs.
324+
return `if ! download-oci "${file}" "${hash}" "${url}"; then
325+
echo "== Failed to download ${url} =="
326+
continue
327+
fi
328+
if ! validate-hash "${file}" "${hash}"; then
329+
echo "== Failed to validate hash for ${url} =="
330+
rm -f "${file}"
331+
continue
332+
fi
333+
echo "== Downloaded ${url} with hash ${hash} =="
334+
return 0`, nil
264335
} else {
265336
return `commands=(
266337
"curl -f --compressed -Lo ${file} --connect-timeout 20 --retry 6 --retry-delay 10"

pkg/model/resources/nodeup_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ package resources
1919
import (
2020
"strings"
2121
"testing"
22+
23+
"k8s.io/kops/pkg/apis/nodeup"
24+
"k8s.io/kops/pkg/assets"
25+
"k8s.io/kops/upup/pkg/fi"
26+
"k8s.io/kops/util/pkg/architectures"
27+
"k8s.io/kops/util/pkg/hashing"
2228
)
2329

2430
func Test_NodeUpTabs(t *testing.T) {
@@ -28,3 +34,54 @@ func Test_NodeUpTabs(t *testing.T) {
2834
}
2935
}
3036
}
37+
38+
func Test_NodeUpScriptOCIAssetRegistry(t *testing.T) {
39+
renderScript := func(baseURL string) string {
40+
script := &NodeUpScript{
41+
NodeUpAssets: map[architectures.Architecture]*assets.MirroredAsset{
42+
architectures.ArchitectureAmd64: {
43+
Locations: []string{baseURL + "/binaries/kops/1.35.0/linux/amd64/nodeup"},
44+
Hash: hashing.MustFromString("833723369ad345a88dd85d61b1e77336d56e61b864557ded71b92b6e34158e6a"),
45+
},
46+
architectures.ArchitectureArm64: {
47+
Locations: []string{baseURL + "/binaries/kops/1.35.0/linux/arm64/nodeup"},
48+
Hash: hashing.MustFromString("e525c28a65ff0ce4f95f9e730195b4e67fdcb15ceb1f36b5ad6921a8a4490c71"),
49+
},
50+
},
51+
BootConfig: &nodeup.BootConfig{},
52+
CloudProvider: "azure",
53+
}
54+
resource, err := script.Build()
55+
if err != nil {
56+
t.Fatalf("building nodeup script: %v", err)
57+
}
58+
rendered, err := fi.ResourceAsString(resource)
59+
if err != nil {
60+
t.Fatalf("rendering nodeup script: %v", err)
61+
}
62+
return rendered
63+
}
64+
65+
rendered := renderScript("oci://myregistry.azurecr.io/assets")
66+
for _, expected := range []string{
67+
"download-oci()",
68+
`if ! download-oci "${file}" "${hash}" "${url}"; then`,
69+
"/oauth2/exchange",
70+
`"https://${registry}/v2/${repository}/blobs/sha256:${hash}"`,
71+
} {
72+
if !strings.Contains(rendered, expected) {
73+
t.Errorf("expected the nodeup script to contain %q", expected)
74+
}
75+
}
76+
// All file assets are remapped to the OCI registry; the generic download commands are not needed.
77+
if strings.Contains(rendered, "commands=(") {
78+
t.Errorf("expected the nodeup script to not contain the generic download commands")
79+
}
80+
81+
rendered = renderScript("https://artifacts.k8s.io")
82+
for _, unexpected := range []string{"download-oci"} {
83+
if strings.Contains(rendered, unexpected) {
84+
t.Errorf("expected the nodeup script to not contain %q", unexpected)
85+
}
86+
}
87+
}

pkg/nodemodel/fileassets.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import (
2929
"k8s.io/kops/util/pkg/architectures"
3030
)
3131

32+
// acrCredentialProviderVersion is the cloud-provider-azure release providing the
33+
// azure-acr-credential-provider binary; hashes are pinned in pkg/assets/assetdata/acr.yaml.
34+
const acrCredentialProviderVersion = "v1.34.12"
35+
3236
// KubernetesFileAssets are the assets for downloading Kubernetes binaries
3337
type KubernetesFileAssets struct {
3438
// KubernetesFileAssets are the assets for downloading Kubernetes binaries
@@ -102,6 +106,20 @@ func BuildKubernetesFileAssets(ig model.InstanceGroup, assetBuilder *assets.Asse
102106
return nil, err
103107
}
104108
kubernetesAssets[arch] = append(kubernetesAssets[arch], assets.BuildMirroredAsset(asset))
109+
case kops.CloudProviderAzure:
110+
// The ACR credential provider is only used when pulling from a private
111+
// registry holding the cluster's assets.
112+
if ig.RawClusterSpec().OCIAssetRegistryHost() != "" {
113+
u, err := url.Parse(fmt.Sprintf("https://github.com/kubernetes-sigs/cloud-provider-azure/releases/download/%s/azure-acr-credential-provider-linux-%s", acrCredentialProviderVersion, arch))
114+
if err != nil {
115+
return nil, err
116+
}
117+
asset, err := assetBuilder.RemapFile(u, nil)
118+
if err != nil {
119+
return nil, err
120+
}
121+
kubernetesAssets[arch] = append(kubernetesAssets[arch], assets.BuildMirroredAsset(asset))
122+
}
105123
}
106124

107125
if ig.InstallCNIAssets() {

upup/pkg/fi/assetstore.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,9 @@ func (a *AssetStore) Add(ctx context.Context, id string) error {
211211
if i == -1 {
212212
i = strings.Index(id, "@gs://")
213213
}
214+
if i == -1 {
215+
i = strings.Index(id, "@oci://")
216+
}
214217
if i != -1 {
215218
urls := strings.Split(id[i+1:], ",")
216219
hash, err := hashing.FromString(id[:i])

upup/pkg/fi/http.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,8 @@ func downloadURLToWriter(ctx context.Context, desturl string, dest io.Writer, ha
8888
if err != nil {
8989
return nil, fmt.Errorf("Invalud URL for file %q: %v", desturl, err)
9090
}
91-
if u.Scheme != "gs" {
92-
reader, err = OpenURL(desturl)
93-
if err != nil {
94-
return nil, err
95-
}
96-
defer reader.Close()
97-
} else {
91+
switch u.Scheme {
92+
case "gs":
9893
bucketName := u.Host
9994
objectName := strings.TrimPrefix(u.Path, "/")
10095

@@ -109,6 +104,18 @@ func downloadURLToWriter(ctx context.Context, desturl string, dest io.Writer, ha
109104
return nil, fmt.Errorf("Failed to open reader on object %q: %v", desturl, err)
110105
}
111106
defer reader.Close()
107+
case "oci":
108+
reader, err = openOCIBlob(ctx, u, hash)
109+
if err != nil {
110+
return nil, err
111+
}
112+
defer reader.Close()
113+
default:
114+
reader, err = OpenURL(desturl)
115+
if err != nil {
116+
return nil, err
117+
}
118+
defer reader.Close()
112119
}
113120
start := time.Now()
114121
defer func() {

0 commit comments

Comments
 (0)