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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,32 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- **`c1i tasks restart`, `reset`, `skip-step`, `process` and
`update-grant-duration`.** The task action family was five commands wrapping
thirteen server routes; these add the five whose behaviour could be
demonstrated. All ten now share one runner — `approve`, `deny`, `comment`,
`close` and `reassign` each hand-rolled the same request-and-confirm
sequence, and their help text and dry-run output are unchanged.

Verified by effect rather than exit code. `restart`, `reset` and `skip-step`
all rotate the task's current policy step; `restart` and `skip-step` add one
history entry, `reset` four, because it restarts the policy rather than the
step. Neither `restart` nor `reset` reopens a closed task — the state stays
`TASK_STATE_CLOSED`. `process` changes nothing observable on a healthy task;
the stalled case was not reproduced. `update-grant-duration` lands as
`grantDuration` on the task, takes a protobuf duration (`3600s`, not `1h`),
and is refused once the task reaches provisioning with `cannot update grant
duration for a ticket in a provision step`.

Which actions a task accepts depends on its state; the rest are refused with
`action not permitted`. Read the task's own list with
`c1i api --path /api/v1/tasks/<task-id> --fields actions`.

Not wrapped: `escalate` could not be demonstrated even with emergency grants
enabled on the entitlement, `update-request-data` takes a free-form object
that wants a body-file flag, and `approve-with-step-up` needs a step-up
transaction id the CLI cannot obtain.

- **`c1i entitlements create`.** Modelling a manually-managed app took three
raw `api` calls -- resource type, resource, then entitlement -- with the ids
hand-carried between them. One command now does it, and reuses objects you
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,33 @@ c1i tasks deny <task-id> [--policy-step-id <id>] [--comment <text>]
c1i tasks comment <task-id> --comment <text>
c1i tasks close <task-id> [--comment <text>]
c1i tasks reassign <task-id> --to-user-id <id> [--to-user-id <id> ...] [--policy-step-id <id>] [--comment <text>]
c1i tasks restart <task-id> [--policy-step-id <id>] [--comment <text>]
c1i tasks reset <task-id> [--comment <text>]
c1i tasks skip-step <task-id> [--policy-step-id <id>] [--comment <text>]
c1i tasks process <task-id>
c1i tasks update-grant-duration <task-id> --duration <duration>
```

`restart`, `reset` and `skip-step` each rotate the task's current policy step,
so a `--policy-step-id` captured before one of them goes stale — the server
answers `this action is no longer available: the request has advanced to a new
approval step`. Omit the flag to act on whatever step is current.

Which actions a task accepts depends on its state; the server refuses the rest
with `action not permitted`. Read the task's own list with
`c1i api --path /api/v1/tasks/<task-id> --fields actions`.

`restart` re-runs the current approval step; `reset` restarts the whole policy.
Neither reopens a closed task. `process` changes nothing observable on a
healthy task — it is intended for one that has stalled, which was not
reproduced here. `update-grant-duration` takes a
protobuf duration (`3600s`, not `1h`) and only applies before the task reaches
provisioning, after which the server answers `cannot update grant duration for
a ticket in a provision step`; the value lands as `grantDuration`.

`escalate`, `update-request-data` and `approve-with-step-up` are not wrapped;
reach them through `c1i api`.

`approve`/`deny`/`reassign` target a specific policy step. If `--policy-step-id`
is omitted, the task's currently executing step is fetched and used
automatically for all three — but `approve` and `reassign` require a resolvable
Expand Down
17 changes: 16 additions & 1 deletion cmd/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,22 @@ resource with `--resource-id` likewise means you drop
close`/`reassign` therefore never print a state (`close` reports `task_id`,
`reassign` also the `policy_step_id`); if you call these actions
through `api`, read the task back rather than trusting the response's
`state`.
`state`. The same holds for `restart`, `reset`, `skip-step`, `process` and
`update-grant-duration`.
- Which actions a task accepts depends on its state, and the server refuses the
rest with `action not permitted`. Read the task's own list first:
`c1i api --path /api/v1/tasks/<task-id> --fields actions`. `restart`, `reset` and
`skip-step` each rotate the current policy step (measured), so a
`--policy-step-id` captured before one of them is stale and answers `this
action is no longer available: the request has advanced to a new approval
step` -- omit the flag to act on whatever step is current.
- `restart` re-runs the current approval step (one new history entry); `reset`
restarts the whole policy (four, measured). Neither reopens a closed task --
the state stays `TASK_STATE_CLOSED`. `process` changes nothing observable on
a healthy task. `update-grant-duration` needs a protobuf duration (`3600s`,
not `1h`) and only works before provisioning, after which the server says
`cannot update grant duration for a ticket in a provision step`; the value
lands as `grantDuration`.
- Entitlement ids are unique only within an app — some system-builtin
entitlements reuse the same id across every app that has one. Always key
on `(app_id, id)` together, never `id` alone.
Expand Down
15 changes: 8 additions & 7 deletions cmd/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,15 @@ func parseCurrentPolicyStepID(data []byte) (string, error) {
return resp.TaskView.Task.Policy.Current.ID, nil
}

// resolvePolicyStepID returns the policy step ID to use for an approve/deny
// action. If the user supplied one explicitly it is used as-is; otherwise the
// task is fetched and its currently executing step ID is used.
// resolvePolicyStepID returns the policy step id an action should target:
// the explicit --policy-step-id when given, otherwise the task's currently
// executing step, fetched with a GET.
//
// approve requires policyStepId, so callers pass required=true to turn an
// underivable step into an error. deny treats it as optional (the API does
// not require it), so it passes required=false and simply omits the field
// when no current step can be derived.
// required distinguishes the two modes callers need. Actions the server
// rejects without a step (approve, skip-step) and reassign, which we refuse to
// send ambiguously, pass true and get an error. deny and restart pass false:
// when the step cannot be derived the field is omitted rather than blocking
// the action, which is what lets restart act on a closed task.
func resolvePolicyStepID(ctx context.Context, c *client.Client, taskID, explicit string, required bool) (string, error) {
if explicit != "" {
return explicit, nil
Expand Down
96 changes: 96 additions & 0 deletions cmd/tasks_action.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package cmd

import (
"fmt"

"github.com/ConductorOne/c1i/internal/client"
"github.com/spf13/cobra"
)

// policyStepMode says whether an action's request needs a policy step id.
type policyStepMode int

const (
stepUnused policyStepMode = iota // the endpoint takes no policyStepId
stepOptional // send it when it can be resolved, omit otherwise
stepRequired // the server rejects the call without it
)

// taskAction describes one POST /api/v1/tasks/{id}/action/{verb} command.
// These fields are all the commands differ by, so they share one RunE.
type taskAction struct {
verb string // the path segment, e.g. "restart"
step policyStepMode
// extraBody adds fields beyond comment/policyStepId, and may reject bad
// flag combinations. Runs before any client is built, so it exits 2.
extraBody func(cmd *cobra.Command, body map[string]any) error
// confirm formats the success line. State is passed but is the task's
// PRE-action state, so most actions must not print it.
confirm func(id, state, stepID string) string
}

// runTaskAction is the shared RunE. Flags are validated before a client is
// built, so a usage error exits 2 rather than failing on credentials.
func (a taskAction) runTaskAction(cmd *cobra.Command, args []string) error {
var comment string
if cmd.Flags().Lookup("comment") != nil {
comment, _ = cmd.Flags().GetString("comment")
}

body := map[string]any{}
if a.extraBody != nil {
if err := a.extraBody(cmd, body); err != nil {
return err
}
}

taskID := args[0]
path := client.Path("/api/v1/tasks/%s/action/%s", taskID, a.verb)
if comment != "" {
body["comment"] = comment
}

// Resolved even for a preview, so --dry-run still rejects a bad --url and
// still names the tenant it would hit.
baseURL, err := GetBaseURL()
if err != nil {
return err
}

// Credentials are only needed to send, or to fetch a step. Matches what
// each command did before sharing this runner.
if a.step == stepUnused && dryRunActive() {
return printDryRun(cmd, "POST", path, body)
}
c, err := newClient(cmd, baseURL)
if err != nil {
return fmt.Errorf("authentication failed: %w", err)
}

var stepID string
if a.step != stepUnused {
explicit, _ := cmd.Flags().GetString("policy-step-id")
stepID, err = resolvePolicyStepID(cmd.Context(), c, taskID, explicit, a.step == stepRequired)
if err != nil {
return err
}
if stepID != "" {
body["policyStepId"] = stepID
}
if dryRunActive() {
return printDryRun(cmd, "POST", path, body)
}
}

data, err := c.Post(cmd.Context(), path, body)
if err != nil {
return fmt.Errorf("API error: %w", err)
}
id, state, err := parseTaskActionResponse(data)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}

_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s", a.confirm(id, state, stepID))
return nil
}
Loading
Loading