Skip to content

Commit 2c170ba

Browse files
committed
feat: enable self registration of agents
Assisted by: Cursor Signed-off-by: Jayendra Parsai <jparsai@redhat.com>
1 parent c638a2b commit 2c170ba

File tree

14 files changed

+1074
-2
lines changed

14 files changed

+1074
-2
lines changed

cmd/argocd-agent/principal.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ func NewPrincipalRunCommand() *cobra.Command {
7373
autoNamespaceLabels []string
7474
enableWebSocket bool
7575
enableResourceProxy bool
76+
resourceProxyAddress string
7677
pprofPort int
7778
resourceProxySecretName string
7879
resourceProxyCertPath string
@@ -97,6 +98,8 @@ func NewPrincipalRunCommand() *cobra.Command {
9798
// OpenTelemetry configuration
9899
otlpAddress string
99100
otlpInsecure bool
101+
102+
enableSelfClusterRegistration bool
100103
)
101104
command := &cobra.Command{
102105
Use: "principal",
@@ -236,6 +239,7 @@ func NewPrincipalRunCommand() *cobra.Command {
236239
cmdutil.Fatal("Error reading TLS config for resource proxy: %v", err)
237240
}
238241
opts = append(opts, principal.WithResourceProxyTLS(proxyTLS))
242+
opts = append(opts, principal.WithResourceProxyAddress(resourceProxyAddress))
239243
}
240244

241245
if jwtKey != "" {
@@ -324,6 +328,9 @@ func NewPrincipalRunCommand() *cobra.Command {
324328
opts = append(opts, principal.WithRedis(redisAddress, redisPassword, redisCompressionType))
325329
opts = append(opts, principal.WithHealthzPort(healthzPort))
326330

331+
// Self cluster registration options
332+
opts = append(opts, principal.WithClusterRegistration(enableSelfClusterRegistration))
333+
327334
s, err := principal.NewServer(ctx, kubeConfig, namespace, opts...)
328335
if err != nil {
329336
cmdutil.Fatal("Could not create new server instance: %v", err)
@@ -445,6 +452,10 @@ func NewPrincipalRunCommand() *cobra.Command {
445452
command.Flags().BoolVar(&enableResourceProxy, "enable-resource-proxy",
446453
env.BoolWithDefault("ARGOCD_PRINCIPAL_ENABLE_RESOURCE_PROXY", true),
447454
"Whether to enable the resource proxy")
455+
command.Flags().StringVar(&resourceProxyAddress, "resource-proxy-address",
456+
env.StringWithDefault("ARGOCD_PRINCIPAL_RESOURCE_PROXY_ADDRESS", nil, "argocd-agent-resource-proxy:9090"),
457+
"Resource proxy address on principal side")
458+
448459
command.Flags().DurationVar(&keepAliveMinimumInterval, "keepalive-min-interval",
449460
env.DurationWithDefault("ARGOCD_PRINCIPAL_KEEP_ALIVE_MIN_INTERVAL", nil, 0),
450461
"Drop agent connections that send keepalive pings more often than the specified interval") // It should be less than "keep-alive-ping-interval" of agent
@@ -476,6 +487,10 @@ func NewPrincipalRunCommand() *cobra.Command {
476487
command.Flags().StringVar(&kubeConfig, "kubeconfig", "", "Path to a kubeconfig file to use")
477488
command.Flags().StringVar(&kubeContext, "kubecontext", "", "Override the default kube context")
478489

490+
command.Flags().BoolVar(&enableSelfClusterRegistration, "enable-self-cluster-registration",
491+
env.BoolWithDefault("ARGOCD_PRINCIPAL_ENABLE_SELF_CLUSTER_REGISTRATION", false),
492+
"Allow agents with valid client certificates to self-register on connection")
493+
479494
return command
480495
}
481496

hack/dev-env/start-principal.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,17 @@ E2E_ENV_FILE="/tmp/argocd-agent-e2e"
4444
if [ -f "$E2E_ENV_FILE" ]; then
4545
source "$E2E_ENV_FILE"
4646
export ARGOCD_PRINCIPAL_ENABLE_WEBSOCKET=${ARGOCD_PRINCIPAL_ENABLE_WEBSOCKET:-false}
47+
export ARGOCD_PRINCIPAL_ENABLE_SELF_CLUSTER_REGISTRATION=${ARGOCD_PRINCIPAL_ENABLE_SELF_CLUSTER_REGISTRATION:-false}
4748
fi
4849

50+
export ARGOCD_AGENT_RESOURCE_PROXY=$(ip r show default | sed -e 's,.*\ src\ ,,' | sed -e 's,\ metric.*$,,')
51+
4952
SCRIPTPATH="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )"
5053
go run github.com/argoproj-labs/argocd-agent/cmd/argocd-agent principal \
5154
--allowed-namespaces '*' \
5255
--kubecontext vcluster-control-plane \
5356
--log-level ${ARGOCD_AGENT_LOG_LEVEL:-trace} \
5457
--namespace argocd \
5558
--auth "mtls:CN=([^,]+)" \
59+
--resource-proxy-address "${ARGOCD_AGENT_RESOURCE_PROXY}:9090" \
5660
$ARGS

internal/argocd/cluster/cluster.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,30 @@
1515
package cluster
1616

1717
import (
18+
"context"
19+
"crypto/x509"
1820
"errors"
1921
"fmt"
2022
"time"
2123

24+
"github.com/argoproj-labs/argocd-agent/internal/config"
2225
"github.com/argoproj-labs/argocd-agent/internal/event"
26+
"github.com/argoproj-labs/argocd-agent/internal/tlsutil"
2327
"github.com/redis/go-redis/v9"
2428
"github.com/redis/go-redis/v9/maintnotifications"
2529
"github.com/sirupsen/logrus"
2630

2731
appv1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
2832
cacheutil "github.com/argoproj/argo-cd/v3/util/cache"
2933
appstatecache "github.com/argoproj/argo-cd/v3/util/cache/appstate"
34+
v1 "k8s.io/api/core/v1"
35+
apierrors "k8s.io/apimachinery/pkg/api/errors"
3036
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
37+
"k8s.io/client-go/kubernetes"
3138
)
3239

40+
const LabelKeySelfRegisteredCluster = "argocd-agent.argoproj-labs.io/self-registered-cluster"
41+
3342
// SetAgentConnectionStatus updates cluster info with connection state and time in mapped cluster at principal.
3443
// This is called when the agent is connected or disconnected with the principal.
3544
func (m *Manager) SetAgentConnectionStatus(agentName, status appv1.ConnectionStatus, modifiedAt time.Time) {
@@ -181,3 +190,101 @@ func NewClusterCacheInstance(redisAddress, redisPassword string, redisCompressio
181190

182191
return clusterCache, nil
183192
}
193+
194+
// CreateCluster creates a cluster secret for an agent's cluster on the principal.
195+
func CreateCluster(ctx context.Context, kubeclient kubernetes.Interface, namespace, agentName, resourceProxyAddress string) error {
196+
197+
// Generate client certificate signed by principal's CA.
198+
clientCert, clientKey, caData, err := generateAgentClientCert(ctx, kubeclient, namespace, agentName, config.SecretNamePrincipalCA)
199+
if err != nil {
200+
return fmt.Errorf("could not generate client certificate: %w", err)
201+
}
202+
203+
// Note: this structure has to be same as manual creation done by `argocd-agentctl agent create <agent_name>`
204+
cluster := &appv1.Cluster{
205+
Server: fmt.Sprintf("https://%s?agentName=%s", resourceProxyAddress, agentName),
206+
Name: agentName,
207+
Labels: map[string]string{
208+
LabelKeyClusterAgentMapping: agentName,
209+
LabelKeySelfRegisteredCluster: "true",
210+
},
211+
Config: appv1.ClusterConfig{
212+
TLSClientConfig: appv1.TLSClientConfig{
213+
CertData: []byte(clientCert),
214+
KeyData: []byte(clientKey),
215+
CAData: []byte(caData),
216+
},
217+
},
218+
}
219+
220+
// Convert cluster object to Kubernetes secret object
221+
secret := &v1.Secret{
222+
ObjectMeta: metav1.ObjectMeta{
223+
Name: getClusterSecretName(agentName),
224+
Namespace: namespace,
225+
},
226+
}
227+
if err := ClusterToSecret(cluster, secret); err != nil {
228+
return fmt.Errorf("could not convert cluster to secret: %w", err)
229+
}
230+
231+
// Create the secret to register the agent's cluster.
232+
// Handle AlreadyExists as success to make this idempotent and avoid TOCTOU race
233+
// conditions where concurrent registrations both pass the existence check.
234+
if _, err = kubeclient.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{}); err != nil {
235+
if apierrors.IsAlreadyExists(err) {
236+
return nil
237+
}
238+
return fmt.Errorf("could not create cluster secret to register agent's cluster: %w", err)
239+
}
240+
241+
return nil
242+
}
243+
244+
// ClusterSecretExists checks if a cluster secret exists for the given agent.
245+
func ClusterSecretExists(ctx context.Context, kubeclient kubernetes.Interface, namespace, agentName string) (bool, error) {
246+
if _, err := kubeclient.CoreV1().Secrets(namespace).Get(ctx, getClusterSecretName(agentName), metav1.GetOptions{}); err != nil {
247+
if apierrors.IsNotFound(err) {
248+
return false, nil
249+
}
250+
return false, err
251+
}
252+
return true, nil
253+
}
254+
255+
func generateAgentClientCert(ctx context.Context, kubeclient kubernetes.Interface, namespace, agentName, caSecretName string) (clientCert, clientKey, caData string, err error) {
256+
257+
// Read the CA certificate from the principal's CA secret
258+
tlsCert, err := tlsutil.TLSCertFromSecret(ctx, kubeclient, namespace, caSecretName)
259+
if err != nil {
260+
err = fmt.Errorf("could not read CA secret: %w", err)
261+
return
262+
}
263+
264+
// Parse CA certificate from PEM format
265+
signerCert, err := x509.ParseCertificate(tlsCert.Certificate[0])
266+
if err != nil {
267+
err = fmt.Errorf("could not parse CA certificate: %w", err)
268+
return
269+
}
270+
271+
// Generate a client cert with agent name as CN and sign it with the CA's cert and key
272+
clientCert, clientKey, err = tlsutil.GenerateClientCertificate(agentName, signerCert, tlsCert.PrivateKey)
273+
if err != nil {
274+
err = fmt.Errorf("could not create client cert: %w", err)
275+
return
276+
}
277+
278+
// Convert CA certificate to PEM format
279+
caData, err = tlsutil.CertDataToPEM(tlsCert.Certificate[0])
280+
if err != nil {
281+
err = fmt.Errorf("could not convert CA certificate to PEM format: %w", err)
282+
return
283+
}
284+
285+
return
286+
}
287+
288+
func getClusterSecretName(agentName string) string {
289+
return "cluster-" + agentName
290+
}

internal/argocd/cluster/cluster_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@ import (
2121
"time"
2222

2323
"github.com/alicebob/miniredis/v2"
24+
"github.com/argoproj-labs/argocd-agent/internal/config"
2425
"github.com/argoproj-labs/argocd-agent/internal/event"
26+
"github.com/argoproj-labs/argocd-agent/internal/tlsutil"
2527
"github.com/argoproj-labs/argocd-agent/test/fake/kube"
2628
appv1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
2729
cacheutil "github.com/argoproj/argo-cd/v3/util/cache"
2830
"github.com/stretchr/testify/require"
31+
corev1 "k8s.io/api/core/v1"
32+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
33+
"k8s.io/client-go/kubernetes"
2934
)
3035

3136
func setup(t *testing.T, redisAddress string) (string, *Manager) {
@@ -43,6 +48,25 @@ func setup(t *testing.T, redisAddress string) (string, *Manager) {
4348
return agentName, m
4449
}
4550

51+
func createTestCASecret(t *testing.T, kubeclient kubernetes.Interface, namespace string) {
52+
t.Helper()
53+
caCertPEM, caKeyPEM, err := tlsutil.GenerateCaCertificate(config.SecretNamePrincipalCA)
54+
require.NoError(t, err, "generate CA certificate")
55+
56+
_, err = kubeclient.CoreV1().Secrets(namespace).Create(context.Background(), &corev1.Secret{
57+
ObjectMeta: metav1.ObjectMeta{
58+
Name: config.SecretNamePrincipalCA,
59+
Namespace: namespace,
60+
},
61+
Type: corev1.SecretTypeTLS,
62+
Data: map[string][]byte{
63+
"tls.crt": []byte(caCertPEM),
64+
"tls.key": []byte(caKeyPEM),
65+
},
66+
}, metav1.CreateOptions{})
67+
require.NoError(t, err, "create CA secret")
68+
}
69+
4670
func Test_UpdateClusterInfo(t *testing.T) {
4771
miniRedis, err := miniredis.Run()
4872
require.NoError(t, err)
@@ -313,3 +337,38 @@ func Test_RefreshClusterInfo(t *testing.T) {
313337
})
314338
})
315339
}
340+
341+
func Test_CreateCluster(t *testing.T) {
342+
const testNamespace = "argocd"
343+
const testResourceProxyAddr = "resource-proxy:8443"
344+
345+
t.Run("Returns error when CA secret is missing", func(t *testing.T) {
346+
kubeclient := kube.NewFakeClientsetWithResources()
347+
348+
err := CreateCluster(context.Background(), kubeclient, testNamespace, "test-agent", testResourceProxyAddr)
349+
350+
require.Error(t, err)
351+
require.Contains(t, err.Error(), "could not generate client certificate")
352+
})
353+
354+
t.Run("Creates cluster secret successfully with valid CA", func(t *testing.T) {
355+
kubeclient := kube.NewFakeClientsetWithResources()
356+
createTestCASecret(t, kubeclient, testNamespace)
357+
358+
agentName := "test-agent"
359+
err := CreateCluster(context.Background(), kubeclient, testNamespace, agentName, testResourceProxyAddr)
360+
361+
require.NoError(t, err)
362+
363+
secret, err := kubeclient.CoreV1().Secrets(testNamespace).Get(
364+
context.Background(),
365+
getClusterSecretName(agentName),
366+
metav1.GetOptions{},
367+
)
368+
require.NoError(t, err)
369+
require.NotNil(t, secret)
370+
require.Equal(t, getClusterSecretName(agentName), secret.Name)
371+
require.Equal(t, agentName, secret.Labels[LabelKeyClusterAgentMapping])
372+
require.Equal(t, "true", secret.Labels[LabelKeySelfRegisteredCluster])
373+
})
374+
}

principal/apis/auth/auth.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/argoproj-labs/argocd-agent/internal/logging"
2626
"github.com/argoproj-labs/argocd-agent/internal/queue"
2727
"github.com/argoproj-labs/argocd-agent/pkg/api/grpc/authapi"
28+
"github.com/argoproj-labs/argocd-agent/principal/clusterregistration"
2829
"github.com/sirupsen/logrus"
2930
"google.golang.org/grpc/codes"
3031
"google.golang.org/grpc/status"
@@ -36,6 +37,8 @@ type Server struct {
3637
issuer issuer.Issuer
3738
options *ServerOptions
3839
queues *queue.SendRecvQueues
40+
41+
clusterRegistrationManager *clusterregistration.ClusterRegistrationManager
3942
}
4043

4144
const (
@@ -50,7 +53,9 @@ const (
5053

5154
var errAuthenticationFailed = status.Error(codes.Unauthenticated, authFailedMessage)
5255

53-
type ServerOptions struct{}
56+
type ServerOptions struct {
57+
clusterRegistrationManager *clusterregistration.ClusterRegistrationManager
58+
}
5459

5560
type ServerOption func(o *ServerOptions) error
5661

@@ -72,6 +77,8 @@ func NewServer(queues *queue.SendRecvQueues, authMethods *auth.Methods, iss issu
7277
return nil, err
7378
}
7479
}
80+
81+
s.clusterRegistrationManager = s.options.clusterRegistrationManager
7582
return s, nil
7683
}
7784

@@ -117,6 +124,15 @@ func (s *Server) Authenticate(ctx context.Context, ar *authapi.AuthRequest) (*au
117124
return nil, errAuthenticationFailed
118125
}
119126
logCtx.WithField("client", clientID).Info("client authentication successful")
127+
128+
// If self cluster registration is enabled, register the agent's cluster and create cluster secret if it doesn't exist
129+
if s.clusterRegistrationManager != nil && s.clusterRegistrationManager.IsSelfClusterRegistrationEnabled() {
130+
if err := s.clusterRegistrationManager.RegisterCluster(ctx, clientID); err != nil {
131+
logCtx.WithError(err).WithField("client", clientID).Error("Failed to self register agent's cluster")
132+
return nil, errAuthenticationFailed
133+
}
134+
}
135+
120136
subject := &auth.AuthSubject{ClientID: clientID, Mode: ar.Mode}
121137
accessToken, refreshToken, err := s.issueTokens(subject, true)
122138
if err != nil {

0 commit comments

Comments
 (0)