Skip to content

Commit c5fe2e4

Browse files
committed
feat(clusterprofile-integration): add declarative ClusterProfile -> argocd-agent registration
Adds a standalone, additive bridge controller (clusterprofile-agent-driver) and exec-credential plugin (argocd-agent-creds) that let an operator register an argocd-agent agent as an Argo CD cluster purely by applying a ClusterProfile object, with zero changes to argocd-agent's or clusterprofile-integration-for-argocd's existing source. - internal/clusterprofiledriver + cmd/clusterprofile-agent-driver: watches ClusterProfile objects (spec.clusterManager.name == argocd-agent), mints a non-expiring resource-proxy JWT via the principal's own signing key, writes a co-located companion token Secret, and publishes status.accessProviders for the unmodified CPI controller to consume. - cmd/argocd-agent-creds: client-go exec-credential plugin that fetches the companion Secret's token at credential-resolution time. - docs/clusterprofile-integration: architecture README, Kind reproduction script (setup-kind-poc.sh), manifests, and a recorded demo (demo.sh, walking through the flow end-to-end. - hack/dev-kind-poc: supporting dev scripts used while iterating on the PoC. Verified end-to-end on Kind (hub + spoke) from a clean slate, twice, to confirm reproducibility: ClusterProfile -> token Secret -> Argo CD cluster Secret -> principal cluster mapping -> a real Application syncing Healthy on the spoke. See docs/clusterprofile-integration/README.md for design decisions, known limitations, and the full reproduction walkthrough. Claude Sonnet 5 Signed-off-by: Mike Ng <ming@redhat.com>
1 parent 8cf8718 commit c5fe2e4

19 files changed

Lines changed: 2621 additions & 46 deletions

Dockerfile.clusterprofile-driver

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# syntax=docker/dockerfile:1
2+
#
3+
# Builds the two OPTIONAL binaries that make up the declarative
4+
# ClusterProfile <-> argocd-agent integration PoC:
5+
# - /bin/clusterprofile-agent-driver: standalone controller
6+
# - /bin/argocd-agent-creds: client-go exec credential plugin
7+
#
8+
# See docs/clusterprofile-integration/README.md.
9+
FROM docker.io/library/golang:1.26 AS builder
10+
WORKDIR /src
11+
12+
COPY go.mod go.sum ./
13+
RUN go mod download
14+
15+
COPY . .
16+
RUN CGO_ENABLED=0 go build -o dist/clusterprofile-agent-driver ./cmd/clusterprofile-agent-driver
17+
RUN CGO_ENABLED=0 go build -o dist/argocd-agent-creds ./cmd/argocd-agent-creds
18+
19+
FROM docker.io/library/alpine:3.23
20+
RUN apk upgrade --no-cache
21+
COPY --from=builder /src/dist/clusterprofile-agent-driver /bin/clusterprofile-agent-driver
22+
COPY --from=builder /src/dist/argocd-agent-creds /bin/argocd-agent-creds
23+
USER 999
24+
ENTRYPOINT ["/bin/clusterprofile-agent-driver"]

cmd/argocd-agent-creds/main.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright 2024 The argocd-agent Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// argocd-agent-creds is a Kubernetes client-go exec credential plugin
16+
// (https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins).
17+
//
18+
// It is invoked by Argo CD (argocd-server, argocd-repo-server, etc., running
19+
// on the argocd-agent principal cluster) whenever it builds a REST client
20+
// for a cluster whose Argo CD Secret was generated by
21+
// clusterprofile-integration-for-argocd from a ClusterProfile with an
22+
// "argocd-agent" AccessProvider.
23+
//
24+
// It does not mint or store any secret itself: it only reads the
25+
// non-expiring resource-proxy bearer token that clusterprofile-agent-driver
26+
// already wrote into a companion Secret next to the ClusterProfile, and
27+
// returns it to the caller as an ExecCredential.
28+
//
29+
// The binary must be present in the PATH of whichever Argo CD component
30+
// invokes it. See docs/clusterprofile-integration/README.md for how it is
31+
// injected into the argocd-server Pod via an init container.
32+
package main
33+
34+
import (
35+
"context"
36+
"encoding/json"
37+
"fmt"
38+
"os"
39+
40+
corev1 "k8s.io/api/core/v1"
41+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
42+
"k8s.io/client-go/kubernetes"
43+
clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
44+
"k8s.io/client-go/rest"
45+
46+
"github.com/argoproj-labs/argocd-agent/internal/clusterprofiledriver"
47+
)
48+
49+
const execInfoEnvVar = "KUBERNETES_EXEC_INFO"
50+
51+
func main() {
52+
if err := run(); err != nil {
53+
fmt.Fprintf(os.Stderr, "argocd-agent-creds: %v\n", err)
54+
os.Exit(1)
55+
}
56+
}
57+
58+
func run() error {
59+
raw := os.Getenv(execInfoEnvVar)
60+
if raw == "" {
61+
return fmt.Errorf("%s is not set; this binary must be invoked as a client-go exec credential plugin", execInfoEnvVar)
62+
}
63+
64+
var execCred clientauthv1beta1.ExecCredential
65+
if err := json.Unmarshal([]byte(raw), &execCred); err != nil {
66+
return fmt.Errorf("failed to parse %s: %w", execInfoEnvVar, err)
67+
}
68+
69+
// Primary channel: env vars advertised by clusterprofile-agent-driver via
70+
// the KEP-5339 additional-envs cluster extension (see
71+
// clusterprofiledriver.additionalEnvVarsExtensionKey). These are applied
72+
// to this process's environment unconditionally by client-go's exec
73+
// authenticator, regardless of whether Cluster/ProvideClusterInfo made it
74+
// through for this particular request - which empirically (Argo CD
75+
// v3.4.4) it does not for at least the API-discovery-client path used by
76+
// GetResource/live-manifest/pod-logs.
77+
agentName := os.Getenv(clusterprofiledriver.EnvAgentName)
78+
tokenSecretName := os.Getenv(clusterprofiledriver.EnvTokenSecretName)
79+
tokenSecretNamespace := os.Getenv(clusterprofiledriver.EnvTokenSecretNamespace)
80+
key := os.Getenv(clusterprofiledriver.EnvTokenSecretKey)
81+
mtlsSecretName := os.Getenv(clusterprofiledriver.EnvMTLSSecretName)
82+
mtlsSecretNamespace := os.Getenv(clusterprofiledriver.EnvMTLSSecretNamespace)
83+
84+
// Fallback channel: ExecCredential.Spec.Cluster.Config, populated from
85+
// the "client.authentication.k8s.io/exec" cluster extension when Argo CD
86+
// does pass Cluster through (e.g. other call sites/future Argo CD
87+
// versions may behave differently).
88+
if tokenSecretName == "" || tokenSecretNamespace == "" {
89+
if execCred.Spec.Cluster == nil || len(execCred.Spec.Cluster.Config.Raw) == 0 {
90+
if os.Getenv("ARGOCD_AGENT_CREDS_DEBUG") != "" {
91+
fmt.Fprintf(os.Stderr, "argocd-agent-creds: DEBUG raw KUBERNETES_EXEC_INFO=%s\n", raw)
92+
}
93+
return fmt.Errorf("no token secret reference available: neither %s/%s env vars nor a cluster config extension were provided", clusterprofiledriver.EnvTokenSecretName, clusterprofiledriver.EnvTokenSecretNamespace)
94+
}
95+
var ext clusterprofiledriver.ExecExtension
96+
if err := json.Unmarshal(execCred.Spec.Cluster.Config.Raw, &ext); err != nil {
97+
return fmt.Errorf("failed to parse cluster config extension: %w", err)
98+
}
99+
agentName = ext.AgentName
100+
tokenSecretName = ext.TokenSecretName
101+
tokenSecretNamespace = ext.TokenSecretNamespace
102+
key = ext.TokenSecretKey
103+
}
104+
if key == "" {
105+
key = clusterprofiledriver.TokenSecretKey
106+
}
107+
// Fall back to the well-known shared mTLS secret name/namespace if the
108+
// driver didn't advertise one (e.g. an older driver version, or the
109+
// Cluster.Config-only fallback path above).
110+
if mtlsSecretName == "" {
111+
mtlsSecretName = clusterprofiledriver.SharedClientCertSecretName
112+
}
113+
if mtlsSecretNamespace == "" {
114+
mtlsSecretNamespace = tokenSecretNamespace
115+
}
116+
117+
cfg, err := rest.InClusterConfig()
118+
if err != nil {
119+
return fmt.Errorf("failed to load in-cluster config: %w", err)
120+
}
121+
clientset, err := kubernetes.NewForConfig(cfg)
122+
if err != nil {
123+
return fmt.Errorf("failed to build kubernetes client: %w", err)
124+
}
125+
ctx := context.Background()
126+
127+
token, err := fetchSecretKey(ctx, clientset, tokenSecretNamespace, tokenSecretName, key)
128+
if err != nil {
129+
return fmt.Errorf("failed to fetch resource-proxy token for agent %q: %w", agentName, err)
130+
}
131+
132+
// The resource proxy's TLS listener is hardcoded to
133+
// tls.RequireAndVerifyClientCert (argocd-agent's existing, unmodified
134+
// cmd/argocd-agent/principal.go). A bearer token alone cannot satisfy
135+
// the TLS handshake, so we also present a CA-signed client certificate;
136+
// see the comment on clusterprofiledriver.SharedClientCertSecretName for
137+
// why one shared cert works for every agent.
138+
certPEM, err := fetchSecretKey(ctx, clientset, mtlsSecretNamespace, mtlsSecretName, corev1.TLSCertKey)
139+
if err != nil {
140+
return fmt.Errorf("failed to fetch shared mTLS client certificate %s/%s: %w", mtlsSecretNamespace, mtlsSecretName, err)
141+
}
142+
keyPEM, err := fetchSecretKey(ctx, clientset, mtlsSecretNamespace, mtlsSecretName, corev1.TLSPrivateKeyKey)
143+
if err != nil {
144+
return fmt.Errorf("failed to fetch shared mTLS client key %s/%s: %w", mtlsSecretNamespace, mtlsSecretName, err)
145+
}
146+
147+
resp := clientauthv1beta1.ExecCredential{
148+
TypeMeta: metav1.TypeMeta{
149+
APIVersion: clientauthv1beta1.SchemeGroupVersion.String(),
150+
Kind: "ExecCredential",
151+
},
152+
Status: &clientauthv1beta1.ExecCredentialStatus{
153+
// Resource-proxy tokens minted by clusterprofile-agent-driver do
154+
// not expire, so no ExpirationTimestamp is set.
155+
Token: token,
156+
ClientCertificateData: certPEM,
157+
ClientKeyData: keyPEM,
158+
},
159+
}
160+
161+
out, err := json.Marshal(resp)
162+
if err != nil {
163+
return fmt.Errorf("failed to marshal ExecCredential response: %w", err)
164+
}
165+
fmt.Println(string(out))
166+
return nil
167+
}
168+
169+
// fetchSecretKey reads a single data key from a Secret using this process's
170+
// in-cluster ServiceAccount. The calling Argo CD component's ServiceAccount
171+
// must have RBAC "get" permission on Secrets in the given namespace
172+
// (typically the argocd namespace itself, if ClusterProfiles are co-located
173+
// with the Argo CD control plane).
174+
func fetchSecretKey(ctx context.Context, clientset *kubernetes.Clientset, namespace, name, key string) (string, error) {
175+
secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{})
176+
if err != nil {
177+
return "", fmt.Errorf("failed to get secret %s/%s: %w", namespace, name, err)
178+
}
179+
180+
data, ok := secret.Data[key]
181+
if !ok || len(data) == 0 {
182+
return "", fmt.Errorf("secret %s/%s has no data at key %q", namespace, name, key)
183+
}
184+
return string(data), nil
185+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright 2024 The argocd-agent Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// clusterprofile-agent-driver is an OPTIONAL, standalone controller that
16+
// bridges the ClusterProfile API (sigs.k8s.io/cluster-inventory-api) and
17+
// argocd-agent, so that agents can be registered with Argo CD declaratively
18+
// instead of via `argocd-agentctl agent create`.
19+
//
20+
// It watches ClusterProfile objects whose spec.clusterManager.name is
21+
// "argocd-agent" and, for each one, mints a non-expiring resource-proxy JWT
22+
// (using the same signing key as the argocd-agent principal), stores it in
23+
// a companion Secret next to the ClusterProfile, and publishes a
24+
// status.accessProviders entry that clusterprofile-integration-for-argocd
25+
// converts into an Argo CD cluster Secret using the argocd-agent-creds exec
26+
// plugin (see cmd/argocd-agent-creds).
27+
//
28+
// See docs/clusterprofile-integration/README.md for the full design.
29+
package main
30+
31+
import (
32+
"flag"
33+
"os"
34+
35+
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
36+
ctrl "sigs.k8s.io/controller-runtime"
37+
"sigs.k8s.io/controller-runtime/pkg/cache"
38+
"sigs.k8s.io/controller-runtime/pkg/healthz"
39+
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
40+
41+
clusterv1alpha1 "sigs.k8s.io/cluster-inventory-api/apis/v1alpha1"
42+
43+
"github.com/argoproj-labs/argocd-agent/internal/clusterprofiledriver"
44+
"github.com/argoproj-labs/argocd-agent/internal/issuer"
45+
46+
"github.com/sirupsen/logrus"
47+
logf "sigs.k8s.io/controller-runtime/pkg/log"
48+
"sigs.k8s.io/controller-runtime/pkg/log/zap"
49+
)
50+
51+
func main() {
52+
var (
53+
metricsAddr string
54+
probeAddr string
55+
resourceProxyAddress string
56+
jwtKeyPath string
57+
caCertPath string
58+
leaderElectionNS string
59+
enableLeaderElection bool
60+
watchNamespace string
61+
)
62+
63+
flag.StringVar(&metricsAddr, "metrics-addr", envOr("DRIVER_METRICS_ADDR", ":8080"), "The address the metric endpoint binds to.")
64+
flag.StringVar(&probeAddr, "probe-addr", envOr("DRIVER_PROBE_ADDR", ":8081"), "The address the health probe endpoint binds to.")
65+
flag.StringVar(&resourceProxyAddress, "resource-proxy-address", envOr("DRIVER_RESOURCE_PROXY_ADDRESS", "argocd-agent-resource-proxy.argocd.svc.cluster.local:9090"), "host:port at which Argo CD can reach the argocd-agent resource proxy.")
66+
flag.StringVar(&jwtKeyPath, "jwt-key-path", envOr("DRIVER_JWT_KEY_PATH", "/app/config/jwt/jwt.key"), "Path to the principal's PEM/PKCS8 JWT signing key (mounted from secret argocd-agent-jwt).")
67+
flag.StringVar(&caCertPath, "resource-proxy-ca-path", envOr("DRIVER_RESOURCE_PROXY_CA_PATH", "/app/config/ca/ca.crt"), "Path to the resource proxy CA certificate (mounted from secret argocd-agent-ca).")
68+
flag.StringVar(&leaderElectionNS, "leader-election-namespace", envOr("DRIVER_LEADER_ELECTION_NAMESPACE", ""), "Namespace to use for the leader election lease. Defaults to the pod's own namespace.")
69+
flag.BoolVar(&enableLeaderElection, "enable-leader-election", envOr("DRIVER_ENABLE_LEADER_ELECTION", "false") == "true", "Enable leader election for the controller manager.")
70+
flag.StringVar(&watchNamespace, "watch-namespace", envOr("DRIVER_WATCH_NAMESPACE", ""), "Namespace to watch for ClusterProfile/Secret objects. Empty means cluster-wide (requires a ClusterRole). By design this driver is meant to be deployed once per namespace it manages (typically the Argo CD control-plane namespace), matching clusterprofile-integration-for-argocd's own namespace-scoped RBAC model, so this should normally be set to that single namespace.")
71+
flag.Parse()
72+
73+
logf.SetLogger(zap.New(zap.UseDevMode(true)))
74+
log := ctrl.Log.WithName("clusterprofile-agent-driver")
75+
76+
log.Info("Starting clusterprofile-agent-driver")
77+
78+
// The issuer name "argocd-agent-server" MUST match the name the
79+
// argocd-agent principal uses (see principal/server.go) so that tokens
80+
// minted here carry the Issuer/Audience claims the principal expects
81+
// when validating resource-proxy bearer tokens.
82+
jwtIssuer, err := issuer.NewIssuer("argocd-agent-server", issuer.WithRSAPrivateKeyFromFile(jwtKeyPath))
83+
if err != nil {
84+
log.Error(err, "unable to construct JWT issuer from signing key", "path", jwtKeyPath)
85+
os.Exit(1)
86+
}
87+
88+
caData, err := os.ReadFile(caCertPath)
89+
if err != nil {
90+
log.Error(err, "unable to read resource proxy CA certificate")
91+
os.Exit(1)
92+
}
93+
94+
scheme := clientgoscheme.Scheme
95+
if err := clusterv1alpha1.AddToScheme(scheme); err != nil {
96+
log.Error(err, "unable to add ClusterProfile scheme")
97+
os.Exit(1)
98+
}
99+
100+
mgrOpts := ctrl.Options{
101+
Scheme: scheme,
102+
Metrics: metricsserver.Options{
103+
BindAddress: metricsAddr,
104+
},
105+
HealthProbeBindAddress: probeAddr,
106+
LeaderElection: enableLeaderElection,
107+
LeaderElectionID: "clusterprofile-agent-driver.argocd-agent.argoproj-labs.io",
108+
LeaderElectionNamespace: leaderElectionNS,
109+
}
110+
if watchNamespace != "" {
111+
log.Info("Restricting watch cache to a single namespace", "namespace", watchNamespace)
112+
mgrOpts.Cache = cache.Options{
113+
DefaultNamespaces: map[string]cache.Config{
114+
watchNamespace: {},
115+
},
116+
}
117+
} else {
118+
log.Info("No --watch-namespace set; watching ClusterProfile/Secret objects cluster-wide (requires a ClusterRole)")
119+
}
120+
121+
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), mgrOpts)
122+
if err != nil {
123+
log.Error(err, "unable to start manager")
124+
os.Exit(1)
125+
}
126+
127+
if err = (&clusterprofiledriver.Reconciler{
128+
Client: mgr.GetClient(),
129+
Scheme: mgr.GetScheme(),
130+
Log: ctrl.Log.WithName("controllers").WithName("ClusterProfile"),
131+
Issuer: jwtIssuer,
132+
ResourceProxyAddress: resourceProxyAddress,
133+
ResourceProxyCAData: caData,
134+
}).SetupWithManager(mgr); err != nil {
135+
log.Error(err, "unable to create controller")
136+
os.Exit(1)
137+
}
138+
139+
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
140+
log.Error(err, "unable to set up health check")
141+
os.Exit(1)
142+
}
143+
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
144+
log.Error(err, "unable to set up ready check")
145+
os.Exit(1)
146+
}
147+
148+
log.Info("Starting manager")
149+
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
150+
log.Error(err, "problem running manager")
151+
os.Exit(1)
152+
}
153+
}
154+
155+
func envOr(key, def string) string {
156+
if v, ok := os.LookupEnv(key); ok && v != "" {
157+
return v
158+
}
159+
return def
160+
}
161+
162+
func init() {
163+
// Route the standard logrus-based logger used elsewhere in argocd-agent
164+
// to a sane default too, in case any imported package logs through it.
165+
logrus.SetLevel(logrus.InfoLevel)
166+
}

0 commit comments

Comments
 (0)