Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions cmd/cluster-agent/subcommands/rotateparidentity/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2025-present Datadog, Inc.

//go:build !windows && kubeapiserver

// Package rotateparidentity implements 'cluster-agent rotate-par-identity'.
package rotateparidentity

import (
"context"
"errors"
"fmt"

"github.com/spf13/cobra"
"go.uber.org/fx"

"github.com/DataDog/datadog-agent/cmd/cluster-agent/command"
"github.com/DataDog/datadog-agent/comp/core"
"github.com/DataDog/datadog-agent/comp/core/config"
"github.com/DataDog/datadog-agent/comp/core/hostname"
"github.com/DataDog/datadog-agent/comp/core/hostname/hostnameimpl"
log "github.com/DataDog/datadog-agent/comp/core/log/def"
pkgconfigsetup "github.com/DataDog/datadog-agent/pkg/config/setup"
parconfig "github.com/DataDog/datadog-agent/pkg/privateactionrunner/adapters/config"
"github.com/DataDog/datadog-agent/pkg/privateactionrunner/autoconnections"
"github.com/DataDog/datadog-agent/pkg/privateactionrunner/enrollment"
parutil "github.com/DataDog/datadog-agent/pkg/privateactionrunner/util"
"github.com/DataDog/datadog-agent/pkg/util/fxutil"
"github.com/DataDog/datadog-agent/pkg/util/kubernetes/apiserver"
"github.com/DataDog/datadog-agent/pkg/util/kubernetes/apiserver/common"
)

// Commands returns a slice of subcommands for the 'cluster-agent' command.
func Commands(globalParams *command.GlobalParams) []*cobra.Command {
cmd := &cobra.Command{
Use: "rotate-par-identity",
Short: "Rotate the Private Action Runner identity for this cluster",
Long: `Generates fresh credentials and registers a new Private Action Runner identity.
The new identity is written to the shared Kubernetes secret. Running cluster agent
replicas will detect the change and reload their PAR connection automatically.`,
RunE: func(_ *cobra.Command, _ []string) error {
return fxutil.OneShot(run,
fx.Supply(core.BundleParams{
ConfigParams: config.NewClusterAgentParams(globalParams.ConfFilePath, config.WithExtraConfFiles(globalParams.ExtraConfFilePath)),
LogParams: log.ForOneShot(command.LoggerName, command.DefaultLogLevel, true),
}),
core.Bundle(core.WithSecrets()),
hostnameimpl.Module(),
)
},
}
return []*cobra.Command{cmd}
}

func run(_ log.Component, cfg config.Component, hostnameComp hostname.Component) error {
ctx := context.Background()

if !cfg.GetBool(pkgconfigsetup.PAREnabled) {
return errors.New("private_action_runner.enabled is false - set it to true before rotating the identity")
}
Comment on lines +60 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject cluster rotations without secret storage

When private_action_runner.enabled is true but private_action_runner.identity_use_k8s_secret is false, this command still enrolls and calls enrollment.RotateIdentity, which falls back to writing the identity file instead of the shared Secret. The command then reports that the identity was written to Kubernetes and replicas will reload, but in a multi-replica cluster-agent deployment only the local filesystem is updated and the other replicas keep using the old credentials.

Useful? React with 👍 / 👎.


// Match the running agent's hostname so ShouldReenroll keeps the rotated identity.
hostnameVal, err := hostnameComp.Get(ctx)
if err != nil {
return fmt.Errorf("failed to get hostname: %w", err)
}

// clustername.GetClusterID would call the DCA HTTP client (no cross-node TLS in one-shot).
apiClient, err := apiserver.GetAPIClient()
if err != nil {
return fmt.Errorf("failed to get Kubernetes client: %w", err)
}
orchClusterID, err := common.GetOrCreateClusterID(apiClient.Cl.CoreV1())
if err != nil {
return fmt.Errorf("failed to get cluster ID: %w", err)
}

agentIdentifier := &enrollment.AgentIdentifier{Hostname: hostnameVal, OrchClusterID: orchClusterID}

result, err := enrollment.Enroll(ctx, cfg, agentIdentifier)
if err != nil {
return fmt.Errorf("enrollment failed: %w", err)
}

if err := enrollment.RotateIdentity(ctx, cfg, result); err != nil {
return fmt.Errorf("failed to persist new identity: %w", err)
}

parCfg, err := parconfig.FromDDConfig(cfg)
if err != nil {
fmt.Printf("Identity rotated, but failed to load runner config for auto-connection: %v\n", err)
} else if urnParts, err := parutil.ParseRunnerURN(result.URN); err != nil {
fmt.Printf("Identity rotated, but failed to parse URN for auto-connection: %v\n", err)
} else {
autoconnections.CreateConnectionsIfEnabled(
ctx, cfg, parCfg,
cfg.GetString("api_key"), cfg.GetString("app_key"), urnParts.RunnerID,
result, autoconnections.NewBasicTagsProvider(),
)
}

fmt.Printf("Identity successfully rotated. New URN: %s\n", result.URN)
fmt.Println("The new identity has been written to the Kubernetes secret.")
fmt.Println("Running cluster agent replicas will detect the change and reload automatically.")
return nil
}
41 changes: 41 additions & 0 deletions cmd/cluster-agent/subcommands/rotateparidentity/command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2025-present Datadog, Inc.

//go:build !windows && kubeapiserver && test

package rotateparidentity

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/DataDog/datadog-agent/cmd/cluster-agent/command"
coreconfig "github.com/DataDog/datadog-agent/comp/core/config"
hostnamemock "github.com/DataDog/datadog-agent/comp/core/hostname/hostnameinterface/mock"
logmock "github.com/DataDog/datadog-agent/comp/core/log/mock"
"github.com/DataDog/datadog-agent/pkg/util/fxutil"
)

func TestRotatePARIdentityCommand(t *testing.T) {
fxutil.TestOneShotSubcommand(t,
Commands(&command.GlobalParams{}),
[]string{"rotate-par-identity"},
run,
func() {})
}

func TestRun_DisabledPAR(t *testing.T) {
cfg := coreconfig.NewMockWithOverrides(t, map[string]interface{}{
"private_action_runner.enabled": false,
})
hostnameComp, _ := hostnamemock.NewMock("test-host")

err := run(logmock.New(t), cfg, hostnameComp)

require.Error(t, err)
assert.Contains(t, err.Error(), "private_action_runner.enabled is false")
}
2 changes: 2 additions & 0 deletions cmd/cluster-agent/subcommands/subcommands.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
cmdflare "github.com/DataDog/datadog-agent/cmd/cluster-agent/subcommands/flare"
cmdhealth "github.com/DataDog/datadog-agent/cmd/cluster-agent/subcommands/health"
cmdmetamap "github.com/DataDog/datadog-agent/cmd/cluster-agent/subcommands/metamap"
cmdrotateparidentity "github.com/DataDog/datadog-agent/cmd/cluster-agent/subcommands/rotateparidentity"
cmdsecrethelper "github.com/DataDog/datadog-agent/cmd/cluster-agent/subcommands/secrethelper"
cmdstart "github.com/DataDog/datadog-agent/cmd/cluster-agent/subcommands/start"
cmdstatus "github.com/DataDog/datadog-agent/cmd/cluster-agent/subcommands/status"
Expand Down Expand Up @@ -52,5 +53,6 @@ func ClusterAgentSubcommands() []command.SubcommandFactory {
cmdworkloadlist.Commands,
cmdtaggerlist.Commands,
cmdcoverage.Commands,
cmdrotateparidentity.Commands,
}
}
40 changes: 40 additions & 0 deletions cmd/privateactionrunner/subcommands/rotateidentity/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "rotateidentity",
srcs = ["command.go"],
importpath = "github.com/DataDog/datadog-agent/cmd/privateactionrunner/subcommands/rotateidentity",
visibility = ["//visibility:public"],
deps = [
"//cmd/privateactionrunner/command",
"//comp/core",
"//comp/core/config",
"//comp/core/hostname",
"//comp/core/hostname/hostnameimpl",
"//comp/core/log/def",
"//pkg/config/setup",
"//pkg/privateactionrunner/adapters/config",
"//pkg/privateactionrunner/enrollment",
"//pkg/privateactionrunner/util",
"//pkg/util/fxutil",
"@//pkg/privateactionrunner/autoconnections",
"@com_github_spf13_cobra//:cobra",
"@org_uber_go_fx//:fx",
],
)

go_test(
name = "rotateidentity_test",
srcs = ["command_test.go"],
embed = [":rotateidentity"],
gotags = ["test"],
deps = [
"//cmd/privateactionrunner/command",
"//comp/core/config",
"//comp/core/hostname/hostnameinterface/mock",
"//comp/core/log/mock",
"//pkg/util/fxutil",
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//require",
],
)
91 changes: 91 additions & 0 deletions cmd/privateactionrunner/subcommands/rotateidentity/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2025-present Datadog, Inc.

// Package rotateidentity implements the 'rotate-identity' subcommand for the private-action-runner.
package rotateidentity

import (
"context"
"errors"
"fmt"

"github.com/spf13/cobra"
"go.uber.org/fx"

"github.com/DataDog/datadog-agent/cmd/privateactionrunner/command"
"github.com/DataDog/datadog-agent/comp/core"
"github.com/DataDog/datadog-agent/comp/core/config"
"github.com/DataDog/datadog-agent/comp/core/hostname"
"github.com/DataDog/datadog-agent/comp/core/hostname/hostnameimpl"
log "github.com/DataDog/datadog-agent/comp/core/log/def"
pkgconfigsetup "github.com/DataDog/datadog-agent/pkg/config/setup"
parconfig "github.com/DataDog/datadog-agent/pkg/privateactionrunner/adapters/config"
"github.com/DataDog/datadog-agent/pkg/privateactionrunner/autoconnections"
"github.com/DataDog/datadog-agent/pkg/privateactionrunner/enrollment"
parutil "github.com/DataDog/datadog-agent/pkg/privateactionrunner/util"
"github.com/DataDog/datadog-agent/pkg/util/fxutil"
)

// Commands returns a slice of subcommands for the 'private-action-runner' command.
func Commands(globalParams *command.GlobalParams) []*cobra.Command {
cmd := &cobra.Command{
Use: "rotate-identity",
Short: "Rotate the Private Action Runner identity by performing a new enrollment",
Long: `Generates fresh credentials and registers a new Private Action Runner identity.
The new identity is persisted to the configured storage (file or Kubernetes secret).
Restart the Private Action Runner process to apply the new identity.`,
RunE: func(_ *cobra.Command, _ []string) error {
return fxutil.OneShot(run,
fx.Supply(core.BundleParams{
ConfigParams: config.NewAgentParams(globalParams.ConfFilePath, config.WithExtraConfFiles(globalParams.ExtraConfFilePath)),
LogParams: log.ForOneShot(command.LoggerName, "info", true),
}),
core.Bundle(core.WithSecrets()),
hostnameimpl.Module(),
)
},
}
return []*cobra.Command{cmd}
}

func run(_ log.Component, cfg config.Component, hostnameComp hostname.Component) error {
ctx := context.Background()

if !cfg.GetBool(pkgconfigsetup.PAREnabled) {
return errors.New("private_action_runner.enabled is false - set it to true before rotating the identity")
}

// Match the running agent's hostname so ShouldReenroll keeps the rotated identity.
agentIdentifier, err := enrollment.GetAgentIdentifier(ctx, hostnameComp)
if err != nil {
return fmt.Errorf("failed to get agent identifier: %w", err)
}

result, err := enrollment.Enroll(ctx, cfg, agentIdentifier)
if err != nil {
return fmt.Errorf("enrollment failed: %w", err)
}

if err := enrollment.RotateIdentity(ctx, cfg, result); err != nil {
return fmt.Errorf("failed to persist new identity: %w", err)
}

parCfg, err := parconfig.FromDDConfig(cfg)
if err != nil {
fmt.Printf("Identity rotated, but failed to load runner config for auto-connection: %v\n", err)
} else if urnParts, err := parutil.ParseRunnerURN(result.URN); err != nil {
fmt.Printf("Identity rotated, but failed to parse URN for auto-connection: %v\n", err)
} else {
autoconnections.CreateConnectionsIfEnabled(
ctx, cfg, parCfg,
cfg.GetString("api_key"), cfg.GetString("app_key"), urnParts.RunnerID,
result, autoconnections.NewBasicTagsProvider(),
)
}

fmt.Printf("Identity successfully rotated. New URN: %s\n", result.URN)
fmt.Println("Restart the Private Action Runner to apply the new identity.")
return nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2025-present Datadog, Inc.

//go:build test

package rotateidentity

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/DataDog/datadog-agent/cmd/privateactionrunner/command"
coreconfig "github.com/DataDog/datadog-agent/comp/core/config"
hostnamemock "github.com/DataDog/datadog-agent/comp/core/hostname/hostnameinterface/mock"
logmock "github.com/DataDog/datadog-agent/comp/core/log/mock"
"github.com/DataDog/datadog-agent/pkg/util/fxutil"
)

func TestRotateIdentityCommand(t *testing.T) {
fxutil.TestOneShotSubcommand(t,
Commands(&command.GlobalParams{}),
[]string{"rotate-identity"},
run,
func() {})
}

func TestRun_DisabledPAR(t *testing.T) {
cfg := coreconfig.NewMockWithOverrides(t, map[string]interface{}{
"private_action_runner.enabled": false,
})
hostnameComp, _ := hostnamemock.NewMock("test-host")

err := run(logmock.New(t), cfg, hostnameComp)

require.Error(t, err)
assert.Contains(t, err.Error(), "private_action_runner.enabled is false")
}
2 changes: 2 additions & 0 deletions cmd/privateactionrunner/subcommands/subcommands.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package subcommands

import (
"github.com/DataDog/datadog-agent/cmd/privateactionrunner/command"
"github.com/DataDog/datadog-agent/cmd/privateactionrunner/subcommands/rotateidentity"
"github.com/DataDog/datadog-agent/cmd/privateactionrunner/subcommands/run"
"github.com/DataDog/datadog-agent/cmd/privateactionrunner/subcommands/version"
)
Expand All @@ -17,5 +18,6 @@ func PrivateActionRunnerSubcommands() []command.SubcommandFactory {
return []command.SubcommandFactory{
run.Commands,
version.Commands,
rotateidentity.Commands,
}
}
Loading
Loading