Wire action-group variables, sudo, and command path resolution through the API and UI.

Completes run-if/set_variable and requires_sudo editing, sudoers path resolve helpers, and execute permission checks so node action groups can gate upgrades and elevate safely.
This commit is contained in:
2026-07-19 22:00:14 +02:00
parent 0e06063c1d
commit 7631591f30
22 changed files with 2685 additions and 274 deletions
+233 -2
View File
@@ -1431,7 +1431,6 @@
.node-action-add-row,
.node-action-group-heading,
.node-action-group-toolbar,
.node-action-item-heading,
.node-action-item-toolbar {
display: flex;
flex-wrap: wrap;
@@ -1439,6 +1438,78 @@
align-items: center;
}
.node-action-item-heading {
display: flex;
flex-wrap: nowrap;
gap: 0.75rem;
align-items: center;
width: 100%;
}
.node-action-item-heading > span:first-child {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-action-item-toolbar {
flex: 0 0 auto;
flex-wrap: nowrap;
margin-left: auto;
justify-content: flex-end;
}
.node-action-reorder {
display: inline-flex;
align-items: center;
gap: 0.15rem;
}
.node-action-reorder-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
margin: 0;
padding: 0;
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
background: var(--ctp-mantle);
color: var(--ctp-overlay1);
cursor: pointer;
line-height: 0;
}
.node-action-reorder-btn:hover:not(:disabled),
.node-action-reorder-btn:focus-visible:not(:disabled) {
color: var(--ctp-text);
background: var(--ctp-surface0);
}
.node-action-reorder-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.node-action-group-heading {
justify-content: space-between;
width: 100%;
}
.node-action-group-heading > input,
.node-action-group-heading > strong {
flex: 1 1 auto;
min-width: 0;
}
.node-action-group-toolbar {
margin-left: auto;
justify-content: flex-end;
}
.node-action-group-list {
list-style: none;
margin: 1rem 0 0;
@@ -1454,6 +1525,145 @@
background: color-mix(in srgb, var(--ctp-surface0) 70%, transparent);
}
.node-action-group-layout {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.node-action-group-main {
flex: 1 1 auto;
min-width: 0;
}
.node-action-sudoers {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 0 0 22rem;
max-width: 26rem;
padding: 0.75rem 0.9rem;
border: 1px solid var(--ctp-surface1);
border-radius: 0.35rem;
background: var(--ctp-mantle);
position: sticky;
top: 1rem;
}
.node-action-sudoers:not([open]) {
padding: 0.35rem 0.65rem;
gap: 0;
}
.node-action-sudoers-summary {
display: flex;
align-items: center;
gap: 0.45rem;
cursor: pointer;
list-style: none;
}
.node-action-sudoers-summary::-webkit-details-marker {
display: none;
}
.node-action-sudoers-summary::marker {
content: '';
}
.node-action-sudoers h4 {
margin: 0;
font-size: 0.95rem;
}
.node-action-sudoers-alert {
flex-shrink: 0;
width: 1em;
height: 1em;
color: var(--ctp-yellow);
}
.node-action-sudoers-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.node-action-sudoers-resolve {
margin: 0.5rem 0 0;
}
button.resolve-paths-needed {
color: var(--ctp-yellow);
border-color: var(--ctp-yellow);
}
.copyable-code {
display: inline-flex;
align-items: flex-start;
gap: 0.35rem;
max-width: 100%;
padding: 0.25rem 0.35rem 0.25rem 0.45rem;
border: 1px solid var(--ctp-surface1);
border-radius: 0.3rem;
background: var(--ctp-crust);
}
.copyable-code code {
flex: 1 1 auto;
min-width: 0;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.35;
}
.copyable-code-copy {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0.15rem;
border: none;
border-radius: 0.2rem;
background: transparent;
color: var(--ctp-overlay1);
cursor: pointer;
line-height: 0;
}
.copyable-code-copy:hover,
.copyable-code-copy:focus-visible {
color: var(--ctp-text);
background: var(--ctp-surface0);
}
.copyable-code-icon {
display: block;
}
.node-action-sudoers-list .copyable-code {
display: flex;
width: 100%;
}
@media (max-width: 52rem) {
.node-action-group-layout {
flex-direction: column;
}
.node-action-sudoers {
flex: 1 1 auto;
max-width: none;
width: 100%;
position: static;
}
}
.node-action-schedule {
border: 1px solid var(--ctp-surface1);
border-radius: 0.4rem;
@@ -1470,6 +1680,13 @@
gap: 0.65rem;
}
.node-action-item-editing {
padding: 0.65rem 0.75rem;
border: 1px solid var(--ctp-mauve);
border-radius: 0.35rem;
background: var(--ctp-mantle);
}
.node-action-item-edit,
.node-action-add-custom,
.node-action-adders {
@@ -1478,7 +1695,12 @@
margin-top: 0.5rem;
}
.schedule-times-editor,
.schedule-times-editor {
display: grid;
gap: 0.65rem;
width: 100%;
}
.schedule-time-row,
.schedule-weekdays {
display: flex;
@@ -1487,6 +1709,10 @@
align-items: center;
}
.schedule-time-row {
width: 100%;
}
.checkbox-row {
display: inline-flex;
align-items: center;
@@ -1583,3 +1809,8 @@
button.danger {
color: var(--ctp-red);
}
button.save-dirty {
color: var(--ctp-green);
border-color: var(--ctp-green);
}
+3
View File
@@ -41,6 +41,7 @@ function stubFetchOk() {
'actions.create',
'actions.update',
'actions.delete',
'actions.execute',
'users.manage',
'secrets.manage',
'roles.manage',
@@ -93,6 +94,7 @@ function stubFetchOk() {
'actions.create',
'actions.update',
'actions.delete',
'actions.execute',
'users.manage',
'secrets.manage',
'roles.manage',
@@ -828,6 +830,7 @@ describe('App', () => {
'actions.create',
'actions.update',
'actions.delete',
'actions.execute',
'users.manage',
'secrets.manage',
'roles.manage',
+27
View File
@@ -149,6 +149,7 @@ const DEFAULT_PERMISSIONS = [
'actions.create',
'actions.update',
'actions.delete',
'actions.execute',
'users.manage',
'secrets.manage',
'roles.manage',
@@ -1118,6 +1119,7 @@ function ActionsConfigTab() {
const [draftDescription, setDraftDescription] = useState('')
const [draftBody, setDraftBody] = useState('')
const [draftEnv, setDraftEnv] = useState<ActionEnvVar[]>([emptyEnvRow()])
const [draftRequiresSudo, setDraftRequiresSudo] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const isEditing = editingActionId !== null
@@ -1169,6 +1171,7 @@ function ActionsConfigTab() {
setDraftDescription('')
setDraftBody('')
setDraftEnv([emptyEnvRow()])
setDraftRequiresSudo(false)
setSubmitError(null)
}
@@ -1185,6 +1188,7 @@ function ActionsConfigTab() {
setDraftDescription('')
setDraftBody('')
setDraftEnv([emptyEnvRow()])
setDraftRequiresSudo(false)
setSubmitError(null)
}
@@ -1203,6 +1207,7 @@ function ActionsConfigTab() {
? action.env.map((envVar) => ({ ...envVar }))
: [emptyEnvRow()],
)
setDraftRequiresSudo(Boolean(action.requires_sudo))
setSubmitError(null)
}
@@ -1250,6 +1255,7 @@ function ActionsConfigTab() {
description: draftDescription,
body: draftBody,
env,
requires_sudo: draftRequiresSudo,
})
: await createAction({
name: draftName,
@@ -1257,6 +1263,7 @@ function ActionsConfigTab() {
kind: editorKind,
body: draftBody,
env,
requires_sudo: draftRequiresSudo,
})
setActions([...response.actions])
clearEditor()
@@ -1342,6 +1349,11 @@ function ActionsConfigTab() {
{action.builtin ? (
<span className="user-badge">built-in</span>
) : null}
{action.requires_sudo ? (
<span className="user-badge action-requires-sudo">
sudo
</span>
) : null}
</div>
<div className="groups-li-meta">
{action.description || 'No description'}
@@ -1521,8 +1533,23 @@ function ActionsConfigTab() {
onChange={(event) => setDraftBody(event.target.value)}
/>
)}
<p className="config-hint">
Do not include a leading <code>sudo</code>; check Requires Sudo
instead so ClusterCanvas runs with <code>sudo -n</code>.
</p>
</div>
<label className="checkbox-row">
<input
type="checkbox"
checked={draftRequiresSudo}
onChange={(event) =>
setDraftRequiresSudo(event.target.checked)
}
/>
Requires Sudo
</label>
<div className="config-field">
<label>Environment variables</label>
<p className="config-hint">
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
import { describe, expect, it } from 'vitest'
import type { Action, NodeActionItem } from './api/client'
import {
applyResolvedPathToCommand,
collectRelativeCommandNames,
collectRunsAsPreviews,
collectSudoersLines,
commandArgv0,
runsAsRemoteCommand,
stripLeadingSudo,
} from './actionSudoers'
describe('stripLeadingSudo', () => {
it('strips a leading sudo', () => {
expect(stripLeadingSudo('sudo apt update')).toBe('apt update')
})
it('leaves commands without sudo', () => {
expect(stripLeadingSudo('/usr/bin/apt update')).toBe('/usr/bin/apt update')
})
})
describe('commandArgv0 and applyResolvedPathToCommand', () => {
it('extracts argv0', () => {
expect(commandArgv0('apt update')).toBe('apt')
expect(commandArgv0('/usr/bin/apt update')).toBe('/usr/bin/apt')
})
it('rewrites relative argv0 from a paths map', () => {
expect(
applyResolvedPathToCommand('apt update', { apt: '/usr/bin/apt' }),
).toBe('/usr/bin/apt update')
})
it('leaves absolute argv0 unchanged', () => {
expect(
applyResolvedPathToCommand('/usr/bin/apt update', {
apt: '/usr/bin/apt',
}),
).toBe('/usr/bin/apt update')
})
})
describe('collectRelativeCommandNames', () => {
it('collects unique relative shell argv0 names', () => {
const libraryById = new Map<string, Action>()
const items: NodeActionItem[] = [
{
id: '1',
source: 'local',
name: 'Update',
kind: 'shell',
body: 'apt update',
requires_sudo: true,
},
{
id: '2',
source: 'local',
name: 'Upgrade',
kind: 'shell',
body: 'apt upgrade -y',
requires_sudo: true,
},
{
id: '3',
source: 'local',
name: 'Abs',
kind: 'shell',
body: '/usr/bin/systemctl restart x',
requires_sudo: true,
},
{
id: '4',
source: 'local',
name: 'Script',
kind: 'script',
body: 'apt update',
requires_sudo: true,
},
]
expect(collectRelativeCommandNames(items, libraryById)).toEqual(['apt'])
})
})
describe('runsAsRemoteCommand', () => {
it('wraps shell commands with sudo -n', () => {
expect(runsAsRemoteCommand('shell', 'apt update', true)).toBe(
'sudo -n apt update',
)
})
it('applies resolved paths to shell previews', () => {
expect(
runsAsRemoteCommand('shell', 'apt update', true, {
apt: '/usr/bin/apt',
}),
).toBe('sudo -n /usr/bin/apt update')
})
it('uses bash -s for scripts with sudo', () => {
expect(
runsAsRemoteCommand('script', 'apt update\napt upgrade -y', true),
).toBe('sudo -n bash -s')
})
})
describe('collectSudoersLines', () => {
const libraryById = new Map<string, Action>()
it('suggests the shell body for shell actions', () => {
const items: NodeActionItem[] = [
{
id: '1',
source: 'local',
name: 'Update',
kind: 'shell',
body: 'apt update',
requires_sudo: true,
},
]
expect(collectSudoersLines(items, libraryById, 'clustercanvas')).toEqual([
'clustercanvas ALL=(root) NOPASSWD: apt update',
])
})
it('applies resolved paths to sudoers suggestions', () => {
const items: NodeActionItem[] = [
{
id: '1',
source: 'local',
name: 'Update',
kind: 'shell',
body: 'apt update',
requires_sudo: true,
},
]
expect(
collectSudoersLines(items, libraryById, 'clustercanvas', {
apt: '/usr/bin/apt',
}),
).toEqual(['clustercanvas ALL=(root) NOPASSWD: /usr/bin/apt update'])
})
it('suggests bash -s for script actions, not script lines', () => {
const items: NodeActionItem[] = [
{
id: '1',
source: 'local',
name: 'Update',
kind: 'script',
body: 'apt update\napt upgrade -y',
requires_sudo: true,
},
]
expect(collectSudoersLines(items, libraryById, 'clustercanvas')).toEqual([
'clustercanvas ALL=(root) NOPASSWD: /usr/bin/bash -s',
'clustercanvas ALL=(root) NOPASSWD: /bin/bash -s',
])
})
})
describe('collectRunsAsPreviews', () => {
it('lists unique remote commands for sudo items', () => {
const libraryById = new Map<string, Action>()
const items: NodeActionItem[] = [
{
id: '1',
source: 'local',
name: 'Update',
kind: 'shell',
body: 'apt update',
requires_sudo: true,
},
{
id: '2',
source: 'local',
name: 'Scripted',
kind: 'script',
body: 'apt update',
requires_sudo: true,
},
]
expect(collectRunsAsPreviews(items, libraryById)).toEqual([
'sudo -n apt update',
'sudo -n bash -s',
])
})
})
+194
View File
@@ -0,0 +1,194 @@
import type { Action, NodeActionItem } from './api/client'
export function itemRequiresSudo(
item: NodeActionItem,
libraryById: Map<string, Action>,
): boolean {
if (item.source === 'local') {
return Boolean(item.requires_sudo)
}
const libraryAction = libraryById.get(item.library_action_id || '')
return Boolean(libraryAction?.requires_sudo)
}
export function itemResolvedBody(
item: NodeActionItem,
libraryById: Map<string, Action>,
): { kind: 'shell' | 'script'; body: string } | null {
if (item.source === 'local') {
if (!item.body?.trim()) {
return null
}
return {
kind: item.kind === 'script' ? 'script' : 'shell',
body: item.body,
}
}
const libraryAction = libraryById.get(item.library_action_id || '')
if (!libraryAction) {
return null
}
return { kind: libraryAction.kind, body: libraryAction.body }
}
export function stripLeadingSudo(command: string): string {
const trimmed = command.trim()
if (/^sudo(\s|$)/i.test(trimmed)) {
return trimmed.replace(/^sudo\s+/i, '').trim()
}
return trimmed
}
/** First whitespace-separated token of a shell command body. */
export function commandArgv0(command: string): string {
const trimmed = stripLeadingSudo(command)
if (!trimmed) {
return ''
}
const match = trimmed.match(/^[^\s]+/)
return match ? match[0] : ''
}
/** Replace argv0 when paths has a mapping for that relative name. */
export function applyResolvedPathToCommand(
command: string,
paths: Readonly<Record<string, string>>,
): string {
const trimmed = command.trim()
if (!trimmed) {
return trimmed
}
const argv0 = commandArgv0(trimmed)
if (!argv0 || argv0.startsWith('/')) {
return trimmed
}
const absolute = paths[argv0]
if (!absolute) {
return trimmed
}
return `${absolute}${trimmed.slice(argv0.length)}`
}
/** Relative argv0 names from shell sudo items (for resolve-commands). */
export function collectRelativeCommandNames(
items: ReadonlyArray<NodeActionItem>,
libraryById: Map<string, Action>,
): string[] {
const seen = new Set<string>()
const names: string[] = []
for (const item of items) {
if (!itemRequiresSudo(item, libraryById)) {
continue
}
const resolved = itemResolvedBody(item, libraryById)
if (!resolved || resolved.kind !== 'shell') {
continue
}
const argv0 = commandArgv0(resolved.body)
if (!argv0 || argv0.startsWith('/') || seen.has(argv0)) {
continue
}
seen.add(argv0)
names.push(argv0)
}
return names
}
/** Remote command ClusterCanvas runs (env exports omitted). */
export function runsAsRemoteCommand(
kind: 'shell' | 'script',
body: string,
requiresSudo: boolean,
paths?: Readonly<Record<string, string>>,
): string {
if (kind === 'script') {
return requiresSudo ? 'sudo -n bash -s' : 'bash -s'
}
let command = stripLeadingSudo(body)
if (paths) {
command = applyResolvedPathToCommand(command, paths)
}
if (!command) {
return requiresSudo ? 'sudo -n' : ''
}
return requiresSudo ? `sudo -n ${command}` : command
}
const SCRIPT_SUDO_COMMANDS = ['/usr/bin/bash -s', '/bin/bash -s'] as const
function exactSudoersCommands(
kind: 'shell' | 'script',
body: string,
paths?: Readonly<Record<string, string>>,
): string[] {
if (kind === 'script') {
return [...SCRIPT_SUDO_COMMANDS]
}
let command = stripLeadingSudo(body)
if (paths) {
command = applyResolvedPathToCommand(command, paths)
}
return command ? [command] : []
}
export function collectSudoersLines(
items: ReadonlyArray<NodeActionItem>,
libraryById: Map<string, Action>,
username: string,
paths?: Readonly<Record<string, string>>,
): string[] {
const seen = new Set<string>()
const lines: string[] = []
for (const item of items) {
if (!itemRequiresSudo(item, libraryById)) {
continue
}
const resolved = itemResolvedBody(item, libraryById)
if (!resolved) {
continue
}
for (const command of exactSudoersCommands(
resolved.kind,
resolved.body,
paths,
)) {
const line = `${username} ALL=(root) NOPASSWD: ${command}`
if (seen.has(line)) {
continue
}
seen.add(line)
lines.push(line)
}
}
return lines
}
export function collectRunsAsPreviews(
items: ReadonlyArray<NodeActionItem>,
libraryById: Map<string, Action>,
paths?: Readonly<Record<string, string>>,
): string[] {
const seen = new Set<string>()
const previews: string[] = []
for (const item of items) {
if (!itemRequiresSudo(item, libraryById)) {
continue
}
const resolved = itemResolvedBody(item, libraryById)
if (!resolved) {
continue
}
const preview = runsAsRemoteCommand(
resolved.kind,
resolved.body,
true,
paths,
)
if (!preview || seen.has(preview)) {
continue
}
seen.add(preview)
previews.push(preview)
}
return previews
}
+44
View File
@@ -774,6 +774,31 @@ export async function testNodeSSH(
return (await response.json()) as NodeSSHTestResponse
}
export type ResolveCommandsResponse = {
paths: Record<string, string>
missing: ReadonlyArray<string>
}
export async function resolveNodeCommands(
id: string,
names: ReadonlyArray<string>,
fetchImpl: typeof fetch = fetch,
): Promise<ResolveCommandsResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(id)}/resolve-commands`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ names }),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ResolveCommandsResponse
}
export type NodeAuditAction = 'create' | 'delete' | 'test_ssh' | 'update'
export type NodeAuditEvent = {
@@ -847,6 +872,7 @@ export type Action = {
kind: ActionKind
body: string
env: ReadonlyArray<ActionEnvVar>
requires_sudo: boolean
builtin: boolean
created_at: string
updated_at: string
@@ -862,6 +888,7 @@ export type CreateActionRequest = {
kind: ActionKind
body: string
env: ReadonlyArray<ActionEnvVar>
requires_sudo?: boolean
}
export type PatchActionRequest = {
@@ -869,6 +896,7 @@ export type PatchActionRequest = {
description?: string
body?: string
env?: ReadonlyArray<ActionEnvVar>
requires_sudo?: boolean
}
export async function fetchActions(
@@ -958,6 +986,9 @@ export type NodeActionItem = {
kind?: ActionKind
body?: string
env?: ReadonlyArray<ActionEnvVar>
requires_sudo?: boolean
set_variable?: string
run_if?: string
}
export type NodeActionGroup = {
@@ -983,10 +1014,17 @@ export type ActionItemRunResult = {
item_id: string
action_name: string
source: string
kind?: ActionKind
remote_command?: string
ssh_username?: string
exit_code: number
stdout: string
stderr: string
error?: string
skipped?: boolean
skip_reason?: string
set_variable?: string
variable_value?: string
started_at: string
finished_at: string
}
@@ -1048,6 +1086,9 @@ export type CreateActionGroupItemRequest = {
kind?: ActionKind
body?: string
env?: ReadonlyArray<ActionEnvVar>
requires_sudo?: boolean
set_variable?: string
run_if?: string
}
export type PatchActionGroupItemRequest = {
@@ -1056,6 +1097,9 @@ export type PatchActionGroupItemRequest = {
kind?: ActionKind
body?: string
env?: ReadonlyArray<ActionEnvVar>
requires_sudo?: boolean
set_variable?: string
run_if?: string
}
export async function fetchActionGroups(
+2
View File
@@ -14,6 +14,7 @@
--ctp-mauve: #8839ef;
--ctp-red: #d20f39;
--ctp-green: #40a02b;
--ctp-yellow: #df8e1d;
--ctp-blue: #1e66f5;
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 18%, var(--ctp-base));
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 12%, var(--ctp-base));
@@ -35,6 +36,7 @@
--ctp-mauve: #cba6f7;
--ctp-red: #f38ba8;
--ctp-green: #a6e3a1;
--ctp-yellow: #f9e2af;
--ctp-blue: #89b4fa;
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 22%, var(--ctp-base));
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 14%, var(--ctp-base));