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:
+233
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
+1020
-202
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user