Skip to content

Commit 5346df5

Browse files
committed
[ACTP] recover from a corrupt persisted identity
A truncated or malformed identity file could keep ensure-enrollment failing until it was deleted by hand, since persistIdentityToFile writes in place and a crash mid-write leaves partial JSON behind. Content-level damage is now marked with ErrIdentityCorrupt and treated as unusable: ensure-enrollment warns, discards it, and continues to the configured identity or self-enrollment, removing the file when a configured identity takes over so par-control cannot pick it up again. I/O and K8s secret failures still abort, because they may be transient and re-enrolling would register a second runner for the same host.
1 parent a869db0 commit 5346df5

3 files changed

Lines changed: 79 additions & 16 deletions

File tree

cmd/privateactionrunner/subcommands/ensureenrollment/command.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,25 @@ func ensureEnrollment(ctx context.Context, logger log.Component, cfg config.Comp
6666
return fmt.Errorf("failed to get agent identifier: %w", err)
6767
}
6868

69+
discardPersisted := false
70+
6971
persisted, err := enrollment.GetIdentityFromPreviousEnrollment(ctx, cfg)
70-
if err != nil {
72+
switch {
73+
case err != nil && !errors.Is(err, enrollment.ErrIdentityCorrupt):
74+
// May be transient; re-enrolling would register a second runner.
7175
return fmt.Errorf("failed to load persisted identity: %w", err)
72-
}
73-
if persisted != nil {
76+
case err != nil:
77+
logger.Warnf("Discarding unusable persisted identity: %v", err)
78+
discardPersisted = true
79+
case persisted != nil:
7480
if err := validateIdentity(persisted.URN, persisted.PrivateKey); err != nil {
75-
return fmt.Errorf("persisted identity is invalid: %w", err)
76-
}
77-
if !enrollment.ShouldReenroll(agentIdentifier, persisted) {
81+
logger.Warnf("Discarding invalid persisted identity: %v", err)
82+
discardPersisted = true
83+
} else if !enrollment.ShouldReenroll(agentIdentifier, persisted) {
7884
logger.Info("Persisted identity is valid; enrollment is not required")
7985
return nil
86+
} else {
87+
discardPersisted = true
8088
}
8189
}
8290

@@ -86,10 +94,9 @@ func ensureEnrollment(ctx context.Context, logger log.Component, cfg config.Comp
8694
return err
8795
}
8896
if configuredURN != "" && configuredPrivateKey != "" {
89-
// Rust gives a persisted file precedence over inline configuration and does
90-
// not repeat the hostname comparison, so remove the stale file before
91-
// allowing it to fall back to this configured identity.
92-
if persisted != nil {
97+
// Rust prefers a persisted file over inline configuration and repeats none of
98+
// the checks above, so the unusable file has to go.
99+
if discardPersisted {
93100
if err := enrollment.RemoveIdentityFile(cfg); err != nil {
94101
return err
95102
}

cmd/privateactionrunner/subcommands/ensureenrollment/command_test.go

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,42 @@ func TestEnsureEnrollmentPersistedIdentity(t *testing.T) {
7272
}
7373
}
7474

75+
func TestEnsureEnrollmentCorruptIdentityFallsBack(t *testing.T) {
76+
t.Run("self-enrolls", func(t *testing.T) {
77+
identityPath := filepath.Join(t.TempDir(), "identity.json")
78+
require.NoError(t, os.WriteFile(identityPath, []byte("not-json"), 0o600))
79+
cfg := testConfig(t, identityPath, map[string]interface{}{
80+
"private_action_runner.self_enroll": true,
81+
})
82+
hostnameComp, _ := hostnamemock.NewMock("test-host")
83+
enrollCalls := 0
84+
85+
err := ensureEnrollment(context.Background(), logmock.New(t), cfg, hostnameComp, func(_ context.Context, _ log.Component, _ coreconfig.Component, _ *enrollment.AgentIdentifier) (*enrollment.Result, error) {
86+
enrollCalls++
87+
return &enrollment.Result{URN: validURN()}, nil
88+
})
89+
90+
require.NoError(t, err)
91+
assert.Equal(t, 1, enrollCalls)
92+
})
93+
94+
t.Run("configured identity wins and the file is removed", func(t *testing.T) {
95+
identityPath := filepath.Join(t.TempDir(), "identity.json")
96+
require.NoError(t, os.WriteFile(identityPath, []byte("not-json"), 0o600))
97+
cfg := testConfig(t, identityPath, map[string]interface{}{
98+
"private_action_runner.self_enroll": false,
99+
"private_action_runner.urn": validURN(),
100+
"private_action_runner.private_key": validPrivateKey(t),
101+
})
102+
hostnameComp, _ := hostnamemock.NewMock("test-host")
103+
104+
err := ensureEnrollment(context.Background(), logmock.New(t), cfg, hostnameComp, failIfEnrolled(t))
105+
106+
require.NoError(t, err)
107+
assert.NoFileExists(t, identityPath)
108+
})
109+
}
110+
75111
func TestEnsureEnrollmentMissingIdentityEnrolls(t *testing.T) {
76112
identityPath := filepath.Join(t.TempDir(), "missing.json")
77113
cfg := testConfig(t, identityPath, map[string]interface{}{
@@ -152,17 +188,33 @@ func TestEnsureEnrollmentFailures(t *testing.T) {
152188
assert.Contains(t, err.Error(), "private_action_runner.self_enroll is false")
153189
})
154190

155-
t.Run("malformed persisted identity", func(t *testing.T) {
191+
t.Run("corrupt persisted identity without any fallback", func(t *testing.T) {
156192
identityPath := filepath.Join(t.TempDir(), "identity.json")
157193
require.NoError(t, os.WriteFile(identityPath, []byte("not-json"), 0o600))
158-
cfg := testConfig(t, identityPath, nil)
194+
cfg := testConfig(t, identityPath, map[string]interface{}{
195+
"private_action_runner.self_enroll": false,
196+
})
197+
hostnameComp, _ := hostnamemock.NewMock("test-host")
198+
199+
err := ensureEnrollment(context.Background(), logmock.New(t), cfg, hostnameComp, failIfEnrolled(t))
200+
201+
require.Error(t, err)
202+
assert.Contains(t, err.Error(), "private_action_runner.self_enroll is false")
203+
})
204+
205+
t.Run("unreadable persisted identity is not discarded", func(t *testing.T) {
206+
// A directory stands in for any I/O failure.
207+
identityPath := filepath.Join(t.TempDir(), "identity.json")
208+
require.NoError(t, os.Mkdir(identityPath, 0o700))
209+
cfg := testConfig(t, identityPath, map[string]interface{}{
210+
"private_action_runner.self_enroll": true,
211+
})
159212
hostnameComp, _ := hostnamemock.NewMock("test-host")
160213

161214
err := ensureEnrollment(context.Background(), logmock.New(t), cfg, hostnameComp, failIfEnrolled(t))
162215

163216
require.Error(t, err)
164217
assert.Contains(t, err.Error(), "failed to load persisted identity")
165-
assert.Contains(t, err.Error(), "failed to parse identity file JSON")
166218
})
167219

168220
t.Run("invalid configured private key", func(t *testing.T) {

pkg/privateactionrunner/enrollment/storage.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import (
2121
"github.com/DataDog/datadog-agent/pkg/util/flavor"
2222
)
2323

24+
// ErrIdentityCorrupt marks unusable persisted identity content, such as
25+
// truncated JSON. Not returned for I/O failures, which may be transient.
26+
var ErrIdentityCorrupt = errors.New("persisted identity is corrupt")
27+
2428
// GetIdentityFromPreviousEnrollment retrieves PAR identity from either K8s secret or file based on configuration
2529
func GetIdentityFromPreviousEnrollment(ctx context.Context, cfg configModel.Reader) (*PersistedIdentity, error) {
2630
if cfg.GetBool(setup.PARIdentityUseK8sSecret) && flavor.GetFlavor() == flavor.ClusterAgent {
@@ -59,14 +63,14 @@ func getIdentityFromFile(cfg configModel.Reader) (*PersistedIdentity, error) {
5963

6064
var identityContent PersistedIdentity
6165
if err := json.Unmarshal(data, &identityContent); err != nil {
62-
return nil, fmt.Errorf("failed to parse identity file JSON: %w", err)
66+
return nil, fmt.Errorf("%w: failed to parse identity file JSON: %w", ErrIdentityCorrupt, err)
6367
}
6468

6569
if identityContent.URN == "" {
66-
return nil, errors.New("URN is empty in identity file")
70+
return nil, fmt.Errorf("%w: URN is empty in identity file", ErrIdentityCorrupt)
6771
}
6872
if identityContent.PrivateKey == "" {
69-
return nil, errors.New("private key is empty in identity file")
73+
return nil, fmt.Errorf("%w: private key is empty in identity file", ErrIdentityCorrupt)
7074
}
7175

7276
return &identityContent, nil

0 commit comments

Comments
 (0)