From 7d66e86e4be98eb21804f0e613708d8e6d7a7623 Mon Sep 17 00:00:00 2001 From: Benjamin Elder Date: Wed, 12 Aug 2026 19:47:45 -0700 Subject: [PATCH] atelet: pull image credentials from the kubelet credential provider atelet authenticated image pulls with Google application default credentials, applied to gcr.io and pkg.dev only. That compiles one cloud's registry story into the binary and leaves every other cloud unserved. Every managed node already answers "which credentials does this registry want": the kubelet ships a credential provider executable and a CredentialProviderConfig naming the registries it handles. Reuse that contract instead. internal/credentialprovider is an authn.Keychain that speaks the kubelet's exec-plugin protocol -- config parsing and validation, the kubelet's image match rules, the request/response exchange over stdio, and per-cacheKeyType response caching. atelet resolves credentials through it when --image-credential-provider-config and --image-credential-provider-bin-dir are set (the same flags the kubelet takes), and the DaemonSet mounts the node's plugin dir and config read-only. The result is registry-agnostic: the same atelet authenticates to Artifact Registry on GKE and to ECR on EKS, given each node's own plugin. The GCP application default credentials path stays for installs that do not set the new flags. --- LICENSES/k8s.io/kubelet/LICENSE | 202 +++++++ cmd/atelet/imagecreds.go | 59 ++ cmd/atelet/main.go | 21 +- go.mod | 1 + go.sum | 4 + internal/credentialprovider/config.go | 143 +++++ internal/credentialprovider/config_test.go | 281 +++++++++ .../credentialprovider/credentialprovider.go | 245 ++++++++ .../credentialprovider_test.go | 547 ++++++++++++++++++ internal/credentialprovider/exec.go | 97 ++++ internal/credentialprovider/match.go | 120 ++++ internal/credentialprovider/match_test.go | 127 ++++ internal/imagecache/imagecache.go | 17 +- internal/imagecache/keychain_test.go | 132 +++++ manifests/ate-install/atelet.yaml | 30 +- .../kind/atelet/kustomization.yaml | 16 + vendor/k8s.io/kubelet/LICENSE | 202 +++++++ vendor/k8s.io/kubelet/config/v1/doc.go | 23 + vendor/k8s.io/kubelet/config/v1/register.go | 43 ++ vendor/k8s.io/kubelet/config/v1/types.go | 189 ++++++ .../config/v1/zz_generated.deepcopy.go | 147 +++++ .../config/v1/zz_generated.model_name.go | 42 ++ .../pkg/apis/credentialprovider/OWNERS | 5 + .../pkg/apis/credentialprovider/doc.go | 20 + .../pkg/apis/credentialprovider/register.go | 41 ++ .../pkg/apis/credentialprovider/types.go | 128 ++++ .../pkg/apis/credentialprovider/v1/doc.go | 22 + .../apis/credentialprovider/v1/register.go | 46 ++ .../pkg/apis/credentialprovider/v1/types.go | 129 +++++ .../v1/zz_generated.conversion.go | 141 +++++ .../v1/zz_generated.deepcopy.go | 112 ++++ .../v1/zz_generated.defaults.go | 33 ++ .../zz_generated.deepcopy.go | 112 ++++ vendor/modules.txt | 5 + 34 files changed, 3470 insertions(+), 12 deletions(-) create mode 100644 LICENSES/k8s.io/kubelet/LICENSE create mode 100644 cmd/atelet/imagecreds.go create mode 100644 internal/credentialprovider/config.go create mode 100644 internal/credentialprovider/config_test.go create mode 100644 internal/credentialprovider/credentialprovider.go create mode 100644 internal/credentialprovider/credentialprovider_test.go create mode 100644 internal/credentialprovider/exec.go create mode 100644 internal/credentialprovider/match.go create mode 100644 internal/credentialprovider/match_test.go create mode 100644 internal/imagecache/keychain_test.go create mode 100644 vendor/k8s.io/kubelet/LICENSE create mode 100644 vendor/k8s.io/kubelet/config/v1/doc.go create mode 100644 vendor/k8s.io/kubelet/config/v1/register.go create mode 100644 vendor/k8s.io/kubelet/config/v1/types.go create mode 100644 vendor/k8s.io/kubelet/config/v1/zz_generated.deepcopy.go create mode 100644 vendor/k8s.io/kubelet/config/v1/zz_generated.model_name.go create mode 100644 vendor/k8s.io/kubelet/pkg/apis/credentialprovider/OWNERS create mode 100644 vendor/k8s.io/kubelet/pkg/apis/credentialprovider/doc.go create mode 100644 vendor/k8s.io/kubelet/pkg/apis/credentialprovider/register.go create mode 100644 vendor/k8s.io/kubelet/pkg/apis/credentialprovider/types.go create mode 100644 vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/doc.go create mode 100644 vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/register.go create mode 100644 vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/types.go create mode 100644 vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/zz_generated.conversion.go create mode 100644 vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/zz_generated.deepcopy.go create mode 100644 vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/zz_generated.defaults.go create mode 100644 vendor/k8s.io/kubelet/pkg/apis/credentialprovider/zz_generated.deepcopy.go diff --git a/LICENSES/k8s.io/kubelet/LICENSE b/LICENSES/k8s.io/kubelet/LICENSE new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/LICENSES/k8s.io/kubelet/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/cmd/atelet/imagecreds.go b/cmd/atelet/imagecreds.go new file mode 100644 index 0000000000..c8171a0c23 --- /dev/null +++ b/cmd/atelet/imagecreds.go @@ -0,0 +1,59 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "fmt" + + "github.com/agent-substrate/substrate/internal/credentialprovider" + "github.com/google/go-containerregistry/pkg/authn" + googlecontainerauth "github.com/google/go-containerregistry/pkg/v1/google" +) + +// newImagePullCredentials picks how atelet authenticates image pulls. Exactly +// one of the two results is non-nil (or neither, for anonymous-only pulls): +// +// - keychain: kubelet image credential provider plugins, mounted from the +// host. Registry-agnostic — the node's plugins decide what they can +// authenticate — so atelet needs no cloud-specific code. +// - gcpAuth: the legacy path, GCP application default credentials applied to +// gcr.io and pkg.dev only. +// +// The credential-provider path wins when configured; it is the direction we +// want atelet to move in, and running both would only obscure which one +// produced a credential. +func newImagePullCredentials(ctx context.Context) (keychain authn.Keychain, gcpAuth authn.Authenticator, err error) { + if *imageCredentialProviderConfig != "" { + if *imageCredentialProviderBinDir == "" { + return nil, nil, fmt.Errorf("--image-credential-provider-bin-dir is required when --image-credential-provider-config is set") + } + kc, err := credentialprovider.New(*imageCredentialProviderConfig, *imageCredentialProviderBinDir) + if err != nil { + return nil, nil, err + } + return kc, nil, nil + } + + if *gcpAuthForImagePulls { + gcpAuth, err := googlecontainerauth.NewEnvAuthenticator(ctx) + if err != nil { + return nil, nil, fmt.Errorf("while creating GCP registry authenticator: %w", err) + } + return nil, gcpAuth, nil + } + + return nil, nil, nil +} diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index 0fcd67c029..5288656da6 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -58,8 +58,6 @@ import ( "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/google/go-containerregistry/pkg/authn" - googlecontainerauth "github.com/google/go-containerregistry/pkg/v1/google" "github.com/spf13/pflag" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "go.opentelemetry.io/otel" @@ -91,7 +89,14 @@ var ( ateapiCAFile = pflag.String("ateapi-ca-file", "/run/servicedns.podcert.ate.dev/trust-bundle.pem", "CA bundle used to verify ateapi.") ateapiServerName = pflag.String("ateapi-server-name", "api.ate-system.svc", "DNS name expected on the ateapi certificate.") - gcpAuthForImagePulls = pflag.Bool("gcp-auth-for-image-pulls", true, "Use GCP application default credentials mechanism.") + gcpAuthForImagePulls = pflag.Bool("gcp-auth-for-image-pulls", true, "Use GCP application default credentials mechanism. Ignored when --image-credential-provider-config is set.") + // The kubelet already knows how to authenticate to its node's cloud + // registry, via an exec plugin the node ships. Pointing atelet at the same + // config and bin dir (mounted read-only from the host) lets it pull with + // no cloud SDK compiled in, on any cloud whose nodes configure a provider. + imageCredentialProviderConfig = pflag.String("image-credential-provider-config", "", "Path to a kubelet CredentialProviderConfig. When set, image pull credentials come from its exec plugins instead of GCP application default credentials.") + imageCredentialProviderBinDir = pflag.String("image-credential-provider-bin-dir", "", "Directory holding the credential provider executables named by --image-credential-provider-config. Required when that flag is set.") + localhostRegistryReplacement = pflag.String("localhost-registry-replacement", "", "The replacement registry endpoint for localhost and/or loopback IP addresses, useful for local development. for example kind-registry:5000") imageCacheDir = pflag.String("image-cache-dir", ateompath.ImageCacheDir, "Directory for the node-local OCI image layer cache. Must be on the volume shared with the ateom pods (the cached layers are their overlay lowerdirs), and on a disk sized for both capacity and IOPS: unpack throughput is gated by the volume's IOPS.") @@ -157,18 +162,16 @@ func main() { conns: lru.New(256), } - var gcpRegistryAuthn authn.Authenticator - if *gcpAuthForImagePulls { - gcpRegistryAuthn, err = googlecontainerauth.NewEnvAuthenticator(ctx) - if err != nil { - serverboot.Fatal(ctx, "Failed to create GCP registry authenticator", err) - } + imageCredsKeychain, gcpRegistryAuthn, err := newImagePullCredentials(ctx) + if err != nil { + serverboot.Fatal(ctx, "Failed to configure image pull credentials", err) } if err := validateImageCacheGCFlags(); err != nil { serverboot.Fatal(ctx, "Invalid image cache GC flags", err) } imageCache, err := imagecache.New(*imageCacheDir, + imagecache.WithKeychain(imageCredsKeychain), imagecache.WithAuthenticator(gcpRegistryAuthn), imagecache.WithLocalhostRegistryReplacement(*localhostRegistryReplacement), imagecache.WithActorsDir(ateompath.ActorsDir), diff --git a/go.mod b/go.mod index 17665f0077..94984ee377 100644 --- a/go.mod +++ b/go.mod @@ -60,6 +60,7 @@ require ( k8s.io/apiextensions-apiserver v0.36.1 k8s.io/apimachinery v0.36.1 k8s.io/client-go v0.36.1 + k8s.io/kubelet v0.36.1 k8s.io/metrics v0.36.1 k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 sigs.k8s.io/controller-runtime v0.24.1 diff --git a/go.sum b/go.sum index 82a1dace31..26a8ddd47f 100644 --- a/go.sum +++ b/go.sum @@ -510,10 +510,14 @@ k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= +k8s.io/component-base v0.36.1 h1:iG6GsELftXqTNG9HG6kiVjatSgAw1sf5pJ6R5a6N0kA= +k8s.io/component-base v0.36.1/go.mod h1:nf9XPlntRdqO6WMeEWAA5F93Y4ICZQdeT9GeqLDB3JI= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/kubelet v0.36.1 h1:FcHiG9wv92xerRPNxztuhYWqwS4IilOQNPxTPQewYgo= +k8s.io/kubelet v0.36.1/go.mod h1:e6IeoCwqc2TbneCKu6P8HjmWLi7U6SOh3Pocs32iGFM= k8s.io/metrics v0.36.1 h1:MQPb+G4RhrKEpt8NETPssbW8QgGUc4Jbqu1jx+kPqGk= k8s.io/metrics v0.36.1/go.mod h1:xqS8XcWLjDzo6E7DJm/GfjKpRKdN5/MtJAQFuV6nLUc= k8s.io/streaming v0.36.1 h1:L+K68n4Gg940BGNNYtUBvL1WTLL0YnKT3s+P1MNAmR4= diff --git a/internal/credentialprovider/config.go b/internal/credentialprovider/config.go new file mode 100644 index 0000000000..6d2acbc44a --- /dev/null +++ b/internal/credentialprovider/config.go @@ -0,0 +1,143 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package credentialprovider + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + kubeletconfigv1 "k8s.io/kubelet/config/v1" + credentialproviderv1 "k8s.io/kubelet/pkg/apis/credentialprovider/v1" + "sigs.k8s.io/yaml" +) + +// supportedAPIVersion is the only CredentialProviderRequest/Response encoding +// this package speaks. A provider configured for anything else is rejected +// rather than silently sent a request it cannot parse. +var supportedAPIVersion = credentialproviderv1.SchemeGroupVersion.String() + +// configKind is the apiVersion/kind a credential provider config file must +// declare. +const ( + configAPIVersion = "kubelet.config.k8s.io/v1" + configKind = "CredentialProviderConfig" +) + +// loadConfig reads a kubelet CredentialProviderConfig and resolves each +// provider's executable inside binDir, applying the same per-provider +// validation the kubelet does. +// +// Errors are returned only for problems with the config as a whole -- missing, +// unparseable, or not a CredentialProviderConfig -- which mean atelet was +// pointed at the wrong file. Individual providers that fail validation are +// skipped (see the loop below), so the returned slice may be shorter than the +// config, or empty. +func loadConfig(configPath, binDir string) ([]*plugin, error) { + raw, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("while reading credential provider config: %w", err) + } + var cfg kubeletconfigv1.CredentialProviderConfig + if err := yaml.UnmarshalStrict(raw, &cfg); err != nil { + return nil, fmt.Errorf("while parsing credential provider config %q: %w", configPath, err) + } + if cfg.Kind != configKind || cfg.APIVersion != configAPIVersion { + return nil, fmt.Errorf("credential provider config %q must be %s %s, got %q %q", configPath, configAPIVersion, configKind, cfg.APIVersion, cfg.Kind) + } + // A single unusable provider is skipped rather than fatal. The config is + // the node's, shared with its kubelet, and may name providers this atelet + // does not implement (tokenAttributes) or does not need; refusing to start + // would turn one unsupported entry into a node-wide outage, and would take + // the other providers -- which may cover every registry the actors use -- + // down with it. Pulls that needed a skipped provider fail at pull time with + // the registry's own 401, next to this warning. + seen := make(map[string]struct{}, len(cfg.Providers)) + plugins := make([]*plugin, 0, len(cfg.Providers)) + for i := range cfg.Providers { + p, err := newPlugin(&cfg.Providers[i], binDir) + if err != nil { + slog.Warn("Skipping unusable image credential provider", + slog.String("config", configPath), slog.Any("err", err)) + continue + } + if _, dup := seen[p.name]; dup { + slog.Warn("Skipping duplicate image credential provider", + slog.String("config", configPath), slog.String("provider", p.name)) + continue + } + seen[p.name] = struct{}{} + plugins = append(plugins, p) + } + // Also not fatal: an empty keychain simply makes every pull anonymous, + // which is correct for public registries and fails loudly for private ones. + if len(plugins) == 0 { + slog.Error("No usable image credential providers; image pulls will be anonymous", + slog.String("config", configPath)) + } + return plugins, nil +} + +// newPlugin validates one provider entry and binds it to its executable. +func newPlugin(cfg *kubeletconfigv1.CredentialProvider, binDir string) (*plugin, error) { + if cfg.Name == "" { + return nil, fmt.Errorf("provider name is required") + } + // The name is joined onto binDir, so it must be a bare file name: a + // provider called "../../bin/sh" would otherwise escape the mounted dir. + if strings.ContainsRune(cfg.Name, filepath.Separator) { + return nil, fmt.Errorf("provider name %q must not contain %q", cfg.Name, string(filepath.Separator)) + } + if len(cfg.MatchImages) == 0 { + return nil, fmt.Errorf("provider %q: matchImages is required", cfg.Name) + } + for _, m := range cfg.MatchImages { + if _, err := matchesImage(m, "example.registry.io/image"); err != nil { + return nil, fmt.Errorf("provider %q: invalid matchImages entry: %w", cfg.Name, err) + } + } + if cfg.DefaultCacheDuration == nil { + return nil, fmt.Errorf("provider %q: defaultCacheDuration is required", cfg.Name) + } + if cfg.DefaultCacheDuration.Duration < 0 { + return nil, fmt.Errorf("provider %q: defaultCacheDuration must not be negative", cfg.Name) + } + if cfg.APIVersion != supportedAPIVersion { + return nil, fmt.Errorf("provider %q: apiVersion %q is not supported (want %q)", cfg.Name, cfg.APIVersion, supportedAPIVersion) + } + // Service-account-bound image pull credentials require minting a token for + // the pulling pod's service account, which atelet does not do: it pulls on + // behalf of actors, which have no Kubernetes service account. + if cfg.TokenAttributes != nil { + return nil, fmt.Errorf("provider %q: tokenAttributes is not supported", cfg.Name) + } + + env := make([]string, 0, len(cfg.Env)) + for _, e := range cfg.Env { + env = append(env, e.Name+"="+e.Value) + } + + return &plugin{ + name: cfg.Name, + path: filepath.Join(binDir, cfg.Name), + args: cfg.Args, + env: env, + matchImages: cfg.MatchImages, + defaultCacheDuration: cfg.DefaultCacheDuration.Duration, + cache: map[string]cacheEntry{}, + }, nil +} diff --git a/internal/credentialprovider/config_test.go b/internal/credentialprovider/config_test.go new file mode 100644 index 0000000000..14f0dd1830 --- /dev/null +++ b/internal/credentialprovider/config_test.go @@ -0,0 +1,281 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package credentialprovider + +import ( + "path/filepath" + "testing" + "time" +) + +// gkeConfig is verbatim /etc/srv/kubernetes/cri_auth_config.yaml from a GKE +// node, the config this package exists to consume. +const gkeConfig = `kind: CredentialProviderConfig +apiVersion: kubelet.config.k8s.io/v1 +providers: + - name: auth-provider-gcp + apiVersion: credentialprovider.kubelet.k8s.io/v1 + matchImages: + - "container.cloud.google.com" + - "gcr.io" + - "*.gcr.io" + - "*.pkg.dev" + args: + - get-credentials + - --v=3 + defaultCacheDuration: 1m +` + +func TestLoadConfigGKE(t *testing.T) { + t.Parallel() + dir := t.TempDir() + plugins, err := loadConfig(writeConfig(t, dir, gkeConfig), "/home/kubernetes/bin") + if err != nil { + t.Fatalf("loadConfig returned unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("loadConfig returned %d plugins, want 1", len(plugins)) + } + p := plugins[0] + if want := filepath.Join("/home/kubernetes/bin", "auth-provider-gcp"); p.path != want { + t.Errorf("plugin path = %q, want %q", p.path, want) + } + if p.defaultCacheDuration != time.Minute { + t.Errorf("plugin defaultCacheDuration = %v, want 1m", p.defaultCacheDuration) + } + if len(p.args) != 2 || p.args[0] != "get-credentials" { + t.Errorf("plugin args = %v, want [get-credentials --v=3]", p.args) + } + + // The provider must claim Artifact Registry and GCR but nothing else. + for _, tc := range []struct { + image string + want bool + }{ + {image: "gcr.io/proj/img", want: true}, + {image: "us.gcr.io/proj/img", want: true}, + {image: "us-central1-docker.pkg.dev/proj/repo/img", want: true}, + {image: "docker.io/library/busybox", want: false}, + {image: "quay.io/proj/img", want: false}, + } { + got, err := p.claims(tc.image) + if err != nil { + t.Fatalf("claims(%q) returned unexpected error: %v", tc.image, err) + } + if got != tc.want { + t.Errorf("claims(%q) = %v, want %v", tc.image, got, tc.want) + } + } +} + +// Config-level problems mean atelet was pointed at the wrong file, and are +// the only thing loadConfig treats as an error. +func TestLoadConfigRejectsInvalidConfig(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + config string + }{ + { + name: "wrong kind", + config: `kind: KubeletConfiguration +apiVersion: kubelet.config.k8s.io/v1 +providers: + - name: p + apiVersion: credentialprovider.kubelet.k8s.io/v1 + matchImages: ["gcr.io"] + defaultCacheDuration: 1m +`, + }, + { + name: "wrong config apiVersion", + config: `kind: CredentialProviderConfig +apiVersion: kubelet.config.k8s.io/v1beta1 +providers: + - name: p + apiVersion: credentialprovider.kubelet.k8s.io/v1 + matchImages: ["gcr.io"] + defaultCacheDuration: 1m +`, + }, + { + name: "not yaml", + config: "this: is: not: valid: yaml:\n", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if _, err := loadConfig(writeConfig(t, dir, tc.config), dir); err == nil { + t.Error("loadConfig accepted an invalid config, want an error") + } + }) + } +} + +// A provider entry atelet cannot use is skipped, not fatal: the config belongs +// to the node and is shared with its kubelet, so one unsupported entry must not +// cost us the others (or the whole atelet). +func TestLoadConfigSkipsUnusableProviders(t *testing.T) { + t.Parallel() + // Each case pairs one unusable provider with one good "keeper", so the + // assertion covers both that the bad one is dropped and that the rest of + // the config still loads. + for _, tc := range []struct { + name string + bad string + }{ + { + name: "missing name", + bad: ` - apiVersion: credentialprovider.kubelet.k8s.io/v1 + matchImages: ["quay.io"] + defaultCacheDuration: 1m +`, + }, + { + name: "name escapes the bin dir", + bad: ` - name: ../../bin/sh + apiVersion: credentialprovider.kubelet.k8s.io/v1 + matchImages: ["quay.io"] + defaultCacheDuration: 1m +`, + }, + { + name: "no matchImages", + bad: ` - name: bad + apiVersion: credentialprovider.kubelet.k8s.io/v1 + defaultCacheDuration: 1m +`, + }, + { + name: "missing defaultCacheDuration", + bad: ` - name: bad + apiVersion: credentialprovider.kubelet.k8s.io/v1 + matchImages: ["quay.io"] +`, + }, + { + name: "unsupported exec apiVersion", + bad: ` - name: bad + apiVersion: credentialprovider.kubelet.k8s.io/v1beta1 + matchImages: ["quay.io"] + defaultCacheDuration: 1m +`, + }, + { + name: "service account token attributes", + bad: ` - name: bad + apiVersion: credentialprovider.kubelet.k8s.io/v1 + matchImages: ["quay.io"] + defaultCacheDuration: 1m + tokenAttributes: + serviceAccountTokenAudience: aud + cacheType: Token + requireServiceAccount: true +`, + }, + { + name: "duplicate of the keeper", + bad: ` - name: keeper + apiVersion: credentialprovider.kubelet.k8s.io/v1 + matchImages: ["quay.io"] + defaultCacheDuration: 1m +`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + config := "kind: CredentialProviderConfig\napiVersion: kubelet.config.k8s.io/v1\nproviders:\n" + + ` - name: keeper + apiVersion: credentialprovider.kubelet.k8s.io/v1 + matchImages: ["gcr.io"] + defaultCacheDuration: 1m +` + tc.bad + + plugins, err := loadConfig(writeConfig(t, dir, config), dir) + if err != nil { + t.Fatalf("loadConfig returned an error for an unusable provider, want it skipped: %v", err) + } + if len(plugins) != 1 { + names := make([]string, 0, len(plugins)) + for _, p := range plugins { + names = append(names, p.name) + } + t.Fatalf("loadConfig returned providers %v, want only [keeper]", names) + } + if plugins[0].name != "keeper" { + t.Errorf("surviving provider is %q, want %q", plugins[0].name, "keeper") + } + // The keeper must be intact, not just present. + if got, err := plugins[0].claims("gcr.io/proj/img"); err != nil || !got { + t.Errorf("keeper.claims(gcr.io/proj/img) = %v, %v; want true, nil", got, err) + } + }) + } +} + +// An unusable config is degraded to "everything pulls anonymously" rather than +// killing atelet, so a node whose providers we cannot run still serves actors +// whose images are public or already cached. +func TestLoadConfigEmptyWhenNoProviderUsable(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + config string + }{ + { + name: "no providers declared", + config: `kind: CredentialProviderConfig +apiVersion: kubelet.config.k8s.io/v1 +providers: [] +`, + }, + { + name: "only provider is unusable", + config: `kind: CredentialProviderConfig +apiVersion: kubelet.config.k8s.io/v1 +providers: + - name: bad + apiVersion: credentialprovider.kubelet.k8s.io/v1 + matchImages: ["gcr.io"] + defaultCacheDuration: 1m + tokenAttributes: + serviceAccountTokenAudience: aud + cacheType: Token + requireServiceAccount: true +`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + plugins, err := loadConfig(writeConfig(t, dir, tc.config), dir) + if err != nil { + t.Fatalf("loadConfig returned an error, want an empty provider set: %v", err) + } + if len(plugins) != 0 { + t.Errorf("loadConfig returned %d providers, want 0", len(plugins)) + } + }) + } +} + +func TestLoadConfigMissingFile(t *testing.T) { + t.Parallel() + if _, err := loadConfig(filepath.Join(t.TempDir(), "absent.yaml"), "/bin"); err == nil { + t.Error("loadConfig accepted a missing config file, want an error") + } +} diff --git a/internal/credentialprovider/credentialprovider.go b/internal/credentialprovider/credentialprovider.go new file mode 100644 index 0000000000..5b0fd68151 --- /dev/null +++ b/internal/credentialprovider/credentialprovider.go @@ -0,0 +1,245 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package credentialprovider resolves container registry credentials by +// invoking kubelet image credential provider plugins +// (https://kubernetes.io/docs/tasks/administer-cluster/kubelet-credential-provider/). +// +// The kubelet already solves "which cloud's credentials does this registry +// want" on every managed node: the node ships a provider executable and a +// CredentialProviderConfig naming the registries it handles. Reusing that +// contract lets atelet pull from a cloud's registry with no cloud SDK compiled +// in — the same atelet binary authenticates to Artifact Registry on GKE and to +// ECR on EKS, given each node's own provider plugin. +// +// The provider binary and config are supplied to atelet as read-only host +// mounts; see manifests/ate-install/atelet.yaml. +package credentialprovider + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/google/go-containerregistry/pkg/authn" + credentialproviderv1 "k8s.io/kubelet/pkg/apis/credentialprovider/v1" +) + +// Keychain is a go-containerregistry authn.Keychain backed by kubelet image +// credential provider plugins. It is safe for concurrent use. +type Keychain struct { + plugins []*plugin +} + +var ( + _ authn.Keychain = (*Keychain)(nil) + _ authn.ContextKeychain = (*Keychain)(nil) +) + +// New loads the CredentialProviderConfig at configPath and resolves each +// provider's executable inside binDir. Both correspond to the kubelet flags of +// the same name (--image-credential-provider-config and +// --image-credential-provider-bin-dir); pointing atelet at the node's own +// values makes it authenticate exactly like the kubelet on that node. +func New(configPath, binDir string) (*Keychain, error) { + plugins, err := loadConfig(configPath, binDir) + if err != nil { + return nil, err + } + names := make([]string, 0, len(plugins)) + for _, p := range plugins { + names = append(names, p.name) + } + slog.Info("Loaded image credential providers", + slog.String("config", configPath), + slog.String("binDir", binDir), + slog.Any("providers", names), + ) + return &Keychain{plugins: plugins}, nil +} + +// Resolve implements authn.Keychain. +func (k *Keychain) Resolve(target authn.Resource) (authn.Authenticator, error) { + return k.ResolveContext(context.Background(), target) +} + +// ResolveContext implements authn.ContextKeychain. It returns the credentials +// of the first configured provider that both claims the image (via +// matchImages) and returns an auth entry for it, and authn.Anonymous when no +// provider does — an unauthenticated pull, which is what public registries +// want. +func (k *Keychain) ResolveContext(ctx context.Context, target authn.Resource) (authn.Authenticator, error) { + // authn.Resource.String() is the registry and repository with no tag or + // digest ("us-docker.pkg.dev/proj/repo/image"), which is exactly the + // granularity the plugin protocol's cache keys are defined at. + image := target.String() + + for _, p := range k.plugins { + claims, err := p.claims(image) + if err != nil { + return nil, err + } + if !claims { + continue + } + auth, err := p.provide(ctx, image) + if err != nil { + return nil, err + } + if auth == nil { + slog.Debug("Image credential provider returned no credentials", + slog.String("provider", p.name), slog.String("image", image)) + continue + } + // Info, not Debug: this is the record of which provider authenticated a + // pull, which is the first thing anyone debugging a 401 in the field + // wants. Digest-pinned images (the API requires them) only reach a + // credential resolution on a cache miss, so this is once per image per + // node, not once per actor. + slog.InfoContext(ctx, "Resolved image credentials from credential provider", + slog.String("provider", p.name), slog.String("image", image)) + return authn.FromConfig(authn.AuthConfig{ + Username: auth.Username, + Password: auth.Password, + }), nil + } + return authn.Anonymous, nil +} + +// cacheEntry is one plugin response held until expiry. A nil auth is cached +// too: "this plugin has no credentials for you" is an answer worth not +// re-execing a subprocess for. +type cacheEntry struct { + auth *credentialproviderv1.AuthConfig + expiresAt time.Time +} + +// plugin is one configured provider executable and its response cache. +type plugin struct { + name string + path string + args []string + env []string + matchImages []string + defaultCacheDuration time.Duration + + mu sync.Mutex + cache map[string]cacheEntry + // now is time.Now, overridden in tests. + now func() time.Time +} + +// claims reports whether this plugin is configured to handle image. +func (p *plugin) claims(image string) (bool, error) { + for _, m := range p.matchImages { + matched, err := matchesImage(m, image) + if err != nil { + return false, err + } + if matched { + return true, nil + } + } + return false, nil +} + +// globalCacheKey is the cache key for plugins that answer identically for +// every image they claim (cacheKeyType: Global). +const globalCacheKey = "global" + +// provide returns cached credentials for image, or execs the plugin and caches +// what it returns. A nil AuthConfig with a nil error means the plugin has no +// credentials for this image. +func (p *plugin) provide(ctx context.Context, image string) (*credentialproviderv1.AuthConfig, error) { + if entry, ok := p.lookup(image); ok { + return entry, nil + } + + resp, err := p.exec(ctx, image) + if err != nil { + return nil, err + } + + key, err := bestAuthKey(resp.Auth, image) + if err != nil { + return nil, err + } + var auth *credentialproviderv1.AuthConfig + if key != "" { + matched := resp.Auth[key] + auth = &matched + } + + p.store(image, resp, auth) + return auth, nil +} + +// lookup checks the cache under each key type the plugin might have stored +// this image's credentials at, most specific first — mirroring the kubelet, +// which cannot know a plugin's cacheKeyType until it has answered once. +func (p *plugin) lookup(image string) (*credentialproviderv1.AuthConfig, bool) { + p.mu.Lock() + defer p.mu.Unlock() + for _, key := range []string{image, registryOf(image), globalCacheKey} { + entry, ok := p.cache[key] + if !ok { + continue + } + if !p.timeNow().Before(entry.expiresAt) { + delete(p.cache, key) + continue + } + return entry.auth, true + } + return nil, false +} + +// store caches auth under the key type the plugin asked for. A response +// carrying an unrecognized cacheKeyType, or an explicit zero duration, is not +// cached at all. +func (p *plugin) store(image string, resp *credentialproviderv1.CredentialProviderResponse, auth *credentialproviderv1.AuthConfig) { + var key string + switch resp.CacheKeyType { + case credentialproviderv1.ImagePluginCacheKeyType: + key = image + case credentialproviderv1.RegistryPluginCacheKeyType: + key = registryOf(image) + case credentialproviderv1.GlobalPluginCacheKeyType: + key = globalCacheKey + default: + slog.Warn("Image credential provider returned an unknown cacheKeyType; not caching", + slog.String("provider", p.name), slog.String("cacheKeyType", string(resp.CacheKeyType))) + return + } + + duration := p.defaultCacheDuration + if resp.CacheDuration != nil { + duration = resp.CacheDuration.Duration + } + if duration <= 0 { + return + } + + p.mu.Lock() + defer p.mu.Unlock() + p.cache[key] = cacheEntry{auth: auth, expiresAt: p.timeNow().Add(duration)} +} + +func (p *plugin) timeNow() time.Time { + if p.now != nil { + return p.now() + } + return time.Now() +} diff --git a/internal/credentialprovider/credentialprovider_test.go b/internal/credentialprovider/credentialprovider_test.go new file mode 100644 index 0000000000..c8838b0946 --- /dev/null +++ b/internal/credentialprovider/credentialprovider_test.go @@ -0,0 +1,547 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package credentialprovider + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" +) + +// fakePlugin writes an executable named name into dir that records each +// request it is handed (one JSON object per line in /.requests) and +// replies with response. It returns the requests file's path. +func fakePlugin(t *testing.T, dir, pluginName, response string) string { + t.Helper() + requests := filepath.Join(dir, pluginName+".requests") + script := fmt.Sprintf(`#!/bin/sh +cat >> %q +echo >> %q +cat <<'RESPONSE' +%s +RESPONSE +`, requests, requests, response) + if err := os.WriteFile(filepath.Join(dir, pluginName), []byte(script), 0o700); err != nil { + t.Fatalf("Failed to write fake plugin: %v", err) + } + return requests +} + +// failingPlugin writes an executable that prints message to stderr and exits +// non-zero. +func failingPlugin(t *testing.T, dir, pluginName, message string) { + t.Helper() + script := fmt.Sprintf("#!/bin/sh\necho %q >&2\nexit 7\n", message) + if err := os.WriteFile(filepath.Join(dir, pluginName), []byte(script), 0o700); err != nil { + t.Fatalf("Failed to write failing plugin: %v", err) + } +} + +// writeConfig writes a CredentialProviderConfig into dir and returns its path. +func writeConfig(t *testing.T, dir, contents string) string { + t.Helper() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("Failed to write credential provider config: %v", err) + } + return path +} + +// gcpLikeConfig mirrors the CredentialProviderConfig a GKE node ships. +const gcpLikeConfig = `kind: CredentialProviderConfig +apiVersion: kubelet.config.k8s.io/v1 +providers: + - name: fake-provider + apiVersion: credentialprovider.kubelet.k8s.io/v1 + matchImages: + - "gcr.io" + - "*.gcr.io" + - "*.pkg.dev" + args: + - get-credentials + defaultCacheDuration: 1m +` + +// repo turns an image reference into the authn.Resource a pull would resolve +// against. +func repo(t *testing.T, ref string) authn.Resource { + t.Helper() + parsed, err := name.ParseReference(ref) + if err != nil { + t.Fatalf("Failed to parse reference %q: %v", ref, err) + } + return parsed.Context() +} + +// resolvedAuth resolves ref through kc and returns the resulting basic auth. +func resolvedAuth(t *testing.T, kc *Keychain, ref string) *authn.AuthConfig { + t.Helper() + authenticator, err := kc.Resolve(repo(t, ref)) + if err != nil { + t.Fatalf("Resolve(%q) returned unexpected error: %v", ref, err) + } + cfg, err := authenticator.Authorization() + if err != nil { + t.Fatalf("Authorization() returned unexpected error: %v", err) + } + return cfg +} + +func TestKeychainResolvesMatchingImage(t *testing.T) { + t.Parallel() + dir := t.TempDir() + requests := fakePlugin(t, dir, "fake-provider", `{ + "kind": "CredentialProviderResponse", + "apiVersion": "credentialprovider.kubelet.k8s.io/v1", + "cacheKeyType": "Registry", + "auth": {"*.pkg.dev": {"username": "_token", "password": "ya29.fake"}} +}`) + + kc, err := New(writeConfig(t, dir, gcpLikeConfig), dir) + if err != nil { + t.Fatalf("New returned unexpected error: %v", err) + } + + got := resolvedAuth(t, kc, "us-central1-docker.pkg.dev/proj/repo/img:latest") + if got.Username != "_token" || got.Password != "ya29.fake" { + t.Errorf("Resolve returned username %q password %q, want %q / %q", got.Username, got.Password, "_token", "ya29.fake") + } + + // The plugin must be handed a well-formed request naming the repository + // without its tag, which is the granularity the protocol's cache keys use. + raw, err := os.ReadFile(requests) + if err != nil { + t.Fatalf("Failed to read recorded requests: %v", err) + } + var req struct { + Kind string `json:"kind"` + APIVersion string `json:"apiVersion"` + Image string `json:"image"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(string(raw))), &req); err != nil { + t.Fatalf("Failed to parse recorded request %q: %v", raw, err) + } + if req.Kind != "CredentialProviderRequest" || req.APIVersion != supportedAPIVersion { + t.Errorf("Plugin received kind %q apiVersion %q, want %q / %q", req.Kind, req.APIVersion, "CredentialProviderRequest", supportedAPIVersion) + } + if want := "us-central1-docker.pkg.dev/proj/repo/img"; req.Image != want { + t.Errorf("Plugin received image %q, want %q", req.Image, want) + } +} + +func TestKeychainSkipsUnmatchedImage(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // An image no provider claims must not exec anything, so point the config + // at a plugin that would fail loudly if it ever ran. + failingPlugin(t, dir, "fake-provider", "plugin must not be invoked") + + kc, err := New(writeConfig(t, dir, gcpLikeConfig), dir) + if err != nil { + t.Fatalf("New returned unexpected error: %v", err) + } + + authenticator, err := kc.Resolve(repo(t, "docker.io/library/busybox:latest")) + if err != nil { + t.Fatalf("Resolve returned unexpected error: %v", err) + } + if authenticator != authn.Anonymous { + t.Errorf("Resolve returned %v for an unclaimed image, want authn.Anonymous", authenticator) + } +} + +func TestKeychainAnonymousWhenPluginReturnsNoAuth(t *testing.T) { + t.Parallel() + dir := t.TempDir() + fakePlugin(t, dir, "fake-provider", `{ + "kind": "CredentialProviderResponse", + "apiVersion": "credentialprovider.kubelet.k8s.io/v1", + "cacheKeyType": "Registry" +}`) + + kc, err := New(writeConfig(t, dir, gcpLikeConfig), dir) + if err != nil { + t.Fatalf("New returned unexpected error: %v", err) + } + + authenticator, err := kc.Resolve(repo(t, "gcr.io/proj/img:latest")) + if err != nil { + t.Fatalf("Resolve returned unexpected error: %v", err) + } + if authenticator != authn.Anonymous { + t.Errorf("Resolve returned %v when the plugin gave no credentials, want authn.Anonymous", authenticator) + } +} + +func TestKeychainSurfacesPluginFailure(t *testing.T) { + t.Parallel() + dir := t.TempDir() + failingPlugin(t, dir, "fake-provider", "metadata server unreachable") + + kc, err := New(writeConfig(t, dir, gcpLikeConfig), dir) + if err != nil { + t.Fatalf("New returned unexpected error: %v", err) + } + + _, err = kc.Resolve(repo(t, "gcr.io/proj/img:latest")) + if err == nil { + t.Fatal("Resolve returned no error for a failing plugin, want one") + } + // The plugin's own diagnostics are the only clue to why a pull lost its + // credentials, so they must reach the error. + if !strings.Contains(err.Error(), "metadata server unreachable") { + t.Errorf("Resolve error %q does not carry the plugin's stderr", err) + } +} + +func TestKeychainRejectsMismatchedResponseVersion(t *testing.T) { + t.Parallel() + dir := t.TempDir() + fakePlugin(t, dir, "fake-provider", `{ + "kind": "CredentialProviderResponse", + "apiVersion": "credentialprovider.kubelet.k8s.io/v1beta1", + "cacheKeyType": "Registry", + "auth": {"gcr.io": {"username": "u", "password": "p"}} +}`) + + kc, err := New(writeConfig(t, dir, gcpLikeConfig), dir) + if err != nil { + t.Fatalf("New returned unexpected error: %v", err) + } + + if _, err := kc.Resolve(repo(t, "gcr.io/proj/img:latest")); err == nil { + t.Fatal("Resolve accepted a response encoded at an unrequested apiVersion, want an error") + } +} + +func TestKeychainFallsThroughToSecondProvider(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // first-provider claims the image but has nothing for it; the keychain + // must go on to ask second-provider rather than give up. + fakePlugin(t, dir, "first-provider", `{ + "kind": "CredentialProviderResponse", + "apiVersion": "credentialprovider.kubelet.k8s.io/v1", + "cacheKeyType": "Registry", + "auth": {} +}`) + fakePlugin(t, dir, "second-provider", `{ + "kind": "CredentialProviderResponse", + "apiVersion": "credentialprovider.kubelet.k8s.io/v1", + "cacheKeyType": "Registry", + "auth": {"gcr.io": {"username": "second", "password": "creds"}} +}`) + + kc, err := New(writeConfig(t, dir, `kind: CredentialProviderConfig +apiVersion: kubelet.config.k8s.io/v1 +providers: + - name: first-provider + apiVersion: credentialprovider.kubelet.k8s.io/v1 + matchImages: ["gcr.io"] + defaultCacheDuration: 1m + - name: second-provider + apiVersion: credentialprovider.kubelet.k8s.io/v1 + matchImages: ["gcr.io"] + defaultCacheDuration: 1m +`), dir) + if err != nil { + t.Fatalf("New returned unexpected error: %v", err) + } + + got := resolvedAuth(t, kc, "gcr.io/proj/img:latest") + if got.Username != "second" { + t.Errorf("Resolve returned username %q, want %q from the second provider", got.Username, "second") + } +} + +// countRequests returns how many requests the fake plugin has recorded. +func countRequests(t *testing.T, requests string) int { + t.Helper() + raw, err := os.ReadFile(requests) + if err != nil { + t.Fatalf("Failed to read recorded requests: %v", err) + } + return len(strings.Fields(strings.TrimSpace(string(raw)))) +} + +func TestKeychainCaching(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + // cacheKeyType and cacheDuration as the plugin reports them. + cacheKeyType string + cacheDuration string + // second is resolved after the first image; wantExecs is how many + // times the plugin should have run in total by then. + first, second string + wantExecs int + }{ + { + name: "registry key reuses across repositories", + cacheKeyType: "Registry", + first: "gcr.io/proj/one:latest", + second: "gcr.io/proj/two:latest", + wantExecs: 1, + }, + { + name: "registry key does not span registries", + cacheKeyType: "Registry", + first: "gcr.io/proj/one:latest", + second: "us.gcr.io/proj/one:latest", + wantExecs: 2, + }, + { + name: "image key does not span repositories", + cacheKeyType: "Image", + first: "gcr.io/proj/one:latest", + second: "gcr.io/proj/two:latest", + wantExecs: 2, + }, + { + name: "image key reuses for the same repository", + cacheKeyType: "Image", + first: "gcr.io/proj/one:latest", + second: "gcr.io/proj/one:other", + wantExecs: 1, + }, + { + name: "global key spans registries", + cacheKeyType: "Global", + first: "gcr.io/proj/one:latest", + second: "us.gcr.io/other/two:latest", + wantExecs: 1, + }, + { + name: "zero duration disables caching", + cacheKeyType: "Registry", + cacheDuration: `"cacheDuration": "0s",`, + first: "gcr.io/proj/one:latest", + second: "gcr.io/proj/one:latest", + wantExecs: 2, + }, + { + name: "unknown key type disables caching", + cacheKeyType: "Nonsense", + first: "gcr.io/proj/one:latest", + second: "gcr.io/proj/one:latest", + wantExecs: 2, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + requests := fakePlugin(t, dir, "fake-provider", fmt.Sprintf(`{ + "kind": "CredentialProviderResponse", + "apiVersion": "credentialprovider.kubelet.k8s.io/v1", + "cacheKeyType": %q, + %s + "auth": {"*.gcr.io": {"username": "u", "password": "p"}, "gcr.io": {"username": "u", "password": "p"}} +}`, tc.cacheKeyType, tc.cacheDuration)) + + kc, err := New(writeConfig(t, dir, gcpLikeConfig), dir) + if err != nil { + t.Fatalf("New returned unexpected error: %v", err) + } + + for _, ref := range []string{tc.first, tc.second} { + if _, err := kc.Resolve(repo(t, ref)); err != nil { + t.Fatalf("Resolve(%q) returned unexpected error: %v", ref, err) + } + } + if got := countRequests(t, requests); got != tc.wantExecs { + t.Errorf("Plugin ran %d times, want %d", got, tc.wantExecs) + } + }) + } +} + +func TestKeychainCacheExpires(t *testing.T) { + t.Parallel() + dir := t.TempDir() + requests := fakePlugin(t, dir, "fake-provider", `{ + "kind": "CredentialProviderResponse", + "apiVersion": "credentialprovider.kubelet.k8s.io/v1", + "cacheKeyType": "Registry", + "auth": {"gcr.io": {"username": "u", "password": "p"}} +}`) + + kc, err := New(writeConfig(t, dir, gcpLikeConfig), dir) + if err != nil { + t.Fatalf("New returned unexpected error: %v", err) + } + clock := time.Now() + kc.plugins[0].now = func() time.Time { return clock } + + ref := repo(t, "gcr.io/proj/img:latest") + if _, err := kc.Resolve(ref); err != nil { + t.Fatalf("Resolve returned unexpected error: %v", err) + } + // The config's defaultCacheDuration is 1m; just short of it still hits. + clock = clock.Add(59 * time.Second) + if _, err := kc.Resolve(ref); err != nil { + t.Fatalf("Resolve returned unexpected error: %v", err) + } + if got := countRequests(t, requests); got != 1 { + t.Fatalf("Plugin ran %d times before the cache expired, want 1", got) + } + + clock = clock.Add(2 * time.Second) + if _, err := kc.Resolve(ref); err != nil { + t.Fatalf("Resolve returned unexpected error: %v", err) + } + if got := countRequests(t, requests); got != 2 { + t.Errorf("Plugin ran %d times after the cache expired, want 2", got) + } +} + +// Not parallel: t.Setenv mutates the process environment the plugin inherits. +func TestKeychainPassesConfiguredEnv(t *testing.T) { + dir := t.TempDir() + // The plugin echoes an env var back as the username, so the assertion + // covers both the configured env and the inherited process env. + script := `#!/bin/sh +cat > /dev/null +cat < /dev/null +cat < maxResponseBytes { + return nil, fmt.Errorf("credential provider %q returned %d bytes, over the %d byte limit", p.name, stdout.Len(), maxResponseBytes) + } + + var resp credentialproviderv1.CredentialProviderResponse + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + return nil, fmt.Errorf("while decoding response from credential provider %q: %w (stderr: %s)", p.name, err, truncate(stderr.String())) + } + // A response encoded at a version we did not ask for may have different + // field semantics, so refuse it rather than misread the credentials. + if resp.APIVersion != supportedAPIVersion { + return nil, fmt.Errorf("credential provider %q responded with apiVersion %q, want %q", p.name, resp.APIVersion, supportedAPIVersion) + } + return &resp, nil +} + +// truncate bounds plugin stderr so a chatty provider cannot flood atelet's +// logs through an error message. +func truncate(s string) string { + const limit = 2048 + s = strings.TrimSpace(s) + if len(s) <= limit { + return s + } + return s[:limit] + "... (truncated)" +} diff --git a/internal/credentialprovider/match.go b/internal/credentialprovider/match.go new file mode 100644 index 0000000000..9b34088d85 --- /dev/null +++ b/internal/credentialprovider/match.go @@ -0,0 +1,120 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package credentialprovider + +import ( + "fmt" + "net" + "net/url" + "path/filepath" + "sort" + "strings" +) + +// matchesImage reports whether glob matches image, using the kubelet's +// credential-provider matching rules (k8s.io/kubernetes/pkg/credentialprovider +// urlsMatch): the domain is split on "." and the path on "/", each resulting +// segment is matched with filepath.Match (so a glob never spans a segment, +// i.e. "*.io" does not match "k8s.gcr.io"), the glob's segments must be a +// prefix of the image's, and ports must be equal. +// +// Both arguments are scheme-less: "*.pkg.dev", "registry.io:8080/path", +// "us-central1-docker.pkg.dev/proj/repo/img". +func matchesImage(glob, image string) (bool, error) { + globURL, err := parseSchemelessURL(glob) + if err != nil { + return false, fmt.Errorf("while parsing match pattern %q: %w", glob, err) + } + imageURL, err := parseSchemelessURL(image) + if err != nil { + return false, fmt.Errorf("while parsing image %q: %w", image, err) + } + + globParts, globPort := splitURL(globURL) + imageParts, imagePort := splitURL(imageURL) + if globPort != imagePort { + return false, nil + } + // The pattern may be less specific than the image (a bare registry matches + // every repository under it), but never more. + if len(globParts) > len(imageParts) { + return false, nil + } + for i, globPart := range globParts { + matched, err := filepath.Match(globPart, imageParts[i]) + if err != nil { + return false, fmt.Errorf("while matching pattern %q against image %q: %w", glob, image, err) + } + if !matched { + return false, nil + } + } + return true, nil +} + +// parseSchemelessURL parses a registry/repository string that carries no +// scheme by lending it one, so net/url splits host, port and path for us. +func parseSchemelessURL(schemeless string) (*url.URL, error) { + parsed, err := url.Parse("https://" + schemeless) + if err != nil { + return nil, err + } + parsed.Scheme = "" + return parsed, nil +} + +// splitURL flattens a URL into the segment list matchesImage compares: the +// host split on "." followed by the path split on "/", with the port returned +// separately (globs are not allowed in ports). +func splitURL(u *url.URL) (parts []string, port string) { + host, port, err := net.SplitHostPort(u.Host) + if err != nil { + host, port = u.Host, "" + } + return append(strings.Split(host, "."), strings.Split(u.Path, "/")...), port +} + +// bestAuthKey picks the key of auth that best matches image, or "" when none +// does. Ties are broken the way the kubelet's docker keyring breaks them — +// reverse lexical order — which puts longer keys ahead of shorter keys sharing +// their prefix, and concrete keys ahead of wildcards (since "*" sorts below +// every character a registry name can start with). +func bestAuthKey[V any](auth map[string]V, image string) (string, error) { + var matches []string + for key := range auth { + matched, err := matchesImage(key, image) + if err != nil { + return "", err + } + if matched { + matches = append(matches, key) + } + } + if len(matches) == 0 { + return "", nil + } + sort.Sort(sort.Reverse(sort.StringSlice(matches))) + return matches[0], nil +} + +// registryOf returns the domain (with port, if any) of an image reference, +// which is the cache key for the "Registry" plugin cache-key type. +func registryOf(image string) string { + parsed, err := parseSchemelessURL(image) + if err != nil { + return image + } + return parsed.Host +} diff --git a/internal/credentialprovider/match_test.go b/internal/credentialprovider/match_test.go new file mode 100644 index 0000000000..083bae699a --- /dev/null +++ b/internal/credentialprovider/match_test.go @@ -0,0 +1,127 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package credentialprovider + +import "testing" + +func TestMatchesImage(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + glob string + image string + want bool + }{ + {name: "exact registry", glob: "gcr.io", image: "gcr.io/proj/img", want: true}, + {name: "registry only, no repo", glob: "gcr.io", image: "gcr.io", want: true}, + {name: "different registry", glob: "gcr.io", image: "quay.io/proj/img"}, + {name: "wildcard subdomain", glob: "*.gcr.io", image: "us.gcr.io/proj/img", want: true}, + {name: "wildcard does not match bare domain", glob: "*.gcr.io", image: "gcr.io/proj/img"}, + {name: "wildcard spans one segment only", glob: "*.gcr.io", image: "a.b.gcr.io/proj/img"}, + {name: "pkg.dev regional", glob: "*.pkg.dev", image: "us-central1-docker.pkg.dev/proj/repo/img", want: true}, + {name: "partial subdomain glob", glob: "app*.k8s.io", image: "appfoo.k8s.io/img", want: true}, + {name: "top level domain glob", glob: "k8s.*", image: "k8s.io/img", want: true}, + {name: "multiple globs", glob: "*.*.registry.io", image: "a.b.registry.io/img", want: true}, + {name: "path prefix matches", glob: "registry.io/path", image: "registry.io/path/deeper/img", want: true}, + {name: "path prefix must match exactly", glob: "registry.io/path", image: "registry.io/other/img"}, + {name: "image shallower than pattern", glob: "registry.io/a/b/c", image: "registry.io/a"}, + {name: "matching port", glob: "registry.io:8080/path", image: "registry.io:8080/path/img", want: true}, + {name: "mismatched port", glob: "registry.io:8080/path", image: "registry.io:9090/path/img"}, + {name: "pattern port, image none", glob: "registry.io:8080", image: "registry.io/img"}, + {name: "image port, pattern none", glob: "registry.io", image: "registry.io:8080/img"}, + {name: "localhost registry", glob: "localhost:5000", image: "localhost:5000/img", want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := matchesImage(tc.glob, tc.image) + if err != nil { + t.Fatalf("matchesImage(%q, %q) returned unexpected error: %v", tc.glob, tc.image, err) + } + if got != tc.want { + t.Errorf("matchesImage(%q, %q) = %v, want %v", tc.glob, tc.image, got, tc.want) + } + }) + } +} + +func TestBestAuthKey(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + auth map[string]string + image string + want string + }{ + { + name: "no keys", + auth: map[string]string{}, + image: "gcr.io/proj/img", + }, + { + name: "no matching key", + auth: map[string]string{"quay.io": "a"}, + image: "gcr.io/proj/img", + }, + { + name: "single match", + auth: map[string]string{"*.pkg.dev": "a"}, + image: "us-docker.pkg.dev/proj/repo/img", + want: "*.pkg.dev", + }, + { + name: "concrete key beats wildcard", + auth: map[string]string{"*.pkg.dev": "a", "us-docker.pkg.dev": "b"}, + image: "us-docker.pkg.dev/proj/repo/img", + want: "us-docker.pkg.dev", + }, + { + name: "longer path beats shorter", + auth: map[string]string{"gcr.io": "a", "gcr.io/proj": "b"}, + image: "gcr.io/proj/img", + want: "gcr.io/proj", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := bestAuthKey(tc.auth, tc.image) + if err != nil { + t.Fatalf("bestAuthKey returned unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("bestAuthKey(%v, %q) = %q, want %q", tc.auth, tc.image, got, tc.want) + } + }) + } +} + +func TestRegistryOf(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + image string + want string + }{ + {image: "gcr.io/proj/img", want: "gcr.io"}, + {image: "us-docker.pkg.dev/proj/repo/img", want: "us-docker.pkg.dev"}, + {image: "localhost:5000/img", want: "localhost:5000"}, + {image: "gcr.io", want: "gcr.io"}, + } { + t.Run(tc.image, func(t *testing.T) { + t.Parallel() + if got := registryOf(tc.image); got != tc.want { + t.Errorf("registryOf(%q) = %q, want %q", tc.image, got, tc.want) + } + }) + } +} diff --git a/internal/imagecache/imagecache.go b/internal/imagecache/imagecache.go index 5ac985fd81..c15bf1a802 100644 --- a/internal/imagecache/imagecache.go +++ b/internal/imagecache/imagecache.go @@ -106,6 +106,10 @@ type Store struct { // GCP credentials (gcr.io / pkg.dev). See remoteOpts. authenticator authn.Authenticator + // keychain, when set, resolves credentials per registry and takes + // precedence over authenticator. See remoteOpts. + keychain authn.Keychain + localhostRegistryReplacement string // platform overrides the default pull platform (linux/GOARCH), for @@ -151,6 +155,13 @@ func WithAuthenticator(a authn.Authenticator) Option { return func(s *Store) { s.authenticator = a } } +// WithKeychain attaches a keychain consulted for every pull, whatever the +// registry — the keychain itself decides which registries it has credentials +// for. It takes precedence over WithAuthenticator. A nil keychain is ignored. +func WithKeychain(k authn.Keychain) Option { + return func(s *Store) { s.keychain = k } +} + // WithLocalhostRegistryReplacement rewrites localhost/loopback registry refs // to the given endpoint, mirroring the containerd mirror config used by kind // local registries (https://kind.sigs.k8s.io/docs/user/local-registry/). @@ -679,8 +690,10 @@ func (s *Store) remoteOpts(ctx context.Context, parsedRef name.Reference) []remo remote.WithContext(ctx), remote.WithPlatform(platform), } - registry := parsedRef.Context().Registry.RegistryStr() - if s.authenticator != nil && registryUsesGCPAuth(registry) { + switch registry := parsedRef.Context().Registry.RegistryStr(); { + case s.keychain != nil: + opts = append(opts, remote.WithAuthFromKeychain(s.keychain)) + case s.authenticator != nil && registryUsesGCPAuth(registry): opts = append(opts, remote.WithAuth(s.authenticator)) } return opts diff --git a/internal/imagecache/keychain_test.go b/internal/imagecache/keychain_test.go new file mode 100644 index 0000000000..ae41327645 --- /dev/null +++ b/internal/imagecache/keychain_test.go @@ -0,0 +1,132 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package imagecache + +import ( + "archive/tar" + "context" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/registry" + v1 "github.com/google/go-containerregistry/pkg/v1" +) + +// newAuthedRegistry starts an in-memory registry that is open until +// requireAuth is called, after which every request needs the given basic +// credentials. Tests push their fixtures while it is open, then close it, so +// any later success is attributable to the credentials under test. +func newAuthedRegistry(t *testing.T, username, password string) (host string, requireAuth func()) { + t.Helper() + var authRequired atomic.Bool + inner := registry.New(registry.Logger(log.New(io.Discard, "", 0))) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if authRequired.Load() { + if u, p, ok := r.BasicAuth(); !ok || u != username || p != password { + w.Header().Set("WWW-Authenticate", `Basic realm="test"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + } + inner.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("parsing registry URL: %v", err) + } + return u.Host, func() { authRequired.Store(true) } +} + +// staticKeychain hands out the same credentials for every resource, recording +// what it was asked about. +type staticKeychain struct { + auth authn.Authenticator + resolved []string +} + +func (k *staticKeychain) Resolve(target authn.Resource) (authn.Authenticator, error) { + k.resolved = append(k.resolved, target.String()) + return k.auth, nil +} + +func TestEnsureImage_KeychainAuthenticatesPull(t *testing.T) { + host, requireAuth := newAuthedRegistry(t, "robot", "s3cret") + ref := host + "/test/app:latest" + layer := layerFromEntries(t, []tarEntry{ + {name: "app/", typeflag: tar.TypeDir}, + {name: "app/main", typeflag: tar.TypeReg, mode: 0o755, body: "main"}, + }) + pushImage(t, ref, v1.Config{}, layer) + requireAuth() + + // Without credentials the registry rejects the pull, so a success below is + // attributable to the keychain and nothing else. + if _, err := newTestStore(t).EnsureImage(context.Background(), ref); err == nil { + t.Fatal("EnsureImage succeeded against an authenticated registry with no keychain, want an error") + } + + kc := &staticKeychain{auth: authn.FromConfig(authn.AuthConfig{Username: "robot", Password: "s3cret"})} + if _, err := newTestStore(t, WithKeychain(kc)).EnsureImage(context.Background(), ref); err != nil { + t.Fatalf("EnsureImage with a keychain: %v", err) + } + if len(kc.resolved) == 0 { + t.Error("keychain was never consulted") + } + for _, got := range kc.resolved { + if want := host + "/test/app"; got != want { + t.Errorf("keychain resolved %q, want %q", got, want) + } + } +} + +func TestRemoteOptsKeychainTakesPrecedence(t *testing.T) { + kc := &staticKeychain{auth: authn.Anonymous} + auth := authn.FromConfig(authn.AuthConfig{Username: "u"}) + + // remote.Option values are opaque, so assert on how many the store + // attaches: two unconditional ones (context, platform) plus at most one + // credential option. + const base = 2 + for _, tc := range []struct { + name string + opts []Option + ref string + want int + }{ + {name: "no credentials", ref: "gcr.io/proj/img", want: base}, + {name: "gcp authenticator, gcp registry", opts: []Option{WithAuthenticator(auth)}, ref: "gcr.io/proj/img", want: base + 1}, + {name: "gcp authenticator, other registry", opts: []Option{WithAuthenticator(auth)}, ref: "quay.io/proj/img", want: base}, + {name: "keychain, any registry", opts: []Option{WithKeychain(kc)}, ref: "quay.io/proj/img", want: base + 1}, + {name: "keychain wins over authenticator", opts: []Option{WithKeychain(kc), WithAuthenticator(auth)}, ref: "quay.io/proj/img", want: base + 1}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newTestStore(t, tc.opts...) + parsed, err := s.parseRef(tc.ref) + if err != nil { + t.Fatalf("parseRef(%q): %v", tc.ref, err) + } + if got := len(s.remoteOpts(context.Background(), parsed)); got != tc.want { + t.Errorf("remoteOpts returned %d options, want %d", got, tc.want) + } + }) + } +} diff --git a/manifests/ate-install/atelet.yaml b/manifests/ate-install/atelet.yaml index 8f28579a35..784b36adb5 100644 --- a/manifests/ate-install/atelet.yaml +++ b/manifests/ate-install/atelet.yaml @@ -112,7 +112,14 @@ spec: - name: atelet image: ko://github.com/agent-substrate/substrate/cmd/atelet args: - - --gcp-auth-for-image-pulls=true + # Image pull credentials come from the node's own kubelet image + # credential provider plugin, mounted read-only below, rather than from + # a cloud SDK compiled into atelet. The plugin and its config are + # whatever the node distribution ships, so the same atelet + # authenticates to Artifact Registry on GKE and to ECR on EKS. + - --image-credential-provider-config=/run/image-credential-provider/config.yaml + - --image-credential-provider-bin-dir=/run/image-credential-provider/bin + - --gcp-auth-for-image-pulls=false - --grpc-server-cred-bundle=/run/podidentity.podcert.ate.dev/credential-bundle.pem - --client-ca-certs=/run/podidentity.podcert.ate.dev/trust-bundle.pem - --ateapi-ca-file=/run/servicedns.podcert.ate.dev/trust-bundle.pem @@ -190,6 +197,12 @@ spec: readOnly: true - name: kubelet-plugins mountPath: /var/lib/kubelet/plugins + - name: image-credential-provider-bin + mountPath: /run/image-credential-provider/bin + readOnly: true + - name: image-credential-provider-config + mountPath: /run/image-credential-provider/config.yaml + readOnly: true volumes: - name: run-ateom hostPath: @@ -200,6 +213,21 @@ spec: hostPath: path: /var/lib/kubelet/plugins type: DirectoryOrCreate + # The kubelet's own image credential provider plugin directory and + # config, i.e. the values of its --image-credential-provider-bin-dir and + # --image-credential-provider-config flags. Paths below are GKE's; other + # node distributions place them elsewhere. Read-only, but note the bin + # dir also holds the node's other kubelet-installed binaries: the plugin + # contract is directory-scoped, so a tighter mount would have to hard-code + # the provider's name and give up being cloud-agnostic. + - name: image-credential-provider-bin + hostPath: + path: /home/kubernetes/bin + type: DirectoryOrCreate + - name: image-credential-provider-config + hostPath: + path: /etc/srv/kubernetes/cri_auth_config.yaml + type: FileOrCreate # Identity for mutual TLS with the ate-apiserver. atelet is not behind a # Service, so it uses a podidentity (SPIFFE) cert rather than a servicedns # serving cert (which requires DNS SANs it would not have); the diff --git a/manifests/ate-install/kind/atelet/kustomization.yaml b/manifests/ate-install/kind/atelet/kustomization.yaml index 2a0cfa4113..767a651f4d 100644 --- a/manifests/ate-install/kind/atelet/kustomization.yaml +++ b/manifests/ate-install/kind/atelet/kustomization.yaml @@ -56,3 +56,19 @@ patches: value: rustfsadmin - name: AWS_SECRET_ACCESS_KEY value: rustfsadmin + # kind nodes ship no image credential provider plugin (the args + # above leave atelet's --image-credential-provider-* flags + # unset), so drop the host mounts the base manifest adds for + # them rather than fabricating empty ones on the node. + # volumeMounts merges on mountPath, not name -- keying these by + # name matches nothing and silently empties the whole list. + volumeMounts: + - mountPath: /run/image-credential-provider/bin + $patch: delete + - mountPath: /run/image-credential-provider/config.yaml + $patch: delete + volumes: + - name: image-credential-provider-bin + $patch: delete + - name: image-credential-provider-config + $patch: delete diff --git a/vendor/k8s.io/kubelet/LICENSE b/vendor/k8s.io/kubelet/LICENSE new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/vendor/k8s.io/kubelet/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/k8s.io/kubelet/config/v1/doc.go b/vendor/k8s.io/kubelet/config/v1/doc.go new file mode 100644 index 0000000000..2b8cbe19fe --- /dev/null +++ b/vendor/k8s.io/kubelet/config/v1/doc.go @@ -0,0 +1,23 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:openapi-model-package=io.k8s.kubelet.config.v1 + +// +groupName=kubelet.config.k8s.io + +package v1 diff --git a/vendor/k8s.io/kubelet/config/v1/register.go b/vendor/k8s.io/kubelet/config/v1/register.go new file mode 100644 index 0000000000..effb5b3817 --- /dev/null +++ b/vendor/k8s.io/kubelet/config/v1/register.go @@ -0,0 +1,43 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GroupName is the group name used in this package +const GroupName = "kubelet.config.k8s.io" + +// SchemeGroupVersion is group version used to register these objects +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1"} + +var ( + // SchemeBuilder is the scheme builder with scheme init functions to run for this API package + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + // AddToScheme is a global function that registers this API group & version to a scheme + AddToScheme = SchemeBuilder.AddToScheme +) + +// addKnownTypes registers known types to the given scheme +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &CredentialProviderConfig{}, + ) + return nil +} diff --git a/vendor/k8s.io/kubelet/config/v1/types.go b/vendor/k8s.io/kubelet/config/v1/types.go new file mode 100644 index 0000000000..6ff4d5541f --- /dev/null +++ b/vendor/k8s.io/kubelet/config/v1/types.go @@ -0,0 +1,189 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ServiceAccountTokenCacheType is the type of cache key used for caching credentials returned by the plugin +// when the service account token is used. +type ServiceAccountTokenCacheType string + +const ( + // TokenServiceAccountTokenCacheType means the kubelet will cache returned credentials + // on a per-token basis. This should be set if the returned credential's lifetime is limited + // to the input service account token's lifetime. + // For example, this must be used when returning the input service account token directly as a pull credential. + TokenServiceAccountTokenCacheType ServiceAccountTokenCacheType = "Token" + // ServiceAccountServiceAccountTokenCacheType means the kubelet will cache returned credentials + // on a per-serviceaccount basis. This should be set if the plugin's credential retrieval logic + // depends only on the service account and not on pod-specific claims. + // Use this when the returned credential is valid for all pods using the same service account. + ServiceAccountServiceAccountTokenCacheType ServiceAccountTokenCacheType = "ServiceAccount" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// CredentialProviderConfig is the configuration containing information about +// each exec credential provider. Kubelet reads this configuration from disk and enables +// each provider as specified by the CredentialProvider type. +type CredentialProviderConfig struct { + metav1.TypeMeta `json:",inline"` + + // providers is a list of credential provider plugins that will be enabled by the kubelet. + // Multiple providers may match against a single image, in which case credentials + // from all providers will be returned to the kubelet. If multiple providers are called + // for a single image, the results are combined. If providers return overlapping + // auth keys, the value from the provider earlier in this list is attempted first. + Providers []CredentialProvider `json:"providers"` +} + +// CredentialProvider represents an exec plugin to be invoked by the kubelet. The plugin is only +// invoked when an image being pulled matches the images handled by the plugin (see matchImages). +type CredentialProvider struct { + // name is the required name of the credential provider. It must match the name of the + // provider executable as seen by the kubelet. The executable must be in the kubelet's + // bin directory (set by the --image-credential-provider-bin-dir flag). + // Required to be unique across all providers. + Name string `json:"name"` + + // matchImages is a required list of strings used to match against images in order to + // determine if this provider should be invoked. If one of the strings matches the + // requested image from the kubelet, the plugin will be invoked and given a chance + // to provide credentials. Images are expected to contain the registry domain + // and URL path. + // + // Each entry in matchImages is a pattern which can optionally contain a port and a path. + // Globs can be used in the domain, but not in the port or the path. Globs are supported + // as subdomains like '*.k8s.io' or 'k8s.*.io', and top-level-domains such as 'k8s.*'. + // Matching partial subdomains like 'app*.k8s.io' is also supported. Each glob can only match + // a single subdomain segment, so *.io does not match *.k8s.io. + // + // A match exists between an image and a matchImage when all of the below are true: + // - Both contain the same number of domain parts and each part matches. + // - The URL path of an imageMatch must be a prefix of the target image URL path. + // - If the imageMatch contains a port, then the port must match in the image as well. + // + // Example values of matchImages: + // - 123456789.dkr.ecr.us-east-1.amazonaws.com + // - *.azurecr.io + // - gcr.io + // - *.*.registry.io + // - registry.io:8080/path + MatchImages []string `json:"matchImages"` + + // defaultCacheDuration is the default duration the plugin will cache credentials in-memory + // if a cache duration is not provided in the plugin response. This field is required. + DefaultCacheDuration *metav1.Duration `json:"defaultCacheDuration"` + + // Required input version of the exec CredentialProviderRequest. The returned CredentialProviderResponse + // MUST use the same encoding version as the input. Current supported values are: + // - credentialprovider.kubelet.k8s.io/v1 + APIVersion string `json:"apiVersion"` + + // Arguments to pass to the command when executing it. + // +optional + Args []string `json:"args,omitempty"` + + // Env defines additional environment variables to expose to the process. These + // are unioned with the host's environment, as well as variables client-go uses + // to pass argument to the plugin. + // +optional + Env []ExecEnvVar `json:"env,omitempty"` + + // tokenAttributes is the configuration for the service account token that will be passed to the plugin. + // The credential provider opts in to using service account tokens for image pull by setting this field. + // When this field is set, kubelet will generate a service account token bound to the pod for which the + // image is being pulled and pass to the plugin as part of CredentialProviderRequest along with other + // attributes required by the plugin. + // + // The service account metadata and token attributes will be used as a dimension to cache + // the credentials in kubelet. The cache key is generated by combining the service account metadata + // (namespace, name, UID, and annotations key+value for the keys defined in + // serviceAccountTokenAttribute.requiredServiceAccountAnnotationKeys and serviceAccountTokenAttribute.optionalServiceAccountAnnotationKeys). + // The pod metadata (namespace, name, UID) that are in the service account token are not used as a dimension + // to cache the credentials in kubelet. This means workloads that are using the same service account + // could end up using the same credentials for image pull. For plugins that don't want this behavior, or + // plugins that operate in pass-through mode; i.e., they return the service account token as-is, they + // can set the credentialProviderResponse.cacheDuration to 0. This will disable the caching of + // credentials in kubelet and the plugin will be invoked for every image pull. This does result in + // token generation overhead for every image pull, but it is the only way to ensure that the + // credentials are not shared across pods (even if they are using the same service account). + // +optional + TokenAttributes *ServiceAccountTokenAttributes `json:"tokenAttributes,omitempty"` +} + +// ServiceAccountTokenAttributes is the configuration for the service account token that will be passed to the plugin. +type ServiceAccountTokenAttributes struct { + // serviceAccountTokenAudience is the intended audience for the projected service account token. + // +required + ServiceAccountTokenAudience string `json:"serviceAccountTokenAudience"` + + // cacheType indicates the type of cache key use for caching the credentials returned by the plugin + // when the service account token is used. + // The most conservative option is to set this to "Token", which means the kubelet will cache returned credentials + // on a per-token basis. This should be set if the returned credential's lifetime is limited to the service account + // token's lifetime. + // If the plugin's credential retrieval logic depends only on the service account and not on pod-specific claims, + // then the plugin can set this to "ServiceAccount". In this case, the kubelet will cache returned credentials + // on a per-serviceaccount basis. Use this when the returned credential is valid for all pods using the same service account. + // +required + CacheType ServiceAccountTokenCacheType `json:"cacheType"` + + // requireServiceAccount indicates whether the plugin requires the pod to have a service account. + // If set to true, kubelet will only invoke the plugin if the pod has a service account. + // If set to false, kubelet will invoke the plugin even if the pod does not have a service account + // and will not include a token in the CredentialProviderRequest in that scenario. This is useful for plugins that + // are used to pull images for pods without service accounts (e.g., static pods). + // +required + RequireServiceAccount *bool `json:"requireServiceAccount"` + + // requiredServiceAccountAnnotationKeys is the list of annotation keys that the plugin is interested in + // and that are required to be present in the service account. + // The keys defined in this list will be extracted from the corresponding service account and passed + // to the plugin as part of the CredentialProviderRequest. If any of the keys defined in this list + // are not present in the service account, kubelet will not invoke the plugin and will return an error. + // This field is optional and may be empty. Plugins may use this field to extract + // additional information required to fetch credentials or allow workloads to opt in to + // using service account tokens for image pull. + // If non-empty, requireServiceAccount must be set to true. + // Keys in this list must be unique. + // This list needs to be mutually exclusive with optionalServiceAccountAnnotationKeys. + // +optional + // +listType=set + RequiredServiceAccountAnnotationKeys []string `json:"requiredServiceAccountAnnotationKeys,omitempty"` + + // optionalServiceAccountAnnotationKeys is the list of annotation keys that the plugin is interested in + // and that are optional to be present in the service account. + // The keys defined in this list will be extracted from the corresponding service account and passed + // to the plugin as part of the CredentialProviderRequest. The plugin is responsible for validating + // the existence of annotations and their values. + // This field is optional and may be empty. Plugins may use this field to extract + // additional information required to fetch credentials. + // Keys in this list must be unique. + // +optional + // +listType=set + OptionalServiceAccountAnnotationKeys []string `json:"optionalServiceAccountAnnotationKeys,omitempty"` +} + +// ExecEnvVar is used for setting environment variables when executing an exec-based +// credential plugin. +type ExecEnvVar struct { + Name string `json:"name"` + Value string `json:"value"` +} diff --git a/vendor/k8s.io/kubelet/config/v1/zz_generated.deepcopy.go b/vendor/k8s.io/kubelet/config/v1/zz_generated.deepcopy.go new file mode 100644 index 0000000000..481dd988a5 --- /dev/null +++ b/vendor/k8s.io/kubelet/config/v1/zz_generated.deepcopy.go @@ -0,0 +1,147 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CredentialProvider) DeepCopyInto(out *CredentialProvider) { + *out = *in + if in.MatchImages != nil { + in, out := &in.MatchImages, &out.MatchImages + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.DefaultCacheDuration != nil { + in, out := &in.DefaultCacheDuration, &out.DefaultCacheDuration + *out = new(metav1.Duration) + **out = **in + } + if in.Args != nil { + in, out := &in.Args, &out.Args + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make([]ExecEnvVar, len(*in)) + copy(*out, *in) + } + if in.TokenAttributes != nil { + in, out := &in.TokenAttributes, &out.TokenAttributes + *out = new(ServiceAccountTokenAttributes) + (*in).DeepCopyInto(*out) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CredentialProvider. +func (in *CredentialProvider) DeepCopy() *CredentialProvider { + if in == nil { + return nil + } + out := new(CredentialProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CredentialProviderConfig) DeepCopyInto(out *CredentialProviderConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.Providers != nil { + in, out := &in.Providers, &out.Providers + *out = make([]CredentialProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CredentialProviderConfig. +func (in *CredentialProviderConfig) DeepCopy() *CredentialProviderConfig { + if in == nil { + return nil + } + out := new(CredentialProviderConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CredentialProviderConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExecEnvVar) DeepCopyInto(out *ExecEnvVar) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecEnvVar. +func (in *ExecEnvVar) DeepCopy() *ExecEnvVar { + if in == nil { + return nil + } + out := new(ExecEnvVar) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceAccountTokenAttributes) DeepCopyInto(out *ServiceAccountTokenAttributes) { + *out = *in + if in.RequireServiceAccount != nil { + in, out := &in.RequireServiceAccount, &out.RequireServiceAccount + *out = new(bool) + **out = **in + } + if in.RequiredServiceAccountAnnotationKeys != nil { + in, out := &in.RequiredServiceAccountAnnotationKeys, &out.RequiredServiceAccountAnnotationKeys + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.OptionalServiceAccountAnnotationKeys != nil { + in, out := &in.OptionalServiceAccountAnnotationKeys, &out.OptionalServiceAccountAnnotationKeys + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceAccountTokenAttributes. +func (in *ServiceAccountTokenAttributes) DeepCopy() *ServiceAccountTokenAttributes { + if in == nil { + return nil + } + out := new(ServiceAccountTokenAttributes) + in.DeepCopyInto(out) + return out +} diff --git a/vendor/k8s.io/kubelet/config/v1/zz_generated.model_name.go b/vendor/k8s.io/kubelet/config/v1/zz_generated.model_name.go new file mode 100644 index 0000000000..bc69a465d6 --- /dev/null +++ b/vendor/k8s.io/kubelet/config/v1/zz_generated.model_name.go @@ -0,0 +1,42 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by openapi-gen. DO NOT EDIT. + +package v1 + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in CredentialProvider) OpenAPIModelName() string { + return "io.k8s.kubelet.config.v1.CredentialProvider" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in CredentialProviderConfig) OpenAPIModelName() string { + return "io.k8s.kubelet.config.v1.CredentialProviderConfig" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in ExecEnvVar) OpenAPIModelName() string { + return "io.k8s.kubelet.config.v1.ExecEnvVar" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in ServiceAccountTokenAttributes) OpenAPIModelName() string { + return "io.k8s.kubelet.config.v1.ServiceAccountTokenAttributes" +} diff --git a/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/OWNERS b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/OWNERS new file mode 100644 index 0000000000..b9a5d28459 --- /dev/null +++ b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/OWNERS @@ -0,0 +1,5 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +reviewers: + - sig-node-api-reviewers + - sig-auth-api-reviewers diff --git a/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/doc.go b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/doc.go new file mode 100644 index 0000000000..7f90183fa4 --- /dev/null +++ b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// +k8s:deepcopy-gen=package +// +groupName=credentialprovider.kubelet.k8s.io + +package credentialprovider diff --git a/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/register.go b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/register.go new file mode 100644 index 0000000000..9319c21fe6 --- /dev/null +++ b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/register.go @@ -0,0 +1,41 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentialprovider + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GroupName is the group name use in this package +const GroupName = "credentialprovider.kubelet.k8s.io" + +// SchemeGroupVersion is group version used to register these objects +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: runtime.APIVersionInternal} + +var ( + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + AddToScheme = SchemeBuilder.AddToScheme +) + +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &CredentialProviderRequest{}, + &CredentialProviderResponse{}, + ) + return nil +} diff --git a/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/types.go b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/types.go new file mode 100644 index 0000000000..fabd318e0d --- /dev/null +++ b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/types.go @@ -0,0 +1,128 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentialprovider + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// CredentialProviderRequest includes the image that the kubelet requires authentication for. +// Kubelet will pass this request object to the plugin via stdin. In general, plugins should +// prefer responding with the same apiVersion they were sent. +type CredentialProviderRequest struct { + metav1.TypeMeta + + // image is the container image that is being pulled as part of the + // credential provider plugin request. Plugins may optionally parse the image + // to extract any information required to fetch credentials. + Image string + + // serviceAccountToken is the service account token bound to the pod for which + // the image is being pulled. This token is only sent to the plugin if the + // tokenAttributes.serviceAccountTokenAudience field is configured in the kubelet's credential provider configuration. + ServiceAccountToken string + + // serviceAccountAnnotations is a map of annotations on the service account bound to the + // pod for which the image is being pulled. The list of annotations in the service account + // that need to be passed to the plugin is configured in the kubelet's credential provider + // configuration. + ServiceAccountAnnotations map[string]string +} + +type PluginCacheKeyType string + +const ( + // ImagePluginCacheKeyType means the kubelet will cache credentials on a per-image basis, + // using the image passed from the kubelet directly as the cache key. This includes + // the registry domain, port (if specified), and path but does not include tags or SHAs. + ImagePluginCacheKeyType PluginCacheKeyType = "Image" + // RegistryPluginCacheKeyType means the kubelet will cache credentials on a per-registry basis. + // The cache key will be based on the registry domain and port (if present) parsed from the requested image. + RegistryPluginCacheKeyType PluginCacheKeyType = "Registry" + // GlobalPluginCacheKeyType means the kubelet will cache credentials for all images that + // match for a given plugin. This cache key should only be returned by plugins that do not use + // the image input at all. + GlobalPluginCacheKeyType PluginCacheKeyType = "Global" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// CredentialProviderResponse holds credentials that the kubelet should use for the specified +// image provided in the original request. Kubelet will read the response from the plugin via stdout. +// This response should be set to the same apiVersion as CredentialProviderRequest. +type CredentialProviderResponse struct { + metav1.TypeMeta + + // cacheKeyType indiciates the type of caching key to use based on the image provided + // in the request. There are three valid values for the cache key type: Image, Registry, and + // Global. If an invalid value is specified, the response will NOT be used by the kubelet. + CacheKeyType PluginCacheKeyType + + // cacheDuration indicates the duration the provided credentials should be cached for. + // The kubelet will use this field to set the in-memory cache duration for credentials + // in the AuthConfig. If null, the kubelet will use defaultCacheDuration provided in + // CredentialProviderConfig. If set to 0, the kubelet will not cache the provided AuthConfig. + // +optional + CacheDuration *metav1.Duration + + // auth is a map containing authentication information passed into the kubelet. + // Each key is a match image string (more on this below). The corresponding authConfig value + // should be valid for all images that match against this key. A plugin should set + // this field to null if no valid credentials can be returned for the requested image. + // + // Each key in the map is a pattern which can optionally contain a port and a path. + // Globs can be used in the domain, but not in the port or the path. Globs are supported + // as subdomains like '*.k8s.io' or 'k8s.*.io', and top-level-domains such as 'k8s.*'. + // Matching partial subdomains like 'app*.k8s.io' is also supported. Each glob can only match + // a single subdomain segment, so *.io does not match *.k8s.io. + // + // The kubelet will match images against the key when all of the below are true: + // - Both contain the same number of domain parts and each part matches. + // - The URL path of an imageMatch must be a prefix of the target image URL path. + // - If the imageMatch contains a port, then the port must match in the image as well. + // + // When multiple keys are returned, the kubelet will traverse all keys in reverse order so that: + // - longer keys come before shorter keys with the same prefix + // - non-wildcard keys come before wildcard keys with the same prefix. + // + // For any given match, the kubelet will attempt an image pull with the provided credentials, + // stopping after the first successfully authenticated pull. + // + // Example keys: + // - 123456789.dkr.ecr.us-east-1.amazonaws.com + // - *.azurecr.io + // - gcr.io + // - *.*.registry.io + // - registry.io:8080/path + // +optional + Auth map[string]AuthConfig +} + +// AuthConfig contains authentication information for a container registry. +// Only username/password based authentication is supported today, but more authentication +// mechanisms may be added in the future. +type AuthConfig struct { + // username is the username used for authenticating to the container registry + // An empty username is valid. + Username string + + // password is the password used for authenticating to the container registry + // An empty password is valid. + Password string +} diff --git a/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/doc.go b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/doc.go new file mode 100644 index 0000000000..871ccc86f6 --- /dev/null +++ b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/doc.go @@ -0,0 +1,22 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// +k8s:deepcopy-gen=package +// +k8s:conversion-gen=k8s.io/kubelet/pkg/apis/credentialprovider +// +k8s:defaulter-gen=TypeMeta +// +groupName=credentialprovider.kubelet.k8s.io + +package v1 diff --git a/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/register.go b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/register.go new file mode 100644 index 0000000000..0f8177bd8c --- /dev/null +++ b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/register.go @@ -0,0 +1,46 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GroupName is the group name use in this package +const GroupName = "credentialprovider.kubelet.k8s.io" + +// SchemeGroupVersion is group version used to register these objects +var ( + SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1"} + localSchemeBuilder = &SchemeBuilder +) + +var ( + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + AddToScheme = SchemeBuilder.AddToScheme +) + +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &CredentialProviderRequest{}, + &CredentialProviderResponse{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/types.go b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/types.go new file mode 100644 index 0000000000..994f34610a --- /dev/null +++ b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/types.go @@ -0,0 +1,129 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// CredentialProviderRequest includes the image that the kubelet requires authentication for. +// Kubelet will pass this request object to the plugin via stdin. In general, plugins should +// prefer responding with the same apiVersion they were sent. +type CredentialProviderRequest struct { + metav1.TypeMeta `json:",inline"` + + // image is the container image that is being pulled as part of the + // credential provider plugin request. Plugins may optionally parse the image + // to extract any information required to fetch credentials. + Image string `json:"image"` + + // serviceAccountToken is the service account token bound to the pod for which + // the image is being pulled. This token is only sent to the plugin if the + // tokenAttributes.serviceAccountTokenAudience field is configured in the kubelet's credential + // provider configuration. + ServiceAccountToken string `json:"serviceAccountToken,omitempty" datapolicy:"token"` + + // serviceAccountAnnotations is a map of annotations on the service account bound to the + // pod for which the image is being pulled. The list of annotations in the service account + // that need to be passed to the plugin is configured in the kubelet's credential provider + // configuration. + ServiceAccountAnnotations map[string]string `json:"serviceAccountAnnotations,omitempty"` +} + +type PluginCacheKeyType string + +const ( + // ImagePluginCacheKeyType means the kubelet will cache credentials on a per-image basis, + // using the image passed from the kubelet directly as the cache key. This includes + // the registry domain, port (if specified), and path but does not include tags or SHAs. + ImagePluginCacheKeyType PluginCacheKeyType = "Image" + // RegistryPluginCacheKeyType means the kubelet will cache credentials on a per-registry basis. + // The cache key will be based on the registry domain and port (if present) parsed from the requested image. + RegistryPluginCacheKeyType PluginCacheKeyType = "Registry" + // GlobalPluginCacheKeyType means the kubelet will cache credentials for all images that + // match for a given plugin. This cache key should only be returned by plugins that do not use + // the image input at all. + GlobalPluginCacheKeyType PluginCacheKeyType = "Global" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// CredentialProviderResponse holds credentials that the kubelet should use for the specified +// image provided in the original request. Kubelet will read the response from the plugin via stdout. +// This response should be set to the same apiVersion as CredentialProviderRequest. +type CredentialProviderResponse struct { + metav1.TypeMeta `json:",inline"` + + // cacheKeyType indiciates the type of caching key to use based on the image provided + // in the request. There are three valid values for the cache key type: Image, Registry, and + // Global. If an invalid value is specified, the response will NOT be used by the kubelet. + CacheKeyType PluginCacheKeyType `json:"cacheKeyType"` + + // cacheDuration indicates the duration the provided credentials should be cached for. + // The kubelet will use this field to set the in-memory cache duration for credentials + // in the AuthConfig. If null, the kubelet will use defaultCacheDuration provided in + // CredentialProviderConfig. If set to 0, the kubelet will not cache the provided AuthConfig. + // +optional + CacheDuration *metav1.Duration `json:"cacheDuration,omitempty"` + + // auth is a map containing authentication information passed into the kubelet. + // Each key is a match image string (more on this below). The corresponding authConfig value + // should be valid for all images that match against this key. A plugin should set + // this field to null if no valid credentials can be returned for the requested image. + // + // Each key in the map is a pattern which can optionally contain a port and a path. + // Globs can be used in the domain, but not in the port or the path. Globs are supported + // as subdomains like '*.k8s.io' or 'k8s.*.io', and top-level-domains such as 'k8s.*'. + // Matching partial subdomains like 'app*.k8s.io' is also supported. Each glob can only match + // a single subdomain segment, so *.io does not match *.k8s.io. + // + // The kubelet will match images against the key when all of the below are true: + // - Both contain the same number of domain parts and each part matches. + // - The URL path of an imageMatch must be a prefix of the target image URL path. + // - If the imageMatch contains a port, then the port must match in the image as well. + // + // When multiple keys are returned, the kubelet will traverse all keys in reverse order so that: + // - longer keys come before shorter keys with the same prefix + // - non-wildcard keys come before wildcard keys with the same prefix. + // + // For any given match, the kubelet will attempt an image pull with the provided credentials, + // stopping after the first successfully authenticated pull. + // + // Example keys: + // - 123456789.dkr.ecr.us-east-1.amazonaws.com + // - *.azurecr.io + // - gcr.io + // - *.*.registry.io + // - registry.io:8080/path + // +optional + Auth map[string]AuthConfig `json:"auth,omitempty"` +} + +// AuthConfig contains authentication information for a container registry. +// Only username/password based authentication is supported today, but more authentication +// mechanisms may be added in the future. +type AuthConfig struct { + // username is the username used for authenticating to the container registry + // An empty username is valid. + Username string `json:"username"` + + // password is the password used for authenticating to the container registry + // An empty password is valid. + Password string `json:"password"` +} diff --git a/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/zz_generated.conversion.go b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/zz_generated.conversion.go new file mode 100644 index 0000000000..77692a7497 --- /dev/null +++ b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/zz_generated.conversion.go @@ -0,0 +1,141 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by conversion-gen. DO NOT EDIT. + +package v1 + +import ( + unsafe "unsafe" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + conversion "k8s.io/apimachinery/pkg/conversion" + runtime "k8s.io/apimachinery/pkg/runtime" + credentialprovider "k8s.io/kubelet/pkg/apis/credentialprovider" +) + +func init() { + localSchemeBuilder.Register(RegisterConversions) +} + +// RegisterConversions adds conversion functions to the given scheme. +// Public to allow building arbitrary schemes. +func RegisterConversions(s *runtime.Scheme) error { + if err := s.AddGeneratedConversionFunc((*AuthConfig)(nil), (*credentialprovider.AuthConfig)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v1_AuthConfig_To_credentialprovider_AuthConfig(a.(*AuthConfig), b.(*credentialprovider.AuthConfig), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*credentialprovider.AuthConfig)(nil), (*AuthConfig)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_credentialprovider_AuthConfig_To_v1_AuthConfig(a.(*credentialprovider.AuthConfig), b.(*AuthConfig), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*CredentialProviderRequest)(nil), (*credentialprovider.CredentialProviderRequest)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v1_CredentialProviderRequest_To_credentialprovider_CredentialProviderRequest(a.(*CredentialProviderRequest), b.(*credentialprovider.CredentialProviderRequest), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*credentialprovider.CredentialProviderRequest)(nil), (*CredentialProviderRequest)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_credentialprovider_CredentialProviderRequest_To_v1_CredentialProviderRequest(a.(*credentialprovider.CredentialProviderRequest), b.(*CredentialProviderRequest), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*CredentialProviderResponse)(nil), (*credentialprovider.CredentialProviderResponse)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v1_CredentialProviderResponse_To_credentialprovider_CredentialProviderResponse(a.(*CredentialProviderResponse), b.(*credentialprovider.CredentialProviderResponse), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*credentialprovider.CredentialProviderResponse)(nil), (*CredentialProviderResponse)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_credentialprovider_CredentialProviderResponse_To_v1_CredentialProviderResponse(a.(*credentialprovider.CredentialProviderResponse), b.(*CredentialProviderResponse), scope) + }); err != nil { + return err + } + return nil +} + +func autoConvert_v1_AuthConfig_To_credentialprovider_AuthConfig(in *AuthConfig, out *credentialprovider.AuthConfig, s conversion.Scope) error { + out.Username = in.Username + out.Password = in.Password + return nil +} + +// Convert_v1_AuthConfig_To_credentialprovider_AuthConfig is an autogenerated conversion function. +func Convert_v1_AuthConfig_To_credentialprovider_AuthConfig(in *AuthConfig, out *credentialprovider.AuthConfig, s conversion.Scope) error { + return autoConvert_v1_AuthConfig_To_credentialprovider_AuthConfig(in, out, s) +} + +func autoConvert_credentialprovider_AuthConfig_To_v1_AuthConfig(in *credentialprovider.AuthConfig, out *AuthConfig, s conversion.Scope) error { + out.Username = in.Username + out.Password = in.Password + return nil +} + +// Convert_credentialprovider_AuthConfig_To_v1_AuthConfig is an autogenerated conversion function. +func Convert_credentialprovider_AuthConfig_To_v1_AuthConfig(in *credentialprovider.AuthConfig, out *AuthConfig, s conversion.Scope) error { + return autoConvert_credentialprovider_AuthConfig_To_v1_AuthConfig(in, out, s) +} + +func autoConvert_v1_CredentialProviderRequest_To_credentialprovider_CredentialProviderRequest(in *CredentialProviderRequest, out *credentialprovider.CredentialProviderRequest, s conversion.Scope) error { + out.Image = in.Image + out.ServiceAccountToken = in.ServiceAccountToken + out.ServiceAccountAnnotations = *(*map[string]string)(unsafe.Pointer(&in.ServiceAccountAnnotations)) + return nil +} + +// Convert_v1_CredentialProviderRequest_To_credentialprovider_CredentialProviderRequest is an autogenerated conversion function. +func Convert_v1_CredentialProviderRequest_To_credentialprovider_CredentialProviderRequest(in *CredentialProviderRequest, out *credentialprovider.CredentialProviderRequest, s conversion.Scope) error { + return autoConvert_v1_CredentialProviderRequest_To_credentialprovider_CredentialProviderRequest(in, out, s) +} + +func autoConvert_credentialprovider_CredentialProviderRequest_To_v1_CredentialProviderRequest(in *credentialprovider.CredentialProviderRequest, out *CredentialProviderRequest, s conversion.Scope) error { + out.Image = in.Image + out.ServiceAccountToken = in.ServiceAccountToken + out.ServiceAccountAnnotations = *(*map[string]string)(unsafe.Pointer(&in.ServiceAccountAnnotations)) + return nil +} + +// Convert_credentialprovider_CredentialProviderRequest_To_v1_CredentialProviderRequest is an autogenerated conversion function. +func Convert_credentialprovider_CredentialProviderRequest_To_v1_CredentialProviderRequest(in *credentialprovider.CredentialProviderRequest, out *CredentialProviderRequest, s conversion.Scope) error { + return autoConvert_credentialprovider_CredentialProviderRequest_To_v1_CredentialProviderRequest(in, out, s) +} + +func autoConvert_v1_CredentialProviderResponse_To_credentialprovider_CredentialProviderResponse(in *CredentialProviderResponse, out *credentialprovider.CredentialProviderResponse, s conversion.Scope) error { + out.CacheKeyType = credentialprovider.PluginCacheKeyType(in.CacheKeyType) + out.CacheDuration = (*metav1.Duration)(unsafe.Pointer(in.CacheDuration)) + out.Auth = *(*map[string]credentialprovider.AuthConfig)(unsafe.Pointer(&in.Auth)) + return nil +} + +// Convert_v1_CredentialProviderResponse_To_credentialprovider_CredentialProviderResponse is an autogenerated conversion function. +func Convert_v1_CredentialProviderResponse_To_credentialprovider_CredentialProviderResponse(in *CredentialProviderResponse, out *credentialprovider.CredentialProviderResponse, s conversion.Scope) error { + return autoConvert_v1_CredentialProviderResponse_To_credentialprovider_CredentialProviderResponse(in, out, s) +} + +func autoConvert_credentialprovider_CredentialProviderResponse_To_v1_CredentialProviderResponse(in *credentialprovider.CredentialProviderResponse, out *CredentialProviderResponse, s conversion.Scope) error { + out.CacheKeyType = PluginCacheKeyType(in.CacheKeyType) + out.CacheDuration = (*metav1.Duration)(unsafe.Pointer(in.CacheDuration)) + out.Auth = *(*map[string]AuthConfig)(unsafe.Pointer(&in.Auth)) + return nil +} + +// Convert_credentialprovider_CredentialProviderResponse_To_v1_CredentialProviderResponse is an autogenerated conversion function. +func Convert_credentialprovider_CredentialProviderResponse_To_v1_CredentialProviderResponse(in *credentialprovider.CredentialProviderResponse, out *CredentialProviderResponse, s conversion.Scope) error { + return autoConvert_credentialprovider_CredentialProviderResponse_To_v1_CredentialProviderResponse(in, out, s) +} diff --git a/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/zz_generated.deepcopy.go b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/zz_generated.deepcopy.go new file mode 100644 index 0000000000..348574fe73 --- /dev/null +++ b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/zz_generated.deepcopy.go @@ -0,0 +1,112 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthConfig) DeepCopyInto(out *AuthConfig) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthConfig. +func (in *AuthConfig) DeepCopy() *AuthConfig { + if in == nil { + return nil + } + out := new(AuthConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CredentialProviderRequest) DeepCopyInto(out *CredentialProviderRequest) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.ServiceAccountAnnotations != nil { + in, out := &in.ServiceAccountAnnotations, &out.ServiceAccountAnnotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CredentialProviderRequest. +func (in *CredentialProviderRequest) DeepCopy() *CredentialProviderRequest { + if in == nil { + return nil + } + out := new(CredentialProviderRequest) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CredentialProviderRequest) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CredentialProviderResponse) DeepCopyInto(out *CredentialProviderResponse) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.CacheDuration != nil { + in, out := &in.CacheDuration, &out.CacheDuration + *out = new(metav1.Duration) + **out = **in + } + if in.Auth != nil { + in, out := &in.Auth, &out.Auth + *out = make(map[string]AuthConfig, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CredentialProviderResponse. +func (in *CredentialProviderResponse) DeepCopy() *CredentialProviderResponse { + if in == nil { + return nil + } + out := new(CredentialProviderResponse) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CredentialProviderResponse) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/zz_generated.defaults.go b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/zz_generated.defaults.go new file mode 100644 index 0000000000..dac177e93b --- /dev/null +++ b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/v1/zz_generated.defaults.go @@ -0,0 +1,33 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by defaulter-gen. DO NOT EDIT. + +package v1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + return nil +} diff --git a/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/zz_generated.deepcopy.go b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/zz_generated.deepcopy.go new file mode 100644 index 0000000000..a5a2432534 --- /dev/null +++ b/vendor/k8s.io/kubelet/pkg/apis/credentialprovider/zz_generated.deepcopy.go @@ -0,0 +1,112 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package credentialprovider + +import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthConfig) DeepCopyInto(out *AuthConfig) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthConfig. +func (in *AuthConfig) DeepCopy() *AuthConfig { + if in == nil { + return nil + } + out := new(AuthConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CredentialProviderRequest) DeepCopyInto(out *CredentialProviderRequest) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.ServiceAccountAnnotations != nil { + in, out := &in.ServiceAccountAnnotations, &out.ServiceAccountAnnotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CredentialProviderRequest. +func (in *CredentialProviderRequest) DeepCopy() *CredentialProviderRequest { + if in == nil { + return nil + } + out := new(CredentialProviderRequest) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CredentialProviderRequest) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CredentialProviderResponse) DeepCopyInto(out *CredentialProviderResponse) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.CacheDuration != nil { + in, out := &in.CacheDuration, &out.CacheDuration + *out = new(v1.Duration) + **out = **in + } + if in.Auth != nil { + in, out := &in.Auth, &out.Auth + *out = make(map[string]AuthConfig, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CredentialProviderResponse. +func (in *CredentialProviderResponse) DeepCopy() *CredentialProviderResponse { + if in == nil { + return nil + } + out := new(CredentialProviderResponse) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CredentialProviderResponse) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index d3d7ded2c4..b44dafdf7c 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1849,6 +1849,11 @@ k8s.io/kube-openapi/pkg/spec3 k8s.io/kube-openapi/pkg/util k8s.io/kube-openapi/pkg/util/proto k8s.io/kube-openapi/pkg/validation/spec +# k8s.io/kubelet v0.36.1 +## explicit; go 1.26.0 +k8s.io/kubelet/config/v1 +k8s.io/kubelet/pkg/apis/credentialprovider +k8s.io/kubelet/pkg/apis/credentialprovider/v1 # k8s.io/metrics v0.36.1 ## explicit; go 1.26.0 k8s.io/metrics/pkg/apis/metrics