Skip to content

fix(template): drop device-labels resolver + add DispatchAction template gate (#196)#236

Merged
PaulDotterer merged 1 commit into
mainfrom
fix/196-drop-device-labels-and-add-dispatch-gate
May 11, 2026
Merged

fix(template): drop device-labels resolver + add DispatchAction template gate (#196)#236
PaulDotterer merged 1 commit into
mainfrom
fix/196-drop-device-labels-and-add-dispatch-gate

Conversation

@PaulDotterer

@PaulDotterer PaulDotterer commented May 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Two scope corrections to the group-variables feature shipped in #196.

Drop device-labels rung from the resolver

Variables are EXCLUSIVELY a group concept (defined on device-groups + user-groups). The original implementation added a third resolver layer that read device.Labels and surfaced them as STRING-typed variables with highest precedence — that was never in scope. Device labels are a separate concept (group-membership filter / dynamic-group eval), not a variable source.

  • `internal/api/template/resolver.go`: drop `collectDeviceLabels` + the call site; update doc comments to two-layer (device-group > user-group) precedence.
  • `internal/api/template/render.go`: update Resolver doc.
  • `internal/api/template/resolver_test.go`: drop `TestStoreResolver_DeviceLabels_Surface` + `TestStoreResolver_PrecedenceLabelOverridesDeviceGroup` (they exercised the now-removed code path).

Add templated-params gate to DispatchAction

Ad-hoc `DispatchAction` has no group context to resolve `{{ var.NAME }}` from — the renderer only runs on the SyncActions path where the agent's device → group memberships supply the values. Without this gate, an operator who tries to run-now an action whose params contain `{{ var.X }}` would have those literal markers enqueued to the device verbatim. The agent would then consume them as opaque strings.

  • `internal/api/template/render.go`: new exported `HasReference(s)` helper colocated with the renderer's regex (single source of truth for the `{{ var.NAME }}` grammar).
  • `internal/api/action_dispatch.go`: marshal `inputs.params` once, scan via `template.HasReference`, return `CodeFailedPrecondition` with a clear operator message: "this action contains templated parameters... assign it to a device-group or user-group instead of running it directly." Marshal failure is fail-closed (`CodeInternal`).
  • `internal/api/dispatch_validation_test.go`: new `TestDispatchAction_TemplatedParamsRefused`.

`DispatchToGroup`, `DispatchActionSet`, `DispatchDefinition`, and `DispatchToMultiple` all funnel into `DispatchAction` — the single gate covers every ad-hoc dispatch path. `DispatchInstantAction` is parameterless (REBOOT/SYNC) and unaffected.

Test plan

  • `go build ./...` clean.
  • `go test -short ./internal/api/...` clean (including the new test + the existing resolver tests with the two device-label tests removed).
  • `go vet` + gofmt clean.
  • Local CR review clean (one finding from first pass — fail-closed on marshal error — addressed).
  • CI gate.

Follow-ups

This is PR 1 of a 4-PR scope correction:

  • PR 2 (sdk): reshape `ListAvailableVariablesRequest` to take group IDs instead of `device_id`.
  • PR 3 (server): rewire the handler to the new proto + bump SDK pin.
  • PR 4 (web): per-group autocomplete on the action-create form + variables tab; show the `FailedPrecondition` error from this PR with a clear UI message.

Refs #196 (scope correction).

Summary by CodeRabbit

  • New Features

    • Added validation to prevent direct dispatch of templated actions to individual devices; users must now assign such actions to groups instead.
  • Bug Fixes

    • Variable resolution now exclusively uses group-based variables; device labels no longer participate in variable sourcing.
  • Tests

    • Added test coverage for templated parameter validation.
    • Removed obsolete tests related to device-label variable resolution.

Review Change Stack

…ate gate (#196)

Two scope corrections to the group-variables feature shipped in #196.

## Drop device-labels rung from the resolver

Variables are EXCLUSIVELY a group concept (defined on device-groups +
user-groups). The original implementation added a third resolver layer
that read device.Labels and surfaced them as STRING-typed variables
with highest precedence — that was never in scope. Device labels are a
separate concept (group-membership filter / dynamic-group eval), not a
variable source.

- internal/api/template/resolver.go: drop collectDeviceLabels +
  the call site; update doc comments to two-layer (device-group >
  user-group) precedence.
- internal/api/template/render.go: update Resolver doc.
- internal/api/template/resolver_test.go: drop
  TestStoreResolver_DeviceLabels_Surface and
  TestStoreResolver_PrecedenceLabelOverridesDeviceGroup
  (they exercised the now-removed code path).

## Add templated-params gate to DispatchAction

Ad-hoc DispatchAction has no group context to resolve {{ var.NAME }}
from \xe2\x80\x94 the renderer only runs on the SyncActions path where the
agent's device \xe2\x86\x92 group memberships supply the values. Without this
gate, an operator who tries to run-now an action whose params contain
{{ var.X }} would have those literal markers enqueued to the device
verbatim. The agent would then consume them as opaque strings.

- internal/api/template/render.go: new exported HasReference(s)
  helper colocated with the renderer's regex (single source of
  truth for the {{ var.NAME }} grammar).
- internal/api/action_dispatch.go: marshal inputs.params once,
  scan for templates via template.HasReference, return
  CodeFailedPrecondition with a clear operator message that
  explains variables are group-only and the action should be
  assigned to a group instead. Marshal failure is fail-closed
  (CodeInternal) \xe2\x80\x94 unscannable params can't be passed.
- internal/api/dispatch_validation_test.go: new
  TestDispatchAction_TemplatedParamsRefused.

DispatchToGroup, DispatchActionSet, DispatchDefinition, and
DispatchToMultiple all funnel into DispatchAction \xe2\x80\x94 the single
gate covers every ad-hoc dispatch path. DispatchInstantAction is
parameterless (REBOOT/SYNC) and unaffected.

Refs #196 (scope correction).
@coderabbitai

coderabbitai Bot commented May 11, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The PR removes device labels from variable resolution precedence and adds a pre-dispatch validation gate that rejects templated parameters in ad-hoc device dispatches. Variable resolution now uses only device-group and user-group variables (group-only). A new HasReference helper detects template references; DispatchAction uses it to reject requests and redirect users to group assignment.

Changes

Template Parameters Gate for Ad-Hoc Dispatches

Layer / File(s) Summary
Variable Resolution Precedence
internal/api/template/resolver.go
StoreResolver now resolves variables from user-group and device-group variables only; device labels are removed from the precedence model. Documentation updated to reflect two-layer resolution. collectDeviceLabels method and generated-store import are deleted.
Template Reference Detection
internal/api/template/render.go
New HasReference(s string) bool helper detects {{ var.NAME }} references using fast substring and regex matching. Documentation clarified to reflect group-only variable sourcing.
Dispatch Validation Gate
internal/api/action_dispatch.go
DispatchAction imports template helper and adds pre-dispatch check: JSON-marshals input parameters, scans for templated references, and rejects ad-hoc device dispatches with CodeFailedPrecondition and messaging directing users to assign actions to groups.
Tests and Validation
internal/api/dispatch_validation_test.go, internal/api/template/resolver_test.go
New test TestDispatchAction_TemplatedParamsRefused verifies rejection of templated parameters. Two resolver tests (TestStoreResolver_DeviceLabels_Surface, TestStoreResolver_PrecedenceLabelOverridesDeviceGroup) removed because device labels no longer participate in variable resolution.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

  • manchtools/power-manage-server#205: Both modify internal/api/action_dispatch.go and refactor DispatchAction parameter handling; main PR adds templated-parameter validation while #205 refactors params normalization.
  • manchtools/power-manage-server#198: Introduced the initial server-side template package; main PR extends it with HasReference detection and refactors StoreResolver to drop device-labels precedence.

Poem

🐰 A swift gate guards the dispatch lane,
Where templated vars now break the chain—
No labels dance in group-only streams,
Just user-groups and device-group schemes.
Variables flow where groups convene,
In cleaner, simpler precedence—seen! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly summarizes both main changes: dropping device-labels from the resolver and adding a template gate to DispatchAction, matching the PR's primary objectives.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/196-drop-device-labels-and-add-dispatch-gate

Warning

Review ran into problems

🔥 Problems

Timed out fetching pipeline failures after 30000ms


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
internal/api/dispatch_validation_test.go (1)

181-216: ⚡ Quick win

Add the same assertion for the ActionId path.

This only pins the inline branch, but the new gate has separate normalization logic for stored actions (json.Unmarshal / string fallback). A templated stored action can regress independently and still leave this suite green. One ActionId case here would cover the whole feature much better.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/dispatch_validation_test.go` around lines 181 - 216, The test
TestDispatchAction_TemplatedParamsRefused only exercises the InlineAction
branch; add a second assertion that calls h.DispatchAction with an ActionSource
using ActionId (i.e. populate pm.DispatchActionRequest.ActionSource as
&pm.DispatchActionRequest_ActionId{ActionId: <pm.ActionId>}) so the
stored-action normalization path (json.Unmarshal / string fallback) is
exercised; expect a non-nil error, connect.CodeFailedPrecondition from
connect.CodeOf(err), and that err.Error() contains "templated parameters" just
like the inline case to prevent regressions in the stored-action path.
internal/api/template/resolver_test.go (1)

61-65: ⚡ Quick win

Replace the removal note with a negative regression test.

Deleting the old label-based tests drops the only coverage around label interaction. Since the new contract is “labels are ignored,” it’s worth asserting that explicitly so a future resolver change can’t accidentally reintroduce label-sourced variables or label-over-group shadowing.

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/api/action_dispatch.go`:
- Around line 150-177: The current gate marshals inputs.params to JSON and runs
template.HasReference over the whole blob (paramsJSONForScan), which rejects
var-like literals even in non-templateable fields (e.g.,
ShellParams.environment); instead, walk only the templateable fields the
renderer uses and run template.HasReference on those string values. Update
action_dispatch.go to replace the json.Marshal(inputs.params) +
template.HasReference(string(...)) check with a targeted walk (or call the
renderer's template-field walker if available, e.g.,
renderer.WalkTemplateableFields or a new walkTemplateableParams(inputs.params))
that visits the same annotated/templateable fields and calls
template.HasReference on each field value, keeping the existing fail-closed
behavior for any marshal/iteration errors.

---

Nitpick comments:
In `@internal/api/dispatch_validation_test.go`:
- Around line 181-216: The test TestDispatchAction_TemplatedParamsRefused only
exercises the InlineAction branch; add a second assertion that calls
h.DispatchAction with an ActionSource using ActionId (i.e. populate
pm.DispatchActionRequest.ActionSource as
&pm.DispatchActionRequest_ActionId{ActionId: <pm.ActionId>}) so the
stored-action normalization path (json.Unmarshal / string fallback) is
exercised; expect a non-nil error, connect.CodeFailedPrecondition from
connect.CodeOf(err), and that err.Error() contains "templated parameters" just
like the inline case to prevent regressions in the stored-action path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 03a9b98b-f66e-4cab-adc7-e72b7134e578

📥 Commits

Reviewing files that changed from the base of the PR and between 647d6eb and 7edf7b3.

📒 Files selected for processing (5)
  • internal/api/action_dispatch.go
  • internal/api/dispatch_validation_test.go
  • internal/api/template/render.go
  • internal/api/template/resolver.go
  • internal/api/template/resolver_test.go

Comment on lines +150 to +177
// Templated-params gate: ad-hoc DispatchAction has no group context
// to resolve `{{ var.NAME }}` from (variables are exclusively a
// group concept; the renderer only runs on the SyncActions path
// where the agent's device → group memberships supply the values).
// Refuse here rather than enqueueing literal `{{ ... }}` markers
// the agent would consume verbatim.
//
// Marshals to JSON once for the scan, regardless of params shape
// (map[string]any from the stored branch, []byte from the inline
// branch, or the rare string fallback). The marshal happens again
// downstream for signing — a one-extra-allocation cost in exchange
// for one shared scan path. Marshal failure here MUST be fail-
// closed: a params shape we can't serialise can't be scanned for
// templates either, and silently bypassing the gate would let
// templated content through that we couldn't see. The downstream
// signing step would also fail on the same params, but failing
// here is more honest about which gate is rejecting the call.
paramsJSONForScan, err := json.Marshal(inputs.params)
if err != nil {
return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal,
fmt.Sprintf("failed to marshal action params for template scan: %v", err))
}
if template.HasReference(string(paramsJSONForScan)) {
return nil, apiErrorCtx(ctx, ErrValidationFailed, connect.CodeFailedPrecondition,
"this action contains templated parameters ({{ var.NAME }}) and cannot be dispatched ad-hoc to a single device. "+
"Variables are resolved from device-group / user-group memberships at agent sync time. "+
"Assign the action to a device-group or user-group instead of running it directly.")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Keep the gate scoped to templateable fields only.

This JSON-wide scan will also reject {{ var.NAME }} inside params the renderer intentionally never substitutes, such as ShellParams.environment or any future non-templateable string field. That changes the contract from “only annotated fields are templates” to “any var-like literal is forbidden” for ad-hoc dispatches. Please gate against the same templateable-field walk the renderer uses instead of scanning the entire marshaled params blob.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/action_dispatch.go` around lines 150 - 177, The current gate
marshals inputs.params to JSON and runs template.HasReference over the whole
blob (paramsJSONForScan), which rejects var-like literals even in
non-templateable fields (e.g., ShellParams.environment); instead, walk only the
templateable fields the renderer uses and run template.HasReference on those
string values. Update action_dispatch.go to replace the
json.Marshal(inputs.params) + template.HasReference(string(...)) check with a
targeted walk (or call the renderer's template-field walker if available, e.g.,
renderer.WalkTemplateableFields or a new walkTemplateableParams(inputs.params))
that visits the same annotated/templateable fields and calls
template.HasReference on each field value, keeping the existing fail-closed
behavior for any marshal/iteration errors.

@PaulDotterer PaulDotterer merged commit 3bb247a into main May 11, 2026
5 checks passed
@PaulDotterer PaulDotterer deleted the fix/196-drop-device-labels-and-add-dispatch-gate branch May 11, 2026 20:24
PaulDotterer added a commit that referenced this pull request May 12, 2026
Reverts the group-based variables feature shipped via 5 server PRs:
- #197 (f0934bf): server storage + handlers + RBAC + events +
  migration 043_group_variables.sql + sqlc queries + group-variable
  validator + audit-redactor schema entries for the new event types
- #198 (6dafcef): server-side {{ var.NAME }} template renderer +
  internal_handler.go SyncActions hook + main.go renderer wiring
- #236 (3bb247a): drop device-labels resolver + add DispatchAction
  template gate
- #237 (ee455b1): rewire ListAvailableVariables to group-scoped +
  SDK pin bump
- #238 (05c7bc1): distinct error code for templated-dispatch refusal

Squashed into one cohesive rollback commit alongside the SDK pin
bump to power-manage-sdk@dda53a0 (which is the post-rollback SDK
HEAD that no longer carries the group-vars proto).

Conflicts resolved during revert:
- cmd/control/main.go: dropped both the 'template' import (we are
  removing the package) AND the 'asynqutil' import the revert wanted
  to put back (it lives in valkey.go now post-#157 slice 4 extraction;
  not main.go's responsibility anymore).
- go.mod: SDK pin set to dda53a0 (post-revert SDK HEAD) rather than
  the pre-#197 SHA the revert hunk targeted, so the server compiles
  against an SDK that also doesn't have the variables proto.
- go.sum: regenerated via 'go mod tidy' against the new pin.

Why: the design has fundamental scaling problems that were not caught
at design time. Group-bound variables become monsters when many
actions are assigned to a group; assignment-bound variables suffer
the inverse N\xc3\x97M repetition problem; bundle-based reuse needs more
design work before re-implementation. Cleaner to revert wholesale
than to ship a half-shape that locks operators into the wrong API.

Companion reverts:
- SDK #64 (merged): the proto + TS-client wrappers
- Web 09a8c75 (shipped): the variables UI + hint banner + error mapping

Refs #196.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant