Skip to content
Merged
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
4 changes: 4 additions & 0 deletions args.go
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,10 @@ const (
// ArgAgentStatus filters sessions by lifecycle status.
ArgAgentStatus = "status"

// ArgAgentRevokePrevious retires the old webhook secret on the rotate-secret
// call itself instead of granting the default grace window.
ArgAgentRevokePrevious = "revoke-previous"

// ArgAgentWorkspacePath is the path inside the session workspace root (/workspace).
ArgAgentWorkspacePath = "workspace-path"

Expand Down
32 changes: 28 additions & 4 deletions commands/agent_triggers.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,11 @@ func AgentTriggers() *Command {
Writer, agentPrettyErrors(), aliasOpt("activate", "enable"),
displayerType(&displayers.HostedAgentTrigger{}))

CmdBuilder(cmd, RunAgentTriggersRotateSecret, "rotate-secret <trigger-id>",
cmdRotate := CmdBuilder(cmd, RunAgentTriggersRotateSecret, "rotate-secret <trigger-id>",
"Rotate a webhook trigger's secret",
agentsTriggersRotateSecretHelpMD,
Writer, agentPrettyErrors())
AddBoolFlag(cmdRotate, doctl.ArgAgentRevokePrevious, "", false, "Retire the old secret immediately instead of granting the grace window. Deliveries still signed with it start failing at once; use this when the old secret is compromised.")

cmdListExec := CmdBuilder(cmd, RunAgentTriggersListExecutions, "list-executions <trigger-id>",
"List a trigger's execution history",
Expand Down Expand Up @@ -306,15 +307,38 @@ func RunAgentTriggersRotateSecret(c *CmdConfig) error {
if err := ensureOneArg(c); err != nil {
return err
}
secret, err := c.HostedAgentTriggers().RotateSecret(c.Args[0])
revokePrevious, err := c.Doit.GetBool(c.NS, doctl.ArgAgentRevokePrevious)
if err != nil {
return err
}
res, err := c.HostedAgentTriggers().RotateSecret(c.Args[0], revokePrevious)
if err != nil {
return err
}
if Output == "json" {
return json.NewEncoder(c.Out).Encode(map[string]string{"webhook_secret": secret})
out := map[string]any{"webhook_secret": res.Secret}
if res.PreviousExpiresAt != "" {
out["previous_secret_expires_at"] = res.PreviousExpiresAt
}
if res.PreviousRevoked {
out["previous_secret_revoked"] = true
}
return json.NewEncoder(c.Out).Encode(out)
}
stylingEnabled = detectStyling()
printWebhookSecretCard(c.Out, secret, "")
printWebhookSecretCard(c.Out, res.Secret, "")
// State the old secret's fate, and only what the API actually reported. The
// server sets exactly one of these; if neither arrives, say so rather than
// picking one, because guessing "revoked" on a live secret is the reading an
// operator would act on and the one that gets someone hurt.
switch {
case res.PreviousRevoked:
fmt.Fprintln(c.Out, "Old secret revoked: deliveries still signed with it will fail.")
case res.PreviousExpiresAt != "":
fmt.Fprintf(c.Out, "Old secret stops working at: %s\n", res.PreviousExpiresAt)
default:
fmt.Fprintln(c.Out, "Old secret status not reported by the API; assume it is still valid until you can confirm.")
}
return nil
}

Expand Down
59 changes: 56 additions & 3 deletions commands/agent_triggers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,13 @@ func TestAgentTriggersGetUpdatePauseDelete(t *testing.T) {
})
}

// rotateExpiry is the previous-secret expiry the API returns on a default
// (grace-window) rotation.
const rotateExpiry = "2026-08-12T12:05:00Z"

func TestAgentTriggersRotateSecretAndExecutions(t *testing.T) {
withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
tm.hostedAgentTriggers.EXPECT().RotateSecret("tr_1").Return("new_sec", nil)
tm.hostedAgentTriggers.EXPECT().RotateSecret("tr_1", false).Return(&do.HostedAgentTriggerRotateSecretResult{Secret: "new_sec", PreviousExpiresAt: rotateExpiry}, nil)
config.Args = []string{"tr_1"}
var buf bytes.Buffer
config.Out = &buf
Expand Down Expand Up @@ -368,7 +372,7 @@ func TestAgentTriggersCreateWebhook_JSONMode(t *testing.T) {
// - the banner is on neither stdout nor stderr
func TestAgentTriggersRotateSecret_JSONMode(t *testing.T) {
withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
tm.hostedAgentTriggers.EXPECT().RotateSecret("tr_1").Return("new_sec", nil)
tm.hostedAgentTriggers.EXPECT().RotateSecret("tr_1", false).Return(&do.HostedAgentTriggerRotateSecretResult{Secret: "new_sec", PreviousExpiresAt: rotateExpiry}, nil)
config.Args = []string{"tr_1"}

var stdout bytes.Buffer
Expand All @@ -386,16 +390,64 @@ func TestAgentTriggersRotateSecret_JSONMode(t *testing.T) {
var parsed map[string]any
require.NoError(t, json.Unmarshal([]byte(raw), &parsed), "stdout must be valid JSON in -o json mode, got: %q", raw)
assert.Equal(t, "new_sec", parsed["webhook_secret"], "JSON must contain webhook_secret field")
assert.Equal(t, rotateExpiry, parsed["previous_secret_expires_at"], "scripts need the expiry to schedule the provider-side update")
assert.NotContains(t, parsed, "previous_secret_revoked", "the old secret is still live during the grace window")
assert.NotContains(t, raw, "store it now", "banner must not appear on stdout in JSON mode")
assert.Empty(t, stderr, "nothing may be written to stderr in -o json mode")
})
}

// --revoke-previous is the breach path: the response reports the old secret as
// already dead rather than giving an expiry to wait out.
func TestAgentTriggersRotateSecret_RevokePrevious(t *testing.T) {
withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
tm.hostedAgentTriggers.EXPECT().RotateSecret("tr_1", true).Return(&do.HostedAgentTriggerRotateSecretResult{Secret: "new_sec", PreviousRevoked: true}, nil)
config.Args = []string{"tr_1"}
config.Doit.Set(config.NS, doctl.ArgAgentRevokePrevious, true)

var stdout bytes.Buffer
config.Out = &stdout

prev := Output
Output = "json"
defer func() { Output = prev }()

require.NoError(t, RunAgentTriggersRotateSecret(config))

var parsed map[string]any
require.NoError(t, json.Unmarshal(stdout.Bytes(), &parsed))
assert.Equal(t, "new_sec", parsed["webhook_secret"])
assert.Equal(t, true, parsed["previous_secret_revoked"])
assert.NotContains(t, parsed, "previous_secret_expires_at", "there is no window left to report")
})
}

// The API sets exactly one of the two fields, so neither arriving means
// something went wrong upstream. Reporting "revoked" on that would tell an
// operator a possibly-live secret is dead, which is the one error here anybody
// acts on.
func TestAgentTriggersRotateSecret_NeitherOutcomeReported(t *testing.T) {
withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
tm.hostedAgentTriggers.EXPECT().RotateSecret("tr_1", false).
Return(&do.HostedAgentTriggerRotateSecretResult{Secret: "new_sec"}, nil)
config.Args = []string{"tr_1"}

var stdout bytes.Buffer
config.Out = &stdout

require.NoError(t, RunAgentTriggersRotateSecret(config))

out := stdout.String()
assert.Contains(t, out, "not reported")
assert.NotContains(t, out, "Old secret revoked", "an unreported outcome must never read as a dead secret")
})
}

// TestAgentTriggersRotateSecret_TextMode verifies that in text mode the
// secret banner still appears on stdout (existing behaviour preserved).
func TestAgentTriggersRotateSecret_TextMode(t *testing.T) {
withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
tm.hostedAgentTriggers.EXPECT().RotateSecret("tr_1").Return("new_sec", nil)
tm.hostedAgentTriggers.EXPECT().RotateSecret("tr_1", false).Return(&do.HostedAgentTriggerRotateSecretResult{Secret: "new_sec", PreviousExpiresAt: rotateExpiry}, nil)
config.Args = []string{"tr_1"}

var stdout bytes.Buffer
Expand All @@ -407,6 +459,7 @@ func TestAgentTriggersRotateSecret_TextMode(t *testing.T) {

require.NoError(t, RunAgentTriggersRotateSecret(config))
assert.Contains(t, stdout.String(), "new_sec", "secret must appear on stdout in text mode")
assert.Contains(t, stdout.String(), rotateExpiry, "an operator needs the exact instant the old secret dies")
})
}

Expand Down
2 changes: 1 addition & 1 deletion commands/agents_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ const agentsTriggersPauseHelpMD = `Pause a trigger. New events or cron ticks are

const agentsTriggersResumeHelpMD = `Resume a paused trigger.`

const agentsTriggersRotateSecretHelpMD = `Issue a new webhook secret (shown once). Webhook triggers only.`
const agentsTriggersRotateSecretHelpMD = `Issue a new webhook secret (shown once). Webhook triggers only. The old secret keeps verifying deliveries for a short grace window, so deliveries already in flight still succeed while you paste the new secret into your external system. Pass ` + "`--revoke-previous`" + ` to retire the old secret immediately instead — deliveries still signed with it start failing at once, so use it when the old secret is compromised.`

const agentsTriggersListExecutionsHelpMD = `List firings for a trigger. Use ` + "`get-execution`" + ` for full payload and output.`

Expand Down
42 changes: 37 additions & 5 deletions do/agent_triggers.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ package do

import (
"context"
"time"

"github.com/digitalocean/godo"
)
Expand Down Expand Up @@ -46,6 +47,17 @@ type HostedAgentReusableSession struct {
*godo.HostedAgentReusableSession
}

// HostedAgentTriggerRotateSecretResult is the outcome of a secret rotation.
// PreviousExpiresAt and PreviousRevoked are read straight from the API rather
// than derived from each other: the server sets exactly one, and inferring the
// other from a missing field would turn any unrelated omission into a false
// "the old secret is dead".
type HostedAgentTriggerRotateSecretResult struct {
Secret string
PreviousExpiresAt string
PreviousRevoked bool
}

// HostedAgentTriggersService is the doctl-facing wrapper around
// godo.HostedAgentTriggersService.
type HostedAgentTriggersService interface {
Expand All @@ -54,7 +66,7 @@ type HostedAgentTriggersService interface {
Get(triggerID string) (*HostedAgentTrigger, error)
Update(triggerID string, update *godo.HostedAgentTriggerUpdateRequest) (*HostedAgentTrigger, error)
Delete(triggerID string) error
RotateSecret(triggerID string) (string, error)
RotateSecret(triggerID string, revokePrevious bool) (*HostedAgentTriggerRotateSecretResult, error)
ListExecutions(triggerID string, opt *godo.HostedAgentTriggerExecutionListOptions) ([]HostedAgentTriggerExecution, string, error)
GetExecution(triggerID, executionID string) (*HostedAgentTriggerExecution, error)
GetBySession(sessionID string) (*HostedAgentTrigger, error)
Expand Down Expand Up @@ -119,12 +131,32 @@ func (s *hostedAgentTriggersService) Delete(triggerID string) error {
return err
}

func (s *hostedAgentTriggersService) RotateSecret(triggerID string) (string, error) {
resp, _, err := s.svc.RotateSecret(context.TODO(), triggerID)
// RotateSecret issues a new webhook secret and reports what became of the old
// one.
func (s *hostedAgentTriggersService) RotateSecret(triggerID string, revokePrevious bool) (*HostedAgentTriggerRotateSecretResult, error) {
// Left nil for the default rotation so no revoke_previous parameter goes on
// the wire at all, rather than an explicit =false.
var opt *godo.HostedAgentTriggerRotateSecretOptions
if revokePrevious {
opt = &godo.HostedAgentTriggerRotateSecretOptions{RevokePrevious: true}
}
resp, _, err := s.svc.RotateSecret(context.TODO(), triggerID, opt)
if err != nil {
return "", err
return nil, err
}
return resp.WebhookSecret, nil
out := &HostedAgentTriggerRotateSecretResult{
Secret: resp.WebhookSecret,
// Read, never inferred from a missing expiry. The API guarantees exactly
// one of the two fields, so an absent expiry means "revoked" only if the
// response really said so — any other cause of a missing field (an older
// server, a proxy dropping it) would otherwise be reported to the
// operator as a dead secret while it is still live.
PreviousRevoked: resp.PreviousSecretRevoked,
}
if resp.PreviousSecretExpiresAt != nil {
out.PreviousExpiresAt = resp.PreviousSecretExpiresAt.UTC().Format(time.RFC3339)
}
return out, nil
}

func (s *hostedAgentTriggersService) ListExecutions(triggerID string, opt *godo.HostedAgentTriggerExecutionListOptions) ([]HostedAgentTriggerExecution, string, error) {
Expand Down
10 changes: 5 additions & 5 deletions do/mocks/HostedAgentTriggersService.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 26 additions & 2 deletions vendor/github.com/digitalocean/godo/hosted_agent_triggers.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.