Next (#1070)
This commit is contained in:
commit
d0075a7a8d
|
|
@ -0,0 +1,116 @@
|
|||
# Security Advisory Duplicates — Maintainer Guide
|
||||
|
||||
This document lists known duplicate security advisory clusters for [OliveTin/OliveTin](https://github.com/OliveTin/OliveTin). When triaging new reports, check here and [open advisories](https://github.com/OliveTin/OliveTin/security/advisories) before accepting.
|
||||
|
||||
**Duplicate policy:** the earliest reporter on the canonical advisory receives primary credit. Later reporters are credited on the canonical advisory when closed as duplicates. See [SECURITY.md](../SECURITY.md).
|
||||
|
||||
## Triage checklist
|
||||
|
||||
1. Search open advisories for the same component and attack path.
|
||||
2. Match against clusters below.
|
||||
3. If duplicate: close the newer advisory, link to canonical, add reporter to canonical credits.
|
||||
4. If unique: accept, patch on a private branch, reassess CVSS with OliveTin context (see SECURITY.md — OliveTin is intentional RCE by design).
|
||||
5. Merge fix to `next`, publish advisory, credit reporters in advisory body (not commit message).
|
||||
|
||||
---
|
||||
|
||||
## shellAfterCompleted command injection
|
||||
|
||||
**Canonical:** [GHSA-vc6p-m6vx-6cwq](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-vc6p-m6vx-6cwq) — reporter **knight-yagami** (2026-03-04)
|
||||
|
||||
Untrusted command output or template variables interpolated into `shellAfterCompleted` and executed via `sh -c`.
|
||||
|
||||
| GHSA | Reporter | Status | Notes |
|
||||
|------|----------|--------|-------|
|
||||
| GHSA-vc6p-m6vx-6cwq | knight-yagami | closed (canonical) | Original report |
|
||||
| GHSA-v5gc-hqpq-227p | 0xkakash1 | closed (duplicate) | Output template variant |
|
||||
| GHSA-m7wr-wj5j-7459 | Ryu7zz | duplicate | Webhook `exec` → output → `shellAfterCompleted` |
|
||||
| GHSA-cjxm-x848-6vmc | Yesuhei | duplicate | Missing shell safety on after-completion |
|
||||
| GHSA-j9p9-36jc-2v8w | anushkavirgaonkar | duplicate | Same root cause |
|
||||
|
||||
**Fix:** shell-quote `output`/`exitCode` before template render; block `shellAfterCompleted` for webhook-tagged actions.
|
||||
|
||||
**CVSS note:** requires admin-configured `shellAfterCompleted` and attacker influence on output — typically PR:H not PR:N.
|
||||
|
||||
---
|
||||
|
||||
## OAuth2 state map memory exhaustion (DoS)
|
||||
|
||||
**Canonical:** [GHSA-xpxj-f2fm-rqch](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-xpxj-f2fm-rqch) — reporter **knight-yagami** (2026-03-04)
|
||||
|
||||
Unauthenticated `/oauth/login` grows `registeredStates` without TTL or cap.
|
||||
|
||||
| GHSA | Reporter | Status | Notes |
|
||||
|------|----------|--------|-------|
|
||||
| GHSA-xpxj-f2fm-rqch | knight-yagami | closed (canonical) | Original report |
|
||||
| GHSA-cj96-c55v-2f3c | Dredsen | duplicate | Same unbounded map |
|
||||
|
||||
**Fix:** TTL sweep (match 15-minute cookie MaxAge), max map size, cleanup on failed callback.
|
||||
|
||||
**CVSS note:** unauthenticated DoS — reported 7.5 is appropriate.
|
||||
|
||||
---
|
||||
|
||||
## URL argument type — unrestricted URI schemes (SSRF / file read)
|
||||
|
||||
**Canonical:** [GHSA-45pc-w4ph-hrq4](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-45pc-w4ph-hrq4) — reporter **fg0x0** (2026-03-09)
|
||||
|
||||
`url` type accepts `file://`, `gopher://`, etc. Blocked in `shell:` mode but still validated weakly for `exec:` actions.
|
||||
|
||||
| GHSA | Reporter | Status | Notes |
|
||||
|------|----------|--------|-------|
|
||||
| GHSA-45pc-w4ph-hrq4 | fg0x0 | closed (canonical) | Original report |
|
||||
| GHSA-cchg-25m4-q6rj | anushkavirgaonkar | duplicate | Same scheme validation gap |
|
||||
|
||||
**Fix:** allowlist `http`/`https` in `typeSafetyCheckUrl`.
|
||||
|
||||
**CVSS note:** admin must configure `exec:` action passing URL to external tool — PR:H.
|
||||
|
||||
---
|
||||
|
||||
## Custom `regex:` argument type in shell actions
|
||||
|
||||
**Canonical:** [GHSA-xc5w-4v5w-7x65](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-xc5w-4v5w-7x65) — reporter **Ayantaker** (2026-05-06)
|
||||
|
||||
`regex:` types not in shell denylist; partial `MatchString` allows injection suffixes.
|
||||
|
||||
| GHSA | Reporter | Status | Notes |
|
||||
|------|----------|--------|-------|
|
||||
| GHSA-xc5w-4v5w-7x65 | Ayantaker | canonical | Missing denylist entry |
|
||||
| GHSA-gvxq-7gvp-4ggr | anushkavirgaonkar | duplicate | Unanchored partial match |
|
||||
|
||||
**Fix:** deny `regex:` in shell mode; enforce full-string match for custom regex types.
|
||||
|
||||
---
|
||||
|
||||
## Shell denylist incomplete (post CVE-2026-27626)
|
||||
|
||||
**Canonical:** [GHSA-c26w-h42g-jfp9](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-c26w-h42g-jfp9) — reporter **sec-reex** (2026-07-03)
|
||||
|
||||
CVE-2026-27626 added `password` to denylist only; `html`, `confirmation`, and choiceless `checkbox` still skip validation and are allowed in `shell:` actions.
|
||||
|
||||
**Fix:** extend `checkShellArgumentSafety` denylist.
|
||||
|
||||
---
|
||||
|
||||
## StartActionAndWait logs ACL bypass
|
||||
|
||||
**Canonical:** [GHSA-jm28-2wcr-qf3h](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-jm28-2wcr-qf3h) — reporter **offset** (2026-03-12)
|
||||
|
||||
`StartActionAndWait` / `StartActionByGetAndWait` return full log output without `logs` ACL check.
|
||||
|
||||
No known duplicates.
|
||||
|
||||
**Fix:** apply `isLogEntryAllowed` before returning `LogEntry`.
|
||||
|
||||
**CVSS note:** requires authenticated user with `exec` but not `logs` — typically 4.3–5.3 not 6.5.
|
||||
|
||||
---
|
||||
|
||||
## Easy to confuse (not duplicates)
|
||||
|
||||
| Topic | Advisories | Distinction |
|
||||
|-------|------------|-------------|
|
||||
| OAuth2 state DoS vs OAuth2 auth bypass | GHSA-xpxj vs GHSA-3v7p | DoS fills state map; bypass spoofs `authHttpHeaderUsername` |
|
||||
| `shellAfterCompleted` vs direct `shell` injection | GHSA-vc6p vs GHSA-49gm | Second-order via output vs first-order argument injection |
|
||||
| `ValidateArgumentType` enumeration | GHSA-f637 vs GHSA-x6q3 | Same issue; GHSA-f637 published |
|
||||
6
Makefile
6
Makefile
|
|
@ -57,6 +57,7 @@ devrun: compile
|
|||
devcontainer: compile podman-image podman-container
|
||||
|
||||
webui-dist:
|
||||
$(call delete-files,webui)
|
||||
$(MAKE) -wC frontend dist
|
||||
mv frontend/dist webui
|
||||
|
||||
|
|
@ -72,4 +73,7 @@ clean:
|
|||
config-tool:
|
||||
cd service && go run cmd/config-tool/main.go
|
||||
|
||||
.PHONY: proto service windows-resources windows-msi
|
||||
devcheck:
|
||||
python3 scripts/devcheck.py $(ARGS)
|
||||
|
||||
.PHONY: proto service windows-resources windows-msi devcheck
|
||||
|
|
|
|||
32
config.yaml
32
config.yaml
|
|
@ -133,7 +133,8 @@ actions:
|
|||
# Docs: https://docs.olivetin.app/args/input_confirmation.html
|
||||
- title: Delete old backups
|
||||
icon: ashtonished
|
||||
justification: true
|
||||
# A single space requires justification with no prefilled template (empty disables it).
|
||||
justification: " "
|
||||
shell: rm -rf /opt/oliveTinOldBackups/ && sleep 5
|
||||
arguments:
|
||||
- type: html
|
||||
|
|
@ -143,6 +144,29 @@ actions:
|
|||
- type: confirmation
|
||||
title: Are you sure?!
|
||||
|
||||
# Checklist arguments let users pick multiple predefined options. Selected
|
||||
# values are passed to the action as a comma-separated string.
|
||||
#
|
||||
# Docs: https://docs.olivetin.app/args/input_checklist.html
|
||||
- title: Backup selected directories
|
||||
icon: backup
|
||||
shell: 'echo "Backing up: {{ directories }}"'
|
||||
arguments:
|
||||
- name: directories
|
||||
title: Directories to back up
|
||||
type: checklist
|
||||
description: Select one or more directories to include in the backup.
|
||||
choices:
|
||||
- title: Documents
|
||||
value: documents
|
||||
- title: Photos
|
||||
value: photos
|
||||
- title: Music
|
||||
value: music
|
||||
- title: Videos
|
||||
value: videos
|
||||
default: documents,photos
|
||||
|
||||
# This is an action that runs a script included with OliveTin, that will
|
||||
# download themes. You will still need to set theme "themeName" in your config.
|
||||
#
|
||||
|
|
@ -299,6 +323,12 @@ entities:
|
|||
# Docs: https://docs.olivetin.app/entities/intro.html
|
||||
- file: entities/servers.yaml
|
||||
name: server
|
||||
icon: ssh
|
||||
properties:
|
||||
- name: hostname
|
||||
title: Hostname
|
||||
- name: ip
|
||||
title: IP
|
||||
|
||||
- file: entities/containers.json
|
||||
name: container
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@
|
|||
** xref:args/regex.adoc[Input: Regex]
|
||||
** xref:args/password.adoc[Input: Password]
|
||||
** xref:args/input_checkbox.adoc[Input: Checkbox/Boolean]
|
||||
** xref:args/input_checklist.adoc[Input: Checklist]
|
||||
** xref:args/input_dropdown.adoc[Input: Dropdown]
|
||||
** xref:args/input_datetime.adoc[Input: Date & Time]
|
||||
** xref:args/input_confirmation.adoc[Input: Confirmation]
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ actions:
|
|||
- title: Check date and send notification via apprise
|
||||
icon: date
|
||||
shell: date
|
||||
shellAfterCompleted: "apprise -c /config/apprise.yml -t 'Notification: Backup script completed' -b 'The backup script completed with code {{ exitCode}}. The log is: \n {{ output }} '"
|
||||
shellAfterCompleted: "apprise -c /config/apprise.yml -t 'Notification: Backup script completed' -b \"$(printf 'Backup completed with exit code %s. Log: %s' {{ exitCode }} {{ output }})\""
|
||||
----
|
||||
|
||||
When running shellAfterCompleted, you *cannot* use argument values - they are not passed to the command. However the following special arguments are defined;
|
||||
|
||||
* `{{ exitCode }}` - The exit code of the previous shell command
|
||||
* `{{ output }}` - The standard output of the previous shell command
|
||||
* `{{ exitCode }}` - The exit code of the previous shell command. OliveTin substitutes this with the `EXITCODE` environment variable when running `shellAfterCompleted`, so shell metacharacters in the value cannot break quoting.
|
||||
* `{{ output }}` - The standard output of the previous shell command. OliveTin substitutes this with the `OUTPUT` environment variable when running `shellAfterCompleted`, so shell metacharacters in command output cannot be executed. You can also reference `$OUTPUT` directly in your `shellAfterCompleted` command. Do not place these placeholders inside single-quoted shell arguments; single quotes prevent `$OUTPUT` and `$EXITCODE` from expanding after substitution.
|
||||
* `{{ .Arguments.ot_executionTrackingId }}` - The unique execution tracking id for this execution (version 3k; in 2k use `{{ ot_executionTrackingId }}`)
|
||||
* `{{ .Arguments.ot_username }}` - The username of the user who started the execution (version 3k; in 2k use `{{ ot_username }}`). May be `guest` or `cron` for unauthenticated or automated runs.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
[#checklist]
|
||||
= Input: Checklist
|
||||
|
||||
The `checklist` type argument renders multiple checkboxes from predefined `choices`. Users can select one or more options, and the selected values are passed to your action as a comma-separated string.
|
||||
|
||||
[source,yaml]
|
||||
----
|
||||
actions:
|
||||
- title: Backup selected directories
|
||||
shell: echo "Backing up: {{ directories }}"
|
||||
arguments:
|
||||
- name: directories
|
||||
title: Directories to back up
|
||||
type: checklist
|
||||
choices:
|
||||
- title: Documents
|
||||
value: documents
|
||||
- title: Photos
|
||||
value: photos
|
||||
- title: Music
|
||||
value: music
|
||||
default: documents,photos
|
||||
----
|
||||
|
||||
When the example above runs with Documents and Photos selected, the shell command becomes:
|
||||
|
||||
[source,shell]
|
||||
----
|
||||
echo "Backing up: documents,photos"
|
||||
----
|
||||
|
||||
== Select all / Select none
|
||||
|
||||
The web interface includes **Select all** and **Select none** controls above the checkbox list.
|
||||
|
||||
== Empty selections
|
||||
|
||||
If no options are selected, the argument value is an empty string. Use `rejectNull: true` when at least one selection is required.
|
||||
|
||||
[source,yaml]
|
||||
----
|
||||
arguments:
|
||||
- name: directories
|
||||
type: checklist
|
||||
rejectNull: true
|
||||
choices:
|
||||
- value: documents
|
||||
- value: photos
|
||||
----
|
||||
|
||||
== Choice values
|
||||
|
||||
Choice `value` fields must not contain commas, because commas are used to join multiple selections together.
|
||||
|
||||
Each `title` is shown in the web interface. If a submitted segment matches a choice `title`, OliveTin maps it to the corresponding `value` before validation, matching the behaviour of xref:args/input_checkbox.adoc[checkbox] arguments with choices.
|
||||
|
||||
== Using Entities
|
||||
|
||||
Checklist options can be generated from entities, using the same pattern as xref:args/input_dropdown.adoc#args-dropdown-entities[entity-backed dropdowns]. Define one choice template and set `entity` to the entity type name:
|
||||
|
||||
[source,yaml]
|
||||
----
|
||||
actions:
|
||||
- title: Restart selected containers
|
||||
shell: 'docker restart {{ containers }}'
|
||||
arguments:
|
||||
- name: containers
|
||||
title: Containers to restart
|
||||
type: checklist
|
||||
entity: container
|
||||
choices:
|
||||
- value: '{{ container.Names }}'
|
||||
title: '{{ container.Names }}'
|
||||
|
||||
entities:
|
||||
- file: entities/containers.json
|
||||
name: container
|
||||
----
|
||||
|
||||
OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a comma-separated string.
|
||||
|
|
@ -20,6 +20,7 @@ A full list of argument types are below;
|
|||
| int | xref:args/input.adoc[Textbox] | Any number, made up of the characters 0 to 9. Negative numbers are not supported.
|
||||
| url | xref:args/input.adoc[Textbox] | A URL (e.g. https://example.com). Accepts any scheme, including `file://` and `ftp://`. See warning below.
|
||||
| confirmation | xref:args/input_confirmation.adoc[Confirmation] | A "hidden" argument that makes the action require a confirmation before launching.
|
||||
| checklist | xref:args/input_checklist.adoc[Checklist] | Multiple checkboxes from predefined choices. Selected values are passed as a comma-separated string.
|
||||
| n/a, but `choices` used | xref:args/input_dropdown.adoc[Dropdown] | A "hidden" argument that makes the action require a confirmation before launching.
|
||||
| raw_string_multiline | xref:args/input_textarea.adoc[Textarea] | Anything. This is **dangerous**, as effectively people can type anything they like
|
||||
|===
|
||||
|
|
@ -31,4 +32,3 @@ The `url` argument type does not restrict the URL scheme. Users can enter `file:
|
|||
|
||||
If your action might be used by untrusted users, validate or filter the URL in your script (e.g. allow only `https://`) before using the value.
|
||||
====
|
||||
|
||||
|
|
|
|||
|
|
@ -92,9 +92,9 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
|
|||
execOnWebhooks: ActionWebhookExecHint[];
|
||||
|
||||
/**
|
||||
* @generated from field: bool justification = 16;
|
||||
* @generated from field: string justification = 20;
|
||||
*/
|
||||
justification: boolean;
|
||||
justification: string;
|
||||
|
||||
/**
|
||||
* @generated from field: bool has_running_instance = 17;
|
||||
|
|
@ -247,6 +247,27 @@ export declare type ActionArgumentChoice = Message<"olivetin.api.v1.ActionArgume
|
|||
*/
|
||||
export declare const ActionArgumentChoiceSchema: GenMessage<ActionArgumentChoice>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.EntityRelatedAction
|
||||
*/
|
||||
export declare type EntityRelatedAction = Message<"olivetin.api.v1.EntityRelatedAction"> & {
|
||||
/**
|
||||
* @generated from field: olivetin.api.v1.Action action = 1;
|
||||
*/
|
||||
action?: Action | undefined;
|
||||
|
||||
/**
|
||||
* @generated from field: map<string, string> prefilled_arguments = 2;
|
||||
*/
|
||||
prefilledArguments: { [key: string]: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message olivetin.api.v1.EntityRelatedAction.
|
||||
* Use `create(EntityRelatedActionSchema)` to create a new message.
|
||||
*/
|
||||
export declare const EntityRelatedActionSchema: GenMessage<EntityRelatedAction>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.Entity
|
||||
*/
|
||||
|
|
@ -275,6 +296,16 @@ export declare type Entity = Message<"olivetin.api.v1.Entity"> & {
|
|||
* @generated from field: map<string, string> fields = 5;
|
||||
*/
|
||||
fields: { [key: string]: string };
|
||||
|
||||
/**
|
||||
* @generated from field: repeated olivetin.api.v1.EntityRelatedAction related_actions = 6;
|
||||
*/
|
||||
relatedActions: EntityRelatedAction[];
|
||||
|
||||
/**
|
||||
* @generated from field: string icon = 7;
|
||||
*/
|
||||
icon: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1894,6 +1925,25 @@ export declare const GetActionBindingResponseSchema: GenMessage<GetActionBinding
|
|||
* @generated from message olivetin.api.v1.GetEntitiesRequest
|
||||
*/
|
||||
export declare type GetEntitiesRequest = Message<"olivetin.api.v1.GetEntitiesRequest"> & {
|
||||
/**
|
||||
* @generated from field: string entity_type = 1;
|
||||
*/
|
||||
entityType: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string filter = 2;
|
||||
*/
|
||||
filter: string;
|
||||
|
||||
/**
|
||||
* @generated from field: int32 page = 3;
|
||||
*/
|
||||
page: number;
|
||||
|
||||
/**
|
||||
* @generated from field: int32 page_size = 4;
|
||||
*/
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1936,6 +1986,21 @@ export declare type EntityDefinition = Message<"olivetin.api.v1.EntityDefinition
|
|||
* @generated from field: repeated string used_on_dashboards = 3;
|
||||
*/
|
||||
usedOnDashboards: string[];
|
||||
|
||||
/**
|
||||
* @generated from field: string icon = 4;
|
||||
*/
|
||||
icon: string;
|
||||
|
||||
/**
|
||||
* @generated from field: repeated olivetin.api.v1.EntityProperty properties = 5;
|
||||
*/
|
||||
properties: EntityProperty[];
|
||||
|
||||
/**
|
||||
* @generated from field: int32 total_instances = 6;
|
||||
*/
|
||||
totalInstances: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1944,6 +2009,27 @@ export declare type EntityDefinition = Message<"olivetin.api.v1.EntityDefinition
|
|||
*/
|
||||
export declare const EntityDefinitionSchema: GenMessage<EntityDefinition>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.EntityProperty
|
||||
*/
|
||||
export declare type EntityProperty = Message<"olivetin.api.v1.EntityProperty"> & {
|
||||
/**
|
||||
* @generated from field: string name = 1;
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string title = 2;
|
||||
*/
|
||||
title: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message olivetin.api.v1.EntityProperty.
|
||||
* Use `create(EntityPropertySchema)` to create a new message.
|
||||
*/
|
||||
export declare const EntityPropertySchema: GenMessage<EntityProperty>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.GetEntityRequest
|
||||
*/
|
||||
|
|
@ -2194,3 +2280,4 @@ export declare const OliveTinApiService: GenService<{
|
|||
output: typeof EntitySchema;
|
||||
},
|
||||
}>;
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -61,6 +61,11 @@ const props = defineProps({
|
|||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
prefilledArguments: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -248,7 +253,16 @@ async function handleClick() {
|
|||
return
|
||||
}
|
||||
if (needsArgumentForm(props.actionData)) {
|
||||
router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
|
||||
const bindingId = props.actionData.bindingId
|
||||
const prefilled = props.prefilledArguments || {}
|
||||
if (Object.keys(prefilled).length > 0) {
|
||||
router.push({
|
||||
path: `/actionBinding/${bindingId}/argumentForm`,
|
||||
state: { prefilledArguments: prefilled }
|
||||
})
|
||||
} else {
|
||||
router.push(`/actionBinding/${bindingId}/argumentForm`)
|
||||
}
|
||||
} else {
|
||||
await startAction()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
<template>
|
||||
<div class="choice-checklist" :id="`${id}-wrapper`">
|
||||
<div class="choice-checklist-controls">
|
||||
<button type="button" class="choice-checklist-control" @click="selectAll">
|
||||
Select all
|
||||
</button>
|
||||
<button type="button" class="choice-checklist-control" @click="selectNone">
|
||||
Select none
|
||||
</button>
|
||||
</div>
|
||||
<fieldset class="choice-checklist-fieldset">
|
||||
<legend class="visually-hidden">{{ label || name }}</legend>
|
||||
<label
|
||||
v-for="(choice, index) in choices"
|
||||
:key="choice.value"
|
||||
class="choice-checklist-item"
|
||||
:for="`${id}-${index}`"
|
||||
>
|
||||
<input
|
||||
:id="`${id}-${index}`"
|
||||
type="checkbox"
|
||||
:checked="isSelected(choice.value)"
|
||||
@change="handleToggle(choice.value)"
|
||||
/>
|
||||
<span>{{ choiceLabel(choice) }}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<input
|
||||
:id="`${id}-value`"
|
||||
:name="name"
|
||||
type="text"
|
||||
class="visually-hidden choice-checklist-value"
|
||||
:value="modelValue"
|
||||
:required="required"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
allChoiceValues,
|
||||
choiceLabel,
|
||||
formatChecklistValue,
|
||||
parseChecklistValue,
|
||||
toggleChoice
|
||||
} from '../utils/choiceChecklistHelpers.js'
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
choices: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const selectedValues = computed(() => parseChecklistValue(props.modelValue))
|
||||
|
||||
function isSelected(value) {
|
||||
return selectedValues.value.includes(value)
|
||||
}
|
||||
|
||||
function emitSelection(selected) {
|
||||
emit('update:modelValue', formatChecklistValue(selected))
|
||||
}
|
||||
|
||||
function handleToggle(value) {
|
||||
emitSelection(toggleChoice(selectedValues.value, value))
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
emitSelection(allChoiceValues(props.choices))
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
emitSelection([])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.choice-checklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.choice-checklist-controls {
|
||||
display: flex;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
.choice-checklist-control {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.choice-checklist-fieldset {
|
||||
border: none;
|
||||
display: grid;
|
||||
gap: 0.5em 1em;
|
||||
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.choice-checklist-item {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.4em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.choice-checklist-item input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -88,6 +88,7 @@ const rootRef = ref(null)
|
|||
const searchInputRef = ref(null)
|
||||
const isOpen = ref(false)
|
||||
const query = ref('')
|
||||
const isUserFiltering = ref(false)
|
||||
const highlightedIndex = ref(0)
|
||||
|
||||
const listboxId = computed(() => `${props.id}-listbox`)
|
||||
|
|
@ -109,6 +110,10 @@ const placeholderText = computed(() => {
|
|||
})
|
||||
|
||||
const filteredChoices = computed(() => {
|
||||
if (!isUserFiltering.value) {
|
||||
return props.choices
|
||||
}
|
||||
|
||||
const search = query.value.trim().toLowerCase()
|
||||
if (!search) {
|
||||
return props.choices
|
||||
|
|
@ -140,14 +145,23 @@ function syncFromModelValue() {
|
|||
}
|
||||
}
|
||||
|
||||
function selectedChoiceIndex(choices) {
|
||||
const index = choices.findIndex(choice => choice.value === props.modelValue)
|
||||
return index >= 0 ? index : 0
|
||||
}
|
||||
|
||||
function openList() {
|
||||
document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } }))
|
||||
const wasClosed = !isOpen.value
|
||||
isOpen.value = true
|
||||
highlightedIndex.value = 0
|
||||
if (wasClosed) {
|
||||
highlightedIndex.value = selectedChoiceIndex(filteredChoices.value)
|
||||
}
|
||||
}
|
||||
|
||||
function closeList() {
|
||||
isOpen.value = false
|
||||
isUserFiltering.value = false
|
||||
syncFromModelValue()
|
||||
}
|
||||
|
||||
|
|
@ -164,12 +178,14 @@ function selectChoice(choice) {
|
|||
function handleFocus() {
|
||||
if (!isOpen.value) {
|
||||
syncFromModelValue()
|
||||
isUserFiltering.value = false
|
||||
}
|
||||
|
||||
openList()
|
||||
}
|
||||
|
||||
function handleSearchInput(event) {
|
||||
isUserFiltering.value = true
|
||||
query.value = event.target.value
|
||||
openList()
|
||||
highlightedIndex.value = 0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
<template>
|
||||
<Section :padding="!hasTable">
|
||||
<template #title>
|
||||
<span class="section-title-with-icon">
|
||||
Entity:
|
||||
<ActionIconGlyph v-if="definition.icon" class="entity-title-icon" :glyph="definition.icon" />
|
||||
{{ definition.title }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="hasTable" #toolbar>
|
||||
<EntityListFilter v-model="searchText" />
|
||||
</template>
|
||||
|
||||
<p v-if="!hasTable">{{ definition.instances.length }} instances.</p>
|
||||
|
||||
<template v-if="hasTable">
|
||||
<p v-if="tableError" class="table-error padding" role="alert">{{ tableError }}</p>
|
||||
<EntityInstancesTable
|
||||
v-else
|
||||
:instances="tableInstances"
|
||||
:properties="definition.properties"
|
||||
:total-instances="totalInstances"
|
||||
v-model:page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ul v-else>
|
||||
<li v-for="inst in definition.instances" :key="inst.uniqueKey">
|
||||
<router-link :to="entityDetailsRoute(inst)">
|
||||
{{ inst.title }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="usedDashboards.length > 0" :class="{ padding: hasTable }">
|
||||
<h3>Used on Dashboards:</h3>
|
||||
<ul>
|
||||
<li v-for="dash in usedDashboards" :key="dash">
|
||||
<template v-if="isEntityDirectory(dash)">
|
||||
{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
|
||||
</template>
|
||||
<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
|
||||
{{ getDashboardTitle(dash) }}
|
||||
</router-link>
|
||||
<span v-else>{{ dash }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import Section from 'picocrank/vue/components/Section.vue'
|
||||
import ActionIconGlyph from './ActionIconGlyph.vue'
|
||||
import EntityInstancesTable from './EntityInstancesTable.vue'
|
||||
import EntityListFilter from './EntityListFilter.vue'
|
||||
import { entityDetailsRoute } from '../utils/entityRoutes.js'
|
||||
|
||||
const props = defineProps({
|
||||
definition: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const searchText = ref('')
|
||||
const tableInstances = ref([])
|
||||
const totalInstances = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const tableError = ref('')
|
||||
let fetchTimer = null
|
||||
let fetchSequence = 0
|
||||
|
||||
const hasTable = computed(() => (props.definition.properties?.length ?? 0) > 0)
|
||||
|
||||
const usedDashboards = computed(() => filteredDashboards(props.definition.usedOnDashboards ?? []))
|
||||
|
||||
watch(searchText, () => {
|
||||
currentPage.value = 1
|
||||
scheduleFetchTableInstances()
|
||||
})
|
||||
|
||||
watch([currentPage, pageSize], () => {
|
||||
scheduleFetchTableInstances()
|
||||
})
|
||||
|
||||
function filteredDashboards(dashboards) {
|
||||
return dashboards.filter(d => d && !d.includes('{{'))
|
||||
}
|
||||
|
||||
function isEntityDirectory(dashboardTitle) {
|
||||
return dashboardTitle.endsWith(' [Entity Directory]')
|
||||
}
|
||||
|
||||
function getDashboardTitle(dashboardTitle) {
|
||||
if (isEntityDirectory(dashboardTitle)) {
|
||||
return dashboardTitle.slice(0, -' [Entity Directory]'.length)
|
||||
}
|
||||
return dashboardTitle
|
||||
}
|
||||
|
||||
function scheduleFetchTableInstances() {
|
||||
if (!hasTable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (fetchTimer) {
|
||||
clearTimeout(fetchTimer)
|
||||
}
|
||||
|
||||
fetchTimer = setTimeout(() => {
|
||||
fetchTableInstances()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
async function fetchTableInstances() {
|
||||
if (!hasTable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = ++fetchSequence
|
||||
tableError.value = ''
|
||||
try {
|
||||
const response = await window.client.getEntities({
|
||||
entityType: props.definition.title,
|
||||
filter: searchText.value.trim(),
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value
|
||||
})
|
||||
|
||||
if (requestId !== fetchSequence) {
|
||||
return
|
||||
}
|
||||
|
||||
const definition = response.entityDefinitions?.find(def => def.title === props.definition.title)
|
||||
tableInstances.value = definition?.instances ?? []
|
||||
totalInstances.value = definition?.totalInstances ?? 0
|
||||
} catch (err) {
|
||||
if (requestId !== fetchSequence) {
|
||||
return
|
||||
}
|
||||
|
||||
console.error('Failed to fetch entity instances:', err)
|
||||
tableError.value = 'Failed to load entity instances.'
|
||||
tableInstances.value = []
|
||||
totalInstances.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (hasTable.value) {
|
||||
fetchTableInstances()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (fetchTimer) {
|
||||
clearTimeout(fetchTimer)
|
||||
fetchTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-title-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.entity-title-icon {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.table-error {
|
||||
color: var(--error, #c00);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<template>
|
||||
<Table
|
||||
:data="tableRows"
|
||||
:headers="headers"
|
||||
:show-pagination="false"
|
||||
>
|
||||
<template #cell-title="{ row, value }">
|
||||
<router-link :to="entityDetailsRoute(row)">
|
||||
{{ value }}
|
||||
</router-link>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<div v-if="totalInstances > 0" class="padding">
|
||||
<Pagination
|
||||
:total="totalInstances"
|
||||
v-model:page="currentPageModel"
|
||||
v-model:page-size="pageSizeModel"
|
||||
item-title="entities"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Table from 'picocrank/vue/components/Table.vue'
|
||||
import Pagination from 'picocrank/vue/components/Pagination.vue'
|
||||
import { entityDetailsRoute } from '../utils/entityRoutes.js'
|
||||
|
||||
const props = defineProps({
|
||||
instances: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
properties: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
totalInstances: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 10
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:page', 'update:pageSize'])
|
||||
|
||||
const headers = computed(() => {
|
||||
const propertyHeaders = props.properties.map(property => ({
|
||||
key: property.name,
|
||||
label: property.title
|
||||
}))
|
||||
|
||||
return [
|
||||
{ key: 'title', label: 'Name' },
|
||||
...propertyHeaders
|
||||
]
|
||||
})
|
||||
|
||||
const tableRows = computed(() =>
|
||||
props.instances.map(instance => ({
|
||||
...instance.fields,
|
||||
title: instance.title,
|
||||
type: instance.type,
|
||||
uniqueKey: instance.uniqueKey
|
||||
}))
|
||||
)
|
||||
|
||||
const currentPageModel = computed({
|
||||
get: () => props.page,
|
||||
set: value => emit('update:page', value)
|
||||
})
|
||||
|
||||
const pageSizeModel = computed({
|
||||
get: () => props.pageSize,
|
||||
set: value => emit('update:pageSize', value)
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<label class="input-with-icons">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor"
|
||||
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
|
||||
</svg>
|
||||
<input
|
||||
:value="modelValue"
|
||||
aria-label="Filter entities"
|
||||
placeholder="Filter entities..."
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
<button title="Clear search filter" :disabled="!modelValue" @click="$emit('update:modelValue', '')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor"
|
||||
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-with-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color, #ccc);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--section-background);
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.input-with-icons input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.input-with-icons button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input-with-icons button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
function parseLegacyChecklistValue(value) {
|
||||
return value.split(',').map((segment) => segment.trim()).filter((segment) => segment !== '')
|
||||
}
|
||||
|
||||
export function parseChecklistValue(value) {
|
||||
if (!value || value === '') {
|
||||
return []
|
||||
}
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (!Array.isArray(parsed)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return parsed.map((segment) => String(segment).trim()).filter((segment) => segment !== '')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return parseLegacyChecklistValue(value)
|
||||
}
|
||||
|
||||
export function formatChecklistValue(selected) {
|
||||
if (!Array.isArray(selected) || selected.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return JSON.stringify(selected)
|
||||
}
|
||||
|
||||
export function toggleChoice(selected, value) {
|
||||
const current = Array.isArray(selected) ? [...selected] : []
|
||||
const index = current.indexOf(value)
|
||||
|
||||
if (index === -1) {
|
||||
current.push(value)
|
||||
return current
|
||||
}
|
||||
|
||||
current.splice(index, 1)
|
||||
return current
|
||||
}
|
||||
|
||||
export function choiceLabel(choice) {
|
||||
return choice.title || choice.value || ''
|
||||
}
|
||||
|
||||
export function allChoiceValues(choices) {
|
||||
return choices.map((choice) => choice.value)
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
allChoiceValues,
|
||||
choiceLabel,
|
||||
formatChecklistValue,
|
||||
parseChecklistValue,
|
||||
toggleChoice
|
||||
} from './choiceChecklistHelpers.js'
|
||||
|
||||
const choices = [
|
||||
{ title: 'Documents', value: 'documents' },
|
||||
{ title: 'Photos', value: 'photos' }
|
||||
]
|
||||
|
||||
test('parseChecklistValue parses JSON-encoded values', () => {
|
||||
assert.deepEqual(parseChecklistValue('["documents","photos"]'), ['documents', 'photos'])
|
||||
assert.deepEqual(parseChecklistValue('["kitchen,bedroom","hallway"]'), ['kitchen,bedroom', 'hallway'])
|
||||
assert.deepEqual(parseChecklistValue(''), [])
|
||||
})
|
||||
|
||||
test('parseChecklistValue accepts legacy comma-delimited values', () => {
|
||||
assert.deepEqual(parseChecklistValue('documents,photos'), ['documents', 'photos'])
|
||||
assert.deepEqual(parseChecklistValue('documents, photos'), ['documents', 'photos'])
|
||||
})
|
||||
|
||||
test('formatChecklistValue joins selected values as JSON', () => {
|
||||
assert.equal(formatChecklistValue(['documents', 'photos']), '["documents","photos"]')
|
||||
assert.equal(formatChecklistValue(['kitchen,bedroom']), '["kitchen,bedroom"]')
|
||||
assert.equal(formatChecklistValue([]), '')
|
||||
})
|
||||
|
||||
test('toggleChoice adds and removes values', () => {
|
||||
assert.deepEqual(toggleChoice([], 'documents'), ['documents'])
|
||||
assert.deepEqual(toggleChoice(['documents'], 'photos'), ['documents', 'photos'])
|
||||
assert.deepEqual(toggleChoice(['documents', 'photos'], 'documents'), ['photos'])
|
||||
})
|
||||
|
||||
test('choiceLabel prefers title over value', () => {
|
||||
assert.equal(choiceLabel(choices[0]), 'Documents')
|
||||
assert.equal(choiceLabel({ value: 'music' }), 'music')
|
||||
})
|
||||
|
||||
test('allChoiceValues returns every choice value', () => {
|
||||
assert.deepEqual(allChoiceValues(choices), ['documents', 'photos'])
|
||||
})
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export function entityDetailsRoute (entity) {
|
||||
return {
|
||||
name: 'EntityDetails',
|
||||
params: {
|
||||
entityType: entity.type,
|
||||
entityKey: entity.uniqueKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
export function applyArgumentTemplate(template, args) {
|
||||
if (!template) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, name) => args[name] ?? '')
|
||||
}
|
||||
|
||||
export function actionRequiresJustification(justification) {
|
||||
return (justification ?? '').length > 0
|
||||
}
|
||||
|
||||
export function actionJustificationTemplate(justification) {
|
||||
return justification ?? ''
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { actionRequiresJustification } from './justificationTemplate.js'
|
||||
|
||||
export function needsArgumentForm (action) {
|
||||
return (action?.arguments?.length > 0) || action?.justification
|
||||
return (action?.arguments?.length > 0) || actionRequiresJustification(action?.justification)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
export function readPrefilledArgumentsFromNavigation() {
|
||||
const state = window.history.state
|
||||
if (state?.prefilledArguments && typeof state.prefilledArguments === 'object') {
|
||||
return { ...state.prefilledArguments }
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
export function getInitialArgumentValue(paramName, prefilledArguments = {}) {
|
||||
const safePrefilledArguments = prefilledArguments && typeof prefilledArguments === 'object'
|
||||
? prefilledArguments
|
||||
: {}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(safePrefilledArguments, paramName)) {
|
||||
return safePrefilledArguments[paramName]
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get(paramName)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from './prefilledArguments.js'
|
||||
|
||||
test('readPrefilledArgumentsFromNavigation returns navigation state values', () => {
|
||||
const originalState = window.history.state
|
||||
window.history.replaceState({ prefilledArguments: { ansible_host: '10.0.0.1' } }, '')
|
||||
|
||||
assert.deepEqual(readPrefilledArgumentsFromNavigation(), { ansible_host: '10.0.0.1' })
|
||||
|
||||
window.history.replaceState(originalState, '')
|
||||
})
|
||||
|
||||
test('getInitialArgumentValue prefers navigation state over query params', () => {
|
||||
const originalState = window.history.state
|
||||
const originalSearch = window.location.search
|
||||
|
||||
window.history.replaceState({ prefilledArguments: { ansible_host: '10.0.0.1' } }, '')
|
||||
window.history.replaceState(window.history.state, '', '?ansible_host=10.0.0.2')
|
||||
|
||||
assert.equal(getInitialArgumentValue('ansible_host', readPrefilledArgumentsFromNavigation()), '10.0.0.1')
|
||||
|
||||
window.history.replaceState(originalState, '')
|
||||
window.history.replaceState(window.history.state, '', originalSearch || '/')
|
||||
})
|
||||
|
||||
test('getInitialArgumentValue falls back to query params when state is absent', () => {
|
||||
const originalState = window.history.state
|
||||
const originalSearch = window.location.search
|
||||
|
||||
window.history.replaceState({}, '')
|
||||
window.history.replaceState(window.history.state, '', '?ansible_host=10.0.0.2')
|
||||
|
||||
assert.equal(getInitialArgumentValue('ansible_host', readPrefilledArgumentsFromNavigation()), '10.0.0.2')
|
||||
|
||||
window.history.replaceState(originalState, '')
|
||||
window.history.replaceState(window.history.state, '', originalSearch || '/')
|
||||
})
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { needsArgumentForm } from './needsArgumentForm.js'
|
||||
import { actionRequiresJustification } from './justificationTemplate.js'
|
||||
|
||||
const nonStorableArgumentTypes = new Set([
|
||||
'password',
|
||||
|
|
@ -17,7 +18,7 @@ export function logEntryArgumentsToStartActionArgs (logEntry) {
|
|||
}
|
||||
|
||||
export function rerunNeedsArgumentForm (action, logEntry) {
|
||||
if (action?.justification && !logEntry?.justification) {
|
||||
if (actionRequiresJustification(action?.justification) && !logEntry?.justification) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +61,7 @@ export function buildRerunStartActionArgs (bindingId, logEntry, action) {
|
|||
arguments: logEntryArgumentsToStartActionArgs(logEntry)
|
||||
}
|
||||
|
||||
if (action?.justification && logEntry?.justification) {
|
||||
if (actionRequiresJustification(action?.justification) && logEntry?.justification) {
|
||||
startActionArgs.justification = logEntry.justification
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ test('rerunNeedsArgumentForm can start directly when stored args are complete',
|
|||
})
|
||||
|
||||
test('rerunNeedsArgumentForm opens the form when justification is missing', () => {
|
||||
const action = { justification: true, arguments: [] }
|
||||
const action = { justification: ' ', arguments: [] }
|
||||
|
||||
assert.equal(rerunNeedsArgumentForm(action, {}), true)
|
||||
assert.equal(
|
||||
|
|
@ -90,7 +90,7 @@ test('buildRerunStartActionArgs includes stored justification', () => {
|
|||
arguments: [{ name: 'host', value: 'db-1' }],
|
||||
justification: 'maintenance window'
|
||||
}, {
|
||||
justification: true,
|
||||
justification: ' ',
|
||||
arguments: [{ name: 'host', type: 'ascii_identifier' }]
|
||||
}),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -96,7 +96,9 @@
|
|||
<td class="duration">{{ formatExecutionDuration(log) }}</td>
|
||||
<td>
|
||||
<router-link :to="`/logs/${log.executionTrackingId}`">
|
||||
<LogActionTitle :justification="log.justification">
|
||||
{{ log.executionTrackingId }}
|
||||
</LogActionTitle>
|
||||
</router-link>
|
||||
</td>
|
||||
<td class="tags">
|
||||
|
|
@ -134,6 +136,7 @@ import Section from 'picocrank/vue/components/Section.vue'
|
|||
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
||||
import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
|
||||
import LogActionTitle from '../components/LogActionTitle.vue'
|
||||
import { HugeiconsIcon } from '@hugeicons/vue'
|
||||
import { DashboardSquare01Icon, WorkoutRunIcon } from '@hugeicons/core-free-icons'
|
||||
import { requestReconnectNow } from '../../../js/websocket.js'
|
||||
|
|
@ -162,7 +165,8 @@ const filteredLogs = computed(() => {
|
|||
const searchLower = searchText.value.toLowerCase()
|
||||
return logs.value.filter(log =>
|
||||
log.executionTrackingId.toLowerCase().includes(searchLower) ||
|
||||
log.actionTitle.toLowerCase().includes(searchLower)
|
||||
log.actionTitle.toLowerCase().includes(searchLower) ||
|
||||
(log.justification || '').toLowerCase().includes(searchLower)
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,30 @@
|
|||
<template>
|
||||
<section id = "argument-popup">
|
||||
<div class="section-header">
|
||||
<h2>Start action: {{ title }}</h2>
|
||||
<h2>
|
||||
<span class="section-title-with-icon">
|
||||
Start action:
|
||||
<router-link
|
||||
:to="`/action/${bindingId}`"
|
||||
class="action-details-title-link"
|
||||
>
|
||||
<ActionIconGlyph v-if="icon" class="action-title-icon" :glyph="icon" />
|
||||
{{ title }}
|
||||
</router-link>
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="section-content padding">
|
||||
<form @submit="handleSubmit">
|
||||
<template v-if="actionArguments.length > 0">
|
||||
|
||||
<template v-for="arg in actionArguments" :key="arg.name">
|
||||
<label :for="arg.name">
|
||||
<label v-if="arg.type !== 'checklist'" :for="arg.name">
|
||||
{{ formatLabel(arg.title) }}
|
||||
</label>
|
||||
<div v-else class="argument-label">
|
||||
{{ formatLabel(arg.title) }}
|
||||
</div>
|
||||
|
||||
<datalist v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0" :id="`${arg.name}-choices`">
|
||||
<option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
|
||||
|
|
@ -25,6 +39,10 @@
|
|||
:choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
|
||||
@update:model-value="handleChoiceUpdate(arg, $event)" />
|
||||
|
||||
<ChoiceChecklist v-else-if="arg.type === 'checklist'" :id="arg.name" :name="arg.name"
|
||||
:label="arg.title" :choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
|
||||
@update:model-value="handleChoiceUpdate(arg, $event)" />
|
||||
|
||||
<component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name"
|
||||
:value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
|
||||
:checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
|
||||
|
|
@ -40,7 +58,7 @@
|
|||
|
||||
<template v-if="justificationRequired">
|
||||
<label for="justification">Justification:</label>
|
||||
<input id="justification" name="justification" type="text" v-model="justificationValue" required />
|
||||
<input id="justification" name="justification" type="text" :value="justificationValue" required @input="handleJustificationInput" />
|
||||
</template>
|
||||
|
||||
<div v-if="actionArguments.length === 0 && !justificationRequired">
|
||||
|
|
@ -61,10 +79,18 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { requestReconnectNow } from '../../../js/websocket.js'
|
||||
import ChoiceCombobox from '../components/ChoiceCombobox.vue'
|
||||
import ChoiceChecklist from '../components/ChoiceChecklist.vue'
|
||||
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||
import {
|
||||
actionJustificationTemplate,
|
||||
actionRequiresJustification,
|
||||
applyArgumentTemplate
|
||||
} from '../utils/justificationTemplate.js'
|
||||
import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from '../utils/prefilledArguments.js'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
|
|
@ -80,8 +106,11 @@ const formErrors = ref({})
|
|||
const actionArguments = ref([])
|
||||
const popupOnStart = ref('')
|
||||
const formReady = ref(false)
|
||||
const justificationRequired = ref(false)
|
||||
const justificationConfig = ref('')
|
||||
const justificationValue = ref('')
|
||||
const justificationEditedManually = ref(false)
|
||||
const justificationRequired = computed(() => actionRequiresJustification(justificationConfig.value))
|
||||
const justificationTemplate = computed(() => actionJustificationTemplate(justificationConfig.value))
|
||||
let isComponentMounted = true
|
||||
|
||||
// Computed properties
|
||||
|
|
@ -109,18 +138,21 @@ async function setup() {
|
|||
icon.value = action.icon
|
||||
popupOnStart.value = action.popupOnStart || ''
|
||||
actionArguments.value = action.arguments || []
|
||||
justificationRequired.value = action.justification || false
|
||||
justificationConfig.value = action.justification || ''
|
||||
justificationValue.value = ''
|
||||
justificationEditedManually.value = false
|
||||
argValues.value = {}
|
||||
formErrors.value = {}
|
||||
confirmationChecked.value = false
|
||||
hasConfirmation.value = false
|
||||
|
||||
// Initialize values from query params or defaults
|
||||
const prefilledArguments = readPrefilledArgumentsFromNavigation()
|
||||
|
||||
// Initialize values from navigation state, query params, or defaults
|
||||
actionArguments.value.forEach(arg => {
|
||||
if (arg.type === 'confirmation') {
|
||||
hasConfirmation.value = true
|
||||
const paramValue = getQueryParamValue(arg.name)
|
||||
const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
|
||||
let checkedValue = false
|
||||
if (paramValue !== null) {
|
||||
checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true
|
||||
|
|
@ -130,7 +162,7 @@ async function setup() {
|
|||
argValues.value[arg.name] = checkedValue
|
||||
confirmationChecked.value = checkedValue
|
||||
} else {
|
||||
const paramValue = getQueryParamValue(arg.name)
|
||||
const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
|
||||
if (arg.type === 'checkbox') {
|
||||
// For checkboxes, handle boolean default values properly
|
||||
if (paramValue !== null) {
|
||||
|
|
@ -158,16 +190,13 @@ async function setup() {
|
|||
formReady.value = true
|
||||
document.body.setAttribute('loaded-argument-form', props.bindingId)
|
||||
}
|
||||
|
||||
updateJustificationFromTemplate()
|
||||
} catch (err) {
|
||||
console.error('Failed to load argument form:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function getQueryParamValue(paramName) {
|
||||
const params = new URLSearchParams(window.location.search.substring(1))
|
||||
return params.get(paramName)
|
||||
}
|
||||
|
||||
function formatLabel(title) {
|
||||
const lastChar = title.charAt(title.length - 1)
|
||||
if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
|
||||
|
|
@ -222,10 +251,16 @@ function getArgumentValue(arg) {
|
|||
return argValues.value[arg.name] || ''
|
||||
}
|
||||
|
||||
function handleJustificationInput(event) {
|
||||
justificationValue.value = event.target.value
|
||||
justificationEditedManually.value = true
|
||||
}
|
||||
|
||||
function handleInput(arg, event) {
|
||||
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
|
||||
argValues.value[arg.name] = value
|
||||
updateUrlWithArg(arg.name, value)
|
||||
updateJustificationFromTemplate()
|
||||
}
|
||||
|
||||
function handleChange(arg, event) {
|
||||
|
|
@ -238,10 +273,19 @@ function handleChange(arg, event) {
|
|||
validateArgument(arg, event.target.value)
|
||||
}
|
||||
|
||||
function getValidationElement(arg) {
|
||||
if (arg.type === 'checklist') {
|
||||
return document.getElementById(`${arg.name}-value`)
|
||||
}
|
||||
|
||||
return document.getElementById(arg.name)
|
||||
}
|
||||
|
||||
function handleChoiceUpdate(arg, value) {
|
||||
argValues.value[arg.name] = value
|
||||
updateUrlWithArg(arg.name, value)
|
||||
validateArgument(arg, value)
|
||||
updateJustificationFromTemplate()
|
||||
}
|
||||
|
||||
async function validateArgument(arg, value) {
|
||||
|
|
@ -251,7 +295,7 @@ async function validateArgument(arg, value) {
|
|||
|
||||
// Skip validation for datetime - backend will handle mangling values without seconds
|
||||
if (arg.type === 'datetime') {
|
||||
const inputElement = document.getElementById(arg.name)
|
||||
const inputElement = getValidationElement(arg)
|
||||
if (inputElement) {
|
||||
inputElement.setCustomValidity('')
|
||||
}
|
||||
|
|
@ -261,7 +305,7 @@ async function validateArgument(arg, value) {
|
|||
|
||||
// Skip validation for checkbox and confirmation - they're always valid
|
||||
if (arg.type === 'checkbox' || arg.type === 'confirmation') {
|
||||
const inputElement = document.getElementById(arg.name)
|
||||
const inputElement = getValidationElement(arg)
|
||||
if (inputElement) {
|
||||
inputElement.setCustomValidity('')
|
||||
}
|
||||
|
|
@ -279,8 +323,7 @@ async function validateArgument(arg, value) {
|
|||
|
||||
const validation = await window.client.validateArgumentType(validateArgumentTypeArgs)
|
||||
|
||||
// Get the input element to set custom validity
|
||||
const inputElement = document.getElementById(arg.name)
|
||||
const inputElement = getValidationElement(arg)
|
||||
|
||||
if (validation.valid) {
|
||||
delete formErrors.value[arg.name]
|
||||
|
|
@ -297,8 +340,7 @@ async function validateArgument(arg, value) {
|
|||
}
|
||||
} catch (err) {
|
||||
console.warn('Validation failed:', err)
|
||||
// On error, clear any custom validity
|
||||
const inputElement = document.getElementById(arg.name)
|
||||
const inputElement = getValidationElement(arg)
|
||||
if (inputElement) {
|
||||
inputElement.setCustomValidity('')
|
||||
}
|
||||
|
|
@ -344,21 +386,39 @@ function formatArgumentValueForApi(arg, rawValue) {
|
|||
return rawValue ?? ''
|
||||
}
|
||||
|
||||
function getArgumentValues() {
|
||||
const ret = []
|
||||
function getSelectedArgumentEntries() {
|
||||
const entries = []
|
||||
|
||||
for (const arg of actionArguments.value) {
|
||||
if (!shouldSendArgument(arg)) {
|
||||
continue
|
||||
}
|
||||
|
||||
ret.push({
|
||||
entries.push({
|
||||
name: arg.name,
|
||||
value: formatArgumentValueForApi(arg, argValues.value[arg.name])
|
||||
})
|
||||
}
|
||||
|
||||
return ret
|
||||
return entries
|
||||
}
|
||||
|
||||
function getArgumentValues() {
|
||||
return getSelectedArgumentEntries().map(({ name, value }) => ({ name, value }))
|
||||
}
|
||||
|
||||
function getArgumentMapForTemplate() {
|
||||
return Object.fromEntries(
|
||||
getSelectedArgumentEntries().map(({ name, value }) => [name, value])
|
||||
)
|
||||
}
|
||||
|
||||
function updateJustificationFromTemplate() {
|
||||
if (!justificationTemplate.value || justificationEditedManually.value) {
|
||||
return
|
||||
}
|
||||
|
||||
justificationValue.value = applyArgumentTemplate(justificationTemplate.value, getArgumentMapForTemplate())
|
||||
}
|
||||
|
||||
function getUniqueId() {
|
||||
|
|
@ -393,7 +453,7 @@ function saveBrowserSuggestions() {
|
|||
const value = argValues.value[arg.name]
|
||||
|
||||
// Only save non-empty values for non-checkbox/confirmation/password types
|
||||
if (value && value !== '' && arg.type !== 'checkbox' && arg.type !== 'confirmation' && arg.type !== 'password') {
|
||||
if (value && value !== '' && arg.type !== 'checkbox' && arg.type !== 'confirmation' && arg.type !== 'checklist' && arg.type !== 'password') {
|
||||
try {
|
||||
const key = `olivetin-suggestions-${arg.suggestionsBrowserKey}`
|
||||
const stored = localStorage.getItem(key)
|
||||
|
|
@ -467,7 +527,7 @@ async function handleSubmit(event) {
|
|||
|
||||
for (const arg of actionArguments.value) {
|
||||
const value = argValues.value[arg.name]
|
||||
const inputElement = document.getElementById(arg.name)
|
||||
const inputElement = getValidationElement(arg)
|
||||
|
||||
if (arg.required && (!value || value === '')) {
|
||||
formErrors.value[arg.name] = 'This field is required'
|
||||
|
|
@ -484,6 +544,11 @@ async function handleSubmit(event) {
|
|||
return
|
||||
}
|
||||
|
||||
if (Object.keys(formErrors.value).length > 0) {
|
||||
console.log('argument form has validation errors')
|
||||
return
|
||||
}
|
||||
|
||||
const argvs = getArgumentValues()
|
||||
console.log('argument form has elements that passed validation')
|
||||
|
||||
|
|
@ -546,6 +611,27 @@ onUnmounted(() => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-title-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-title-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.action-details-title-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--link-color, #0066cc);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.action-details-title-link:hover {
|
||||
color: var(--link-hover-color, #004499);
|
||||
}
|
||||
|
||||
form {
|
||||
grid-template-columns: max-content auto auto;
|
||||
|
|
|
|||
|
|
@ -9,36 +9,18 @@
|
|||
</p>
|
||||
</Section>
|
||||
<template v-else>
|
||||
<Section v-for="def in entityDefinitions" :key="def.title" :title="'Entity: ' + def.title ">
|
||||
<p>{{ def.instances.length }} instances.</p>
|
||||
|
||||
<ul>
|
||||
<li v-for="inst in def.instances" :key="inst.uniqueKey">
|
||||
<router-link :to="{ name: 'EntityDetails', params: { entityType: inst.type, entityKey: inst.uniqueKey } }">
|
||||
{{ inst.title }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>Used on Dashboards:</h3>
|
||||
<ul>
|
||||
<li v-for="dash in filteredDashboards(def.usedOnDashboards)" :key="dash">
|
||||
<template v-if="isEntityDirectory(dash)">
|
||||
{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
|
||||
</template>
|
||||
<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
|
||||
{{ getDashboardTitle(dash) }}
|
||||
</router-link>
|
||||
<span v-else>{{ dash }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Section>
|
||||
<EntityDefinitionSection
|
||||
v-for="def in entityDefinitions"
|
||||
:key="def.title"
|
||||
:definition="def"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import Section from 'picocrank/vue/components/Section.vue'
|
||||
import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
|
||||
|
||||
const definitionsLoaded = ref(false)
|
||||
const entityDefinitions = ref([])
|
||||
|
|
@ -63,21 +45,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
function filteredDashboards(dashboards) {
|
||||
return dashboards.filter(d => d && !d.includes('{{'))
|
||||
}
|
||||
|
||||
function isEntityDirectory(dashboardTitle) {
|
||||
return dashboardTitle.endsWith(' [Entity Directory]')
|
||||
}
|
||||
|
||||
function getDashboardTitle(dashboardTitle) {
|
||||
if (isEntityDirectory(dashboardTitle)) {
|
||||
return dashboardTitle.slice(0, -' [Entity Directory]'.length)
|
||||
}
|
||||
return dashboardTitle
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchEntities()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
<template>
|
||||
<Section title="Entity Details">
|
||||
<Section>
|
||||
<template #title>
|
||||
<span class="section-title-with-icon">
|
||||
Entity Details:
|
||||
<ActionIconGlyph v-if="entityIcon" class="entity-title-icon" :glyph="entityIcon" />
|
||||
<span v-if="entityDetails?.title">{{ entityDetails.title }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template #toolbar>
|
||||
<button @click="goBack" class="back-button">
|
||||
<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
|
||||
|
|
@ -22,15 +29,15 @@
|
|||
<template v-if="entityDetails.fields">
|
||||
<template v-for="(value, key) in entityDetails.fields" :key="key">
|
||||
<dt>{{ key }}</dt>
|
||||
<dd v-html="value"></dd>
|
||||
<dd>{{ value }}</dd>
|
||||
</template>
|
||||
</template>
|
||||
</dl>
|
||||
<p v-if="!entityDetails.title && (!entityDetails.fields || Object.keys(entityDetails.fields).length === 0)">No details available for this entity.</p>
|
||||
</template>
|
||||
</Section>
|
||||
|
||||
<hr />
|
||||
|
||||
<h3>Dashboard Entity Directories</h3>
|
||||
<Section v-if="entityDetails" title="Dashboard Entity Directories">
|
||||
<div v-if="filteredDirectories.length > 0" class="directories-section">
|
||||
<ul class="directory-list">
|
||||
<li v-for="(directory, idx) in filteredDirectories" :key="idx">
|
||||
|
|
@ -49,10 +56,24 @@
|
|||
</ul>
|
||||
</div>
|
||||
<p v-else>No directories found for this entity.
|
||||
<a href = "https://docs.olivetin.app/dashboards/entity-directories.html" target = "_blank">Learn more</a>
|
||||
<a href="https://docs.olivetin.app/dashboards/entity-directories.html" target="_blank" rel="noopener noreferrer">Learn more</a>
|
||||
</p>
|
||||
</template>
|
||||
</Section>
|
||||
|
||||
<section v-if="entityDetails && relatedActions.length > 0" class="transparent">
|
||||
<div class="dashboard-row">
|
||||
<fieldset>
|
||||
<legend class="visually-hidden">Related actions</legend>
|
||||
<template v-for="(related, idx) in relatedActions" :key="related.action?.bindingId || idx">
|
||||
<ActionButton
|
||||
v-if="related.action"
|
||||
:action-data="related.action"
|
||||
:prefilled-arguments="related.prefilledArguments"
|
||||
/>
|
||||
</template>
|
||||
</fieldset>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
|
@ -61,6 +82,8 @@
|
|||
import { HugeiconsIcon } from '@hugeicons/vue'
|
||||
import { ArrowLeftIcon } from '@hugeicons/core-free-icons'
|
||||
import Section from 'picocrank/vue/components/Section.vue'
|
||||
import ActionButton from '../ActionButton.vue'
|
||||
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const entityDetails = ref(null)
|
||||
|
|
@ -77,6 +100,10 @@
|
|||
return entityDetails.value.directories.filter(d => d)
|
||||
})
|
||||
|
||||
const relatedActions = computed(() => entityDetails.value?.relatedActions ?? [])
|
||||
|
||||
const entityIcon = computed(() => entityDetails.value?.icon ?? '')
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'Entities' })
|
||||
}
|
||||
|
|
@ -121,11 +148,6 @@
|
|||
box-shadow: 0 0 .5em rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.directories-section h3 {
|
||||
margin-bottom: 0.5em;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.directory-list a {
|
||||
text-decoration: none;
|
||||
padding: 0.5em;
|
||||
|
|
@ -149,9 +171,22 @@
|
|||
opacity: 0.8;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border-color, #ccc);
|
||||
.section-title-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.entity-title-icon {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, 180px);
|
||||
grid-auto-rows: 1fr;
|
||||
justify-content: center;
|
||||
place-items: stretch;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
|
@ -164,11 +199,6 @@ hr {
|
|||
background-color: var(--bg-hover, #222);
|
||||
}
|
||||
|
||||
.directories-section {
|
||||
border-top-color: var(--border-color, #333);
|
||||
}
|
||||
|
||||
|
||||
.directory-list a:hover {
|
||||
background-color: var(--bg-hover, #222);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
default: test-install test-run
|
||||
default: test-install prep test-run
|
||||
|
||||
test-install:
|
||||
npm install --no-fund
|
||||
|
||||
prep:
|
||||
$(MAKE) -wC .. webui-dist
|
||||
$(MAKE) -wC ../service compile-currentenv
|
||||
|
||||
test-run:
|
||||
# GitHub Actions fails badly on the default timeout of 2000ms
|
||||
npx mocha tests --recursive -t 10000
|
||||
|
|
@ -24,4 +28,4 @@ getsnapshot:
|
|||
rm -rf /opt/OliveTin-snapshot/*
|
||||
gh run download -D /opt/OliveTin-snapshot/
|
||||
|
||||
.PHONY: default find-flakey-tests find-flakey-tests-inf
|
||||
.PHONY: default find-flakey-tests find-flakey-tests-inf prep
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ defaultPopupOnStart: execution-dialog
|
|||
|
||||
actions:
|
||||
- title: Test checkbox argument
|
||||
shell: "echo 'Checkbox value: {{ confirm }}'"
|
||||
exec:
|
||||
- echo
|
||||
- "Checkbox value: {{ confirm }}"
|
||||
icon: ping
|
||||
arguments:
|
||||
- name: confirm
|
||||
|
|
@ -15,5 +17,3 @@ actions:
|
|||
type: checkbox
|
||||
description: "When checked: 1, when unchecked: 0"
|
||||
default: false
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
import { describe, it, before, after } from 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { By, Condition } from 'selenium-webdriver'
|
||||
import {
|
||||
DEFAULT_UI_WAIT_MS,
|
||||
getRootAndWait,
|
||||
getActionButton,
|
||||
takeScreenshotOnFailure,
|
||||
getTerminalBuffer,
|
||||
waitForArgumentFormPage,
|
||||
waitForArgumentFormReady,
|
||||
waitForLogsPage,
|
||||
waitForExecutionComplete,
|
||||
} from '../../lib/elements.js'
|
||||
|
||||
async function openChecklistArgumentForm(actionTitle = 'Test checklist argument') {
|
||||
await getRootAndWait()
|
||||
const btn = await getActionButton(webdriver, actionTitle)
|
||||
await btn.click()
|
||||
|
||||
await waitForArgumentFormPage()
|
||||
await waitForArgumentFormReady()
|
||||
}
|
||||
|
||||
async function submitChecklistForm() {
|
||||
const submitButton = await webdriver.findElement(By.css('button[name="start"]'))
|
||||
await submitButton.click()
|
||||
}
|
||||
|
||||
async function pollTerminal(matcher, timeoutMs = DEFAULT_UI_WAIT_MS) {
|
||||
await webdriver.wait(
|
||||
new Condition('wait for terminal output', async () => {
|
||||
try {
|
||||
const terminalReady = await webdriver.executeScript(`
|
||||
return !!(window.terminal && window.terminal.getBufferAsString);
|
||||
`)
|
||||
if (!terminalReady) {
|
||||
return false
|
||||
}
|
||||
|
||||
const output = await getTerminalBuffer()
|
||||
if (!output) {
|
||||
return false
|
||||
}
|
||||
|
||||
return matcher(output.trim())
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}),
|
||||
timeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForTerminalOutput(expectedValue, label = 'Selected segments') {
|
||||
await pollTerminal(
|
||||
(output) => output.includes(`${label}: ${expectedValue}`),
|
||||
DEFAULT_UI_WAIT_MS
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForTerminalOutputPattern(pattern) {
|
||||
await pollTerminal(
|
||||
(output) => pattern.test(output),
|
||||
DEFAULT_UI_WAIT_MS
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForChecklistValue(expectedValue) {
|
||||
await webdriver.wait(
|
||||
new Condition('wait for checklist hidden value', async () => {
|
||||
const valueInput = await webdriver.findElement(By.css('.choice-checklist > input'))
|
||||
return (await valueInput.getAttribute('value')) === expectedValue
|
||||
}),
|
||||
DEFAULT_UI_WAIT_MS
|
||||
)
|
||||
}
|
||||
|
||||
async function getCheckboxByValueIndex(index) {
|
||||
const checkboxes = await webdriver.findElements(
|
||||
By.css('.choice-checklist-item input[type="checkbox"]')
|
||||
)
|
||||
return checkboxes[index]
|
||||
}
|
||||
|
||||
describe('config: checklist', function () {
|
||||
this.timeout(10000)
|
||||
|
||||
before(async function () {
|
||||
await runner.start('checklist')
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await runner.stop()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
takeScreenshotOnFailure(this.currentTest, webdriver)
|
||||
})
|
||||
|
||||
it('Checklist argument renders multiple checkbox inputs', async function () {
|
||||
await openChecklistArgumentForm()
|
||||
|
||||
const kitchen = await getCheckboxByValueIndex(0)
|
||||
const bedroom = await getCheckboxByValueIndex(1)
|
||||
const hallway = await getCheckboxByValueIndex(2)
|
||||
|
||||
expect(await kitchen.getAttribute('type')).to.equal('checkbox')
|
||||
expect(await bedroom.getAttribute('type')).to.equal('checkbox')
|
||||
expect(await hallway.getAttribute('type')).to.equal('checkbox')
|
||||
expect(await kitchen.isSelected()).to.be.true
|
||||
expect(await bedroom.isSelected()).to.be.true
|
||||
expect(await hallway.isSelected()).to.be.false
|
||||
})
|
||||
|
||||
it('Checklist select none submits an empty value', async function () {
|
||||
await openChecklistArgumentForm()
|
||||
|
||||
const selectNone = await webdriver.findElement(By.xpath("//button[normalize-space()='Select none']"))
|
||||
await selectNone.click()
|
||||
await waitForChecklistValue('')
|
||||
|
||||
const valueInput = await webdriver.findElement(By.css('.choice-checklist > input'))
|
||||
expect(await valueInput.getAttribute('value')).to.equal('')
|
||||
|
||||
await submitChecklistForm()
|
||||
await waitForLogsPage()
|
||||
await waitForExecutionComplete()
|
||||
await waitForTerminalOutputPattern(/Selected segments:\s*(\r?\n|$)/)
|
||||
})
|
||||
|
||||
it('Checklist select all submits every choice value', async function () {
|
||||
await openChecklistArgumentForm()
|
||||
|
||||
const selectNone = await webdriver.findElement(By.xpath("//button[normalize-space()='Select none']"))
|
||||
await selectNone.click()
|
||||
|
||||
const selectAll = await webdriver.findElement(By.xpath("//button[normalize-space()='Select all']"))
|
||||
await selectAll.click()
|
||||
|
||||
await submitChecklistForm()
|
||||
await waitForLogsPage()
|
||||
await waitForExecutionComplete()
|
||||
await waitForTerminalOutput('["kitchen","bedroom","hallway"]')
|
||||
})
|
||||
|
||||
it('Checklist toggles individual choices before submit', async function () {
|
||||
await openChecklistArgumentForm()
|
||||
|
||||
const hallway = await getCheckboxByValueIndex(2)
|
||||
await hallway.click()
|
||||
|
||||
await submitChecklistForm()
|
||||
await waitForLogsPage()
|
||||
await waitForExecutionComplete()
|
||||
await waitForTerminalOutput('["kitchen","bedroom","hallway"]')
|
||||
})
|
||||
|
||||
it('Checklist entity argument renders choices from entities', async function () {
|
||||
await openChecklistArgumentForm('Test checklist entity argument')
|
||||
|
||||
const checkboxes = await webdriver.findElements(
|
||||
By.css('.choice-checklist-item input[type="checkbox"]')
|
||||
)
|
||||
expect(checkboxes).to.have.length(2)
|
||||
|
||||
const labels = await webdriver.findElements(By.css('.choice-checklist-item span'))
|
||||
expect(await labels[0].getText()).to.equal('attic')
|
||||
expect(await labels[1].getText()).to.equal('basement')
|
||||
|
||||
await checkboxes[0].click()
|
||||
|
||||
await submitChecklistForm()
|
||||
await waitForLogsPage()
|
||||
await waitForExecutionComplete()
|
||||
await waitForTerminalOutput('["attic"]', 'Selected rooms')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
||||
|
||||
logLevel: "DEBUG"
|
||||
checkForUpdates: false
|
||||
defaultPopupOnStart: execution-dialog
|
||||
|
||||
entities:
|
||||
- file: entities/rooms.yaml
|
||||
name: room
|
||||
|
||||
actions:
|
||||
- title: Test checklist argument
|
||||
shell: "echo 'Selected segments: {{ segments }}'"
|
||||
icon: ping
|
||||
arguments:
|
||||
- name: segments
|
||||
title: Rooms to clean
|
||||
type: checklist
|
||||
description: Select the rooms to include in the vacuum run.
|
||||
choices:
|
||||
- title: Kitchen
|
||||
value: kitchen
|
||||
- title: Bedroom
|
||||
value: bedroom
|
||||
- title: Hallway
|
||||
value: hallway
|
||||
default: kitchen,bedroom
|
||||
|
||||
- title: Test checklist entity argument
|
||||
shell: "echo 'Selected rooms: {{ rooms }}'"
|
||||
icon: ping
|
||||
arguments:
|
||||
- name: rooms
|
||||
title: Rooms to include
|
||||
type: checklist
|
||||
entity: room
|
||||
choices:
|
||||
- title: '{{ room.hostname }}'
|
||||
value: '{{ room.hostname }}'
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
- hostname: attic
|
||||
- hostname: basement
|
||||
|
|
@ -20,7 +20,8 @@ message Action {
|
|||
repeated string exec_on_file_changed_in_dir = 13;
|
||||
string exec_on_calendar_file = 14;
|
||||
repeated ActionWebhookExecHint exec_on_webhooks = 15;
|
||||
bool justification = 16;
|
||||
reserved 16;
|
||||
string justification = 20;
|
||||
bool has_running_instance = 17;
|
||||
bool has_queued_instance = 18;
|
||||
repeated ActionGroupMembership groups = 19;
|
||||
|
|
@ -57,12 +58,19 @@ message ActionArgumentChoice {
|
|||
string title = 2;
|
||||
}
|
||||
|
||||
message EntityRelatedAction {
|
||||
Action action = 1;
|
||||
map<string, string> prefilled_arguments = 2;
|
||||
}
|
||||
|
||||
message Entity {
|
||||
string title = 1;
|
||||
string unique_key = 2;
|
||||
string type = 3;
|
||||
repeated string directories = 4;
|
||||
map<string, string> fields = 5;
|
||||
repeated EntityRelatedAction related_actions = 6;
|
||||
string icon = 7;
|
||||
}
|
||||
|
||||
message GetDashboardResponse {
|
||||
|
|
@ -427,6 +435,10 @@ message GetActionBindingResponse {
|
|||
}
|
||||
|
||||
message GetEntitiesRequest {
|
||||
string entity_type = 1;
|
||||
string filter = 2;
|
||||
int32 page = 3;
|
||||
int32 page_size = 4;
|
||||
}
|
||||
|
||||
message GetEntitiesResponse {
|
||||
|
|
@ -437,6 +449,14 @@ message EntityDefinition {
|
|||
string title = 1;
|
||||
repeated Entity instances = 2;
|
||||
repeated string used_on_dashboards = 3;
|
||||
string icon = 4;
|
||||
repeated EntityProperty properties = 5;
|
||||
int32 total_instances = 6;
|
||||
}
|
||||
|
||||
message EntityProperty {
|
||||
string name = 1;
|
||||
string title = 2;
|
||||
}
|
||||
|
||||
message GetEntityRequest {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,289 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Scan project Makefiles for development tools and report PATH availability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
FAIL_GROUPS = frozenset({
|
||||
"Core build and test",
|
||||
"Go codestyle (install via: make go-tools)",
|
||||
"Protocol buffers (install via: make -C service go-tools-all)",
|
||||
})
|
||||
|
||||
SKIP_DIRS = frozenset({
|
||||
".git",
|
||||
"node_modules",
|
||||
"vendor",
|
||||
"dist",
|
||||
"reports",
|
||||
"webui",
|
||||
})
|
||||
|
||||
SKIP_COMMANDS = frozenset({
|
||||
".", ":", "[", "bash", "case", "cat", "cd", "chmod", "chown", "cp", "curl",
|
||||
"do", "done", "echo", "elif", "else", "esac", "exit", "false", "fi", "for",
|
||||
"fuser", "grep", "head", "if", "kill", "killall", "lsof", "make", "mkdir",
|
||||
"mv", "objdump", "pwd", "rm", "sed", "set", "sh", "sleep", "tail", "test",
|
||||
"then", "touch", "trap", "true", "unzip", "while",
|
||||
})
|
||||
|
||||
SKIP_PREFIXES = ("./", "../", "-", "$(")
|
||||
|
||||
GO_INSTALL_RE = re.compile(r"""go\s+install\s+(?:["'])([^"']+)(?:["'])""")
|
||||
|
||||
TOOL_GROUPS: list[tuple[str, frozenset[str]]] = [
|
||||
(
|
||||
"Core build and test",
|
||||
frozenset({"go", "npm", "npx", "node", "python3"}),
|
||||
),
|
||||
(
|
||||
"Go codestyle (install via: make go-tools)",
|
||||
frozenset({"gocyclo", "gocritic"}),
|
||||
),
|
||||
(
|
||||
"Protocol buffers (install via: make -C service go-tools-all)",
|
||||
frozenset({"buf", "protoc-gen-go"}),
|
||||
),
|
||||
(
|
||||
"Containers and packaging (optional)",
|
||||
frozenset({"buildah", "docker", "podman", "podman-compose"}),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class Color:
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
GREEN = "\033[32m"
|
||||
RED = "\033[31m"
|
||||
ORANGE = "\033[38;5;208m"
|
||||
DIM = "\033[2m"
|
||||
|
||||
@classmethod
|
||||
def ok(cls, text: str) -> str:
|
||||
return f"{cls.BOLD}{cls.GREEN}{text}{cls.RESET}"
|
||||
|
||||
@classmethod
|
||||
def fail(cls, text: str) -> str:
|
||||
return f"{cls.BOLD}{cls.RED}{text}{cls.RESET}"
|
||||
|
||||
@classmethod
|
||||
def warn(cls, text: str) -> str:
|
||||
return f"{cls.BOLD}{cls.ORANGE}{text}{cls.RESET}"
|
||||
|
||||
@classmethod
|
||||
def dim(cls, text: str) -> str:
|
||||
return f"{cls.DIM}{text}{cls.RESET}"
|
||||
|
||||
|
||||
def colors_enabled() -> bool:
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if "--no-color" in sys.argv:
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
|
||||
def paint_ok(text: str) -> str:
|
||||
return Color.ok(text) if colors_enabled() else text
|
||||
|
||||
|
||||
def paint_fail(text: str) -> str:
|
||||
return Color.fail(text) if colors_enabled() else text
|
||||
|
||||
|
||||
def paint_warn(text: str) -> str:
|
||||
return Color.warn(text) if colors_enabled() else text
|
||||
|
||||
|
||||
def paint_dim(text: str) -> str:
|
||||
return Color.dim(text) if colors_enabled() else text
|
||||
|
||||
|
||||
def find_makefiles(root: Path) -> list[Path]:
|
||||
makefiles: list[Path] = []
|
||||
for path in root.rglob("Makefile"):
|
||||
rel_parts = path.relative_to(root).parts
|
||||
if any(part.startswith(".") or part in SKIP_DIRS for part in rel_parts):
|
||||
continue
|
||||
makefiles.append(path)
|
||||
return sorted(makefiles)
|
||||
|
||||
|
||||
def tool_from_go_install(package: str) -> str:
|
||||
return package.rstrip("/").rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def first_command_token(line: str) -> str | None:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
return None
|
||||
|
||||
if line.startswith("$(call") or line.startswith("$(MAKE)"):
|
||||
return None
|
||||
|
||||
for token in line.split():
|
||||
if "=" in token and not token.startswith("./"):
|
||||
continue
|
||||
if token.startswith(SKIP_PREFIXES):
|
||||
return None
|
||||
return token.strip("\"'")
|
||||
return None
|
||||
|
||||
|
||||
def collect_tools(makefiles: list[Path]) -> dict[str, set[str]]:
|
||||
tools: dict[str, set[str]] = defaultdict(set)
|
||||
|
||||
for makefile in makefiles:
|
||||
rel = makefile.relative_to(ROOT).as_posix()
|
||||
try:
|
||||
lines = makefile.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for line in lines:
|
||||
if not line.startswith("\t"):
|
||||
continue
|
||||
|
||||
recipe = line.lstrip("\t@")
|
||||
for package in GO_INSTALL_RE.findall(recipe):
|
||||
tools[tool_from_go_install(package)].add(rel)
|
||||
|
||||
if "python -c" in recipe or recipe.startswith("python "):
|
||||
tools["python3"].add(rel)
|
||||
|
||||
command = first_command_token(recipe)
|
||||
if command is None:
|
||||
continue
|
||||
|
||||
command = command.lower()
|
||||
if command in SKIP_COMMANDS or command == "python":
|
||||
continue
|
||||
if "/" in command and command not in {"podman-compose"}:
|
||||
continue
|
||||
|
||||
tools[command].add(rel)
|
||||
|
||||
return dict(tools)
|
||||
|
||||
|
||||
def resolve_python() -> tuple[bool, str | None]:
|
||||
for name in ("python3", "python"):
|
||||
path = shutil.which(name)
|
||||
if path is not None:
|
||||
return True, path
|
||||
return False, None
|
||||
|
||||
|
||||
def resolve_tool(name: str) -> tuple[bool, str | None]:
|
||||
if name == "python3":
|
||||
return resolve_python()
|
||||
path = shutil.which(name)
|
||||
return path is not None, path
|
||||
|
||||
|
||||
def group_tools(tools: dict[str, set[str]]) -> list[tuple[str, list[str]]]:
|
||||
grouped: list[tuple[str, list[str]]] = []
|
||||
assigned: set[str] = set()
|
||||
|
||||
for title, members in TOOL_GROUPS:
|
||||
present = sorted(tool for tool in tools if tool in members)
|
||||
if present:
|
||||
grouped.append((title, present))
|
||||
assigned.update(present)
|
||||
|
||||
remaining = sorted(tool for tool in tools if tool not in assigned)
|
||||
if remaining:
|
||||
grouped.append(("Other tools found in Makefiles", remaining))
|
||||
|
||||
return grouped
|
||||
|
||||
|
||||
def format_status(found: bool, path: str | None, *, required: bool) -> str:
|
||||
if found:
|
||||
return paint_ok(f"OK {path}")
|
||||
|
||||
if required:
|
||||
return paint_fail("MISSING")
|
||||
return paint_warn("MISSING")
|
||||
|
||||
|
||||
def print_group(
|
||||
title: str,
|
||||
tool_names: list[str],
|
||||
tools: dict[str, set[str]],
|
||||
verbose: bool,
|
||||
) -> tuple[int, int, list[str]]:
|
||||
required = title in FAIL_GROUPS
|
||||
print(title)
|
||||
available = 0
|
||||
missing: list[str] = []
|
||||
|
||||
for name in tool_names:
|
||||
found, path = resolve_tool(name)
|
||||
available += int(found)
|
||||
if not found:
|
||||
missing.append(name)
|
||||
print(f" {name:<16} {format_status(found, path, required=required)}")
|
||||
if verbose:
|
||||
for source in sorted(tools[name]):
|
||||
print(paint_dim(f" referenced in {source}"))
|
||||
print()
|
||||
return available, len(tool_names), missing
|
||||
|
||||
|
||||
def main() -> int:
|
||||
verbose = "--verbose" in sys.argv or "-v" in sys.argv
|
||||
makefiles = find_makefiles(ROOT)
|
||||
tools = collect_tools(makefiles)
|
||||
|
||||
print("Development environment check")
|
||||
print(f"Scanned {len(makefiles)} Makefile(s) under {ROOT}")
|
||||
print()
|
||||
|
||||
total_available = 0
|
||||
total_checked = 0
|
||||
required_missing: list[str] = []
|
||||
optional_missing: list[str] = []
|
||||
|
||||
for title, tool_names in group_tools(tools):
|
||||
available, checked, missing = print_group(title, tool_names, tools, verbose)
|
||||
total_available += available
|
||||
total_checked += checked
|
||||
|
||||
if title in FAIL_GROUPS:
|
||||
required_missing.extend(missing)
|
||||
else:
|
||||
optional_missing.extend(missing)
|
||||
|
||||
summary = f"Summary: {total_available}/{total_checked} tools available on PATH"
|
||||
if required_missing:
|
||||
print(paint_fail(summary))
|
||||
elif optional_missing:
|
||||
print(paint_warn(summary))
|
||||
else:
|
||||
print(paint_ok(summary))
|
||||
|
||||
if required_missing:
|
||||
print()
|
||||
print(paint_fail("Missing required tools: " + ", ".join(required_missing)))
|
||||
print(paint_dim("Install the missing tools for your platform, then re-run: make devcheck"))
|
||||
return 1
|
||||
|
||||
if optional_missing:
|
||||
print()
|
||||
print(paint_warn("Missing optional tools: " + ", ".join(optional_missing)))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,9 +4,14 @@ import (
|
|||
ctx "context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
|
@ -16,11 +21,6 @@ import (
|
|||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
acl "github.com/OliveTin/OliveTin/internal/acl"
|
||||
auth "github.com/OliveTin/OliveTin/internal/auth"
|
||||
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
||||
|
|
@ -156,15 +156,17 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.
|
|||
}
|
||||
|
||||
authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
|
||||
if err := validateJustificationRequired(pair.Action, req.Msg.Justification, authenticatedUser); err != nil {
|
||||
args := startActionArgumentsFromProto(req.Msg.Arguments)
|
||||
justification := resolveStartJustification(pair.Action, pair, req.Msg.Justification, args)
|
||||
if err := validateJustificationRequired(pair.Action, justification, authenticatedUser); err != nil {
|
||||
return nil, connectInvalidJustification(err)
|
||||
}
|
||||
|
||||
execReq := executor.ExecutionRequest{
|
||||
Binding: pair,
|
||||
TrackingID: req.Msg.UniqueTrackingId,
|
||||
Arguments: startActionArgumentsFromProto(req.Msg.Arguments),
|
||||
Justification: req.Msg.Justification,
|
||||
Arguments: args,
|
||||
Justification: justification,
|
||||
AuthenticatedUser: authenticatedUser,
|
||||
Cfg: api.cfg,
|
||||
}
|
||||
|
|
@ -280,6 +282,21 @@ func (api *oliveTinAPI) findBindingByIDOrNotFound(bindingId string) (*executor.A
|
|||
return api.findBindingOrNotFound(bindingId)
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) startActionAndWaitLogEntry(binding *executor.ActionBinding, args map[string]string, justification string, user *authpublic.AuthenticatedUser) (*apiv1.LogEntry, error) {
|
||||
internalLogEntry, ok := api.startActionAndWaitRun(binding, args, justification, user)
|
||||
if !ok {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
|
||||
}
|
||||
return api.logEntryForAllowedViewer(internalLogEntry, user)
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) logEntryForAllowedViewer(internalLogEntry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) (*apiv1.LogEntry, error) {
|
||||
if err := api.requireLogEntryAllowed(internalLogEntry, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return api.internalLogEntryToPb(internalLogEntry, user), nil
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
|
||||
binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
|
||||
if err != nil {
|
||||
|
|
@ -287,16 +304,18 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request
|
|||
}
|
||||
|
||||
user := auth.UserFromApiCall(ctx, req, api.cfg)
|
||||
if err := validateJustificationRequired(binding.Action, req.Msg.Justification, user); err != nil {
|
||||
args := startActionArgumentsFromProto(req.Msg.Arguments)
|
||||
justification := resolveStartJustification(binding.Action, binding, req.Msg.Justification, args)
|
||||
if err := validateJustificationRequired(binding.Action, justification, user); err != nil {
|
||||
return nil, connectInvalidJustification(err)
|
||||
}
|
||||
|
||||
internalLogEntry, ok := api.startActionAndWaitRun(binding, startActionArgumentsFromProto(req.Msg.Arguments), req.Msg.Justification, user)
|
||||
if !ok {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
|
||||
logEntry, err := api.startActionAndWaitLogEntry(binding, args, justification, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return connect.NewResponse(&apiv1.StartActionAndWaitResponse{
|
||||
LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
|
||||
LogEntry: logEntry,
|
||||
}), nil
|
||||
}
|
||||
|
||||
|
|
@ -323,16 +342,7 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a
|
|||
}), nil
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
|
||||
binding := api.executor.FindBindingByID(req.Msg.ActionId)
|
||||
if binding == nil || binding.Action == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
|
||||
}
|
||||
|
||||
args := make(map[string]string)
|
||||
|
||||
user := auth.UserFromApiCall(ctx, req, api.cfg)
|
||||
|
||||
func (api *oliveTinAPI) runBindingAndWait(binding *executor.ActionBinding, args map[string]string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) {
|
||||
execReq := executor.ExecutionRequest{
|
||||
Binding: binding,
|
||||
TrackingID: uuid.NewString(),
|
||||
|
|
@ -344,15 +354,32 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Re
|
|||
wg, _ := api.executor.ExecRequest(&execReq)
|
||||
wg.Wait()
|
||||
|
||||
internalLogEntry, ok := api.executor.GetLog(execReq.TrackingID)
|
||||
|
||||
if ok {
|
||||
return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{
|
||||
LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
|
||||
}), nil
|
||||
return api.executor.GetLog(execReq.TrackingID)
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) startActionByGetAndWaitLogEntry(binding *executor.ActionBinding, user *authpublic.AuthenticatedUser) (*apiv1.LogEntry, error) {
|
||||
internalLogEntry, ok := api.runBindingAndWait(binding, map[string]string{}, user)
|
||||
if !ok {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
|
||||
}
|
||||
return api.logEntryForAllowedViewer(internalLogEntry, user)
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
|
||||
binding := api.executor.FindBindingByID(req.Msg.ActionId)
|
||||
if binding == nil || binding.Action == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
|
||||
}
|
||||
|
||||
user := auth.UserFromApiCall(ctx, req, api.cfg)
|
||||
logEntry, err := api.startActionByGetAndWaitLogEntry(binding, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{
|
||||
LogEntry: logEntry,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func calculateRateLimitExpires(api *oliveTinAPI, logEntry *executor.InternalLogEntry) string {
|
||||
if logEntry.Binding == nil || logEntry.Binding.Action == nil {
|
||||
|
|
@ -680,6 +707,13 @@ func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *au
|
|||
return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action)
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) requireLogEntryAllowed(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) error {
|
||||
if api.isLogEntryAllowed(entry, user) {
|
||||
return nil
|
||||
}
|
||||
return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied to view this execution"))
|
||||
}
|
||||
|
||||
// mayViewExecutionEvent returns whether the user is allowed to receive this execution event (for EventStream ACL).
|
||||
func (api *oliveTinAPI) mayViewExecutionEvent(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
|
||||
if user == nil {
|
||||
|
|
@ -1373,21 +1407,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
|
|||
}
|
||||
|
||||
entityMap := entities.GetEntities()
|
||||
entityNames := make([]string, 0, len(entityMap))
|
||||
for name := range entityMap {
|
||||
entityNames = append(entityNames, name)
|
||||
}
|
||||
sort.Strings(entityNames)
|
||||
|
||||
entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames))
|
||||
for _, name := range entityNames {
|
||||
def := &apiv1.EntityDefinition{
|
||||
Title: name,
|
||||
UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
|
||||
Instances: buildSortedEntityInstances(name, entityMap[name]),
|
||||
}
|
||||
entityDefinitions = append(entityDefinitions, def)
|
||||
}
|
||||
entityDefinitions := api.buildEntityDefinitionsResponse(req.Msg, entityMap)
|
||||
|
||||
res := &apiv1.GetEntitiesResponse{
|
||||
EntityDefinitions: entityDefinitions,
|
||||
|
|
@ -1396,7 +1416,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
|
|||
return connect.NewResponse(res), nil
|
||||
}
|
||||
|
||||
func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity) []*apiv1.Entity {
|
||||
func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity, properties []config.EntityProperty) []*apiv1.Entity {
|
||||
instanceKeys := make([]string, 0, len(entityInstances))
|
||||
for key := range entityInstances {
|
||||
instanceKeys = append(instanceKeys, key)
|
||||
|
|
@ -1410,6 +1430,7 @@ func buildSortedEntityInstances(entityType string, entityInstances map[string]*e
|
|||
Title: e.Title,
|
||||
UniqueKey: e.UniqueKey,
|
||||
Type: entityType,
|
||||
Fields: entityListFields(e.Data, properties),
|
||||
})
|
||||
}
|
||||
return instances
|
||||
|
|
@ -1511,17 +1532,100 @@ func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.Ge
|
|||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
|
||||
}
|
||||
|
||||
res := buildEntityResponse(entity, req.Msg.Type, api.cfg.Dashboards)
|
||||
res := buildEntityResponse(entity, req.Msg.Type, api.cfg)
|
||||
res.RelatedActions = api.relatedActionsForEntity(user, req.Msg.Type, entity)
|
||||
return connect.NewResponse(res), nil
|
||||
}
|
||||
|
||||
func buildEntityResponse(entity *entities.Entity, entityType string, dashboards []*config.DashboardComponent) *apiv1.Entity {
|
||||
func entityTypeIcon(cfg *config.Config, entityType string) string {
|
||||
entityFile := entityFileForType(cfg, entityType)
|
||||
if entityFile == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return entityFile.Icon
|
||||
}
|
||||
|
||||
func entityFileForType(cfg *config.Config, entityType string) *config.EntityFile {
|
||||
for _, entityFile := range cfg.Entities {
|
||||
if entityFile != nil && entityFile.Name == entityType {
|
||||
return entityFile
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func entityPropertiesFromFile(entityFile *config.EntityFile) []config.EntityProperty {
|
||||
if entityFile == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return entityFile.Properties
|
||||
}
|
||||
|
||||
func entityDefinitionProperties(properties []config.EntityProperty) []*apiv1.EntityProperty {
|
||||
if len(properties) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]*apiv1.EntityProperty, 0, len(properties))
|
||||
for _, property := range properties {
|
||||
result = append(result, &apiv1.EntityProperty{
|
||||
Name: property.Name,
|
||||
Title: property.Title,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func entityListFields(data any, properties []config.EntityProperty) map[string]string {
|
||||
if len(properties) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make(map[string]string, len(properties))
|
||||
for _, property := range properties {
|
||||
fields[property.Name] = entityPropertyValue(data, property.Name)
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func entityPropertyValue(data any, propertyName string) string {
|
||||
dataMap, ok := data.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
if value, found := dataMap[propertyName]; found {
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
|
||||
return entityPropertyValueCaseInsensitive(dataMap, propertyName)
|
||||
}
|
||||
|
||||
func entityPropertyValueCaseInsensitive(dataMap map[string]any, propertyName string) string {
|
||||
propertyNameLower := strings.ToLower(propertyName)
|
||||
for key, value := range dataMap {
|
||||
if strings.ToLower(key) == propertyNameLower {
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildEntityResponse(entity *entities.Entity, entityType string, cfg *config.Config) *apiv1.Entity {
|
||||
properties := entityPropertiesFromFile(entityFileForType(cfg, entityType))
|
||||
res := &apiv1.Entity{
|
||||
Title: entity.Title,
|
||||
UniqueKey: entity.UniqueKey,
|
||||
Type: entityType,
|
||||
Directories: findDirectoriesInEntityFieldsets(entityType, dashboards),
|
||||
Fields: serializeEntityFields(entity.Data),
|
||||
Directories: findDirectoriesInEntityFieldsets(entityType, cfg.Dashboards),
|
||||
Fields: entityFieldsForResponse(entity.Data, properties),
|
||||
Icon: entityTypeIcon(cfg, entityType),
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||
config "github.com/OliveTin/OliveTin/internal/config"
|
||||
"github.com/OliveTin/OliveTin/internal/entities"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEntityInstancesPageSize = 10
|
||||
maxEntityInstancesPageSize = 100
|
||||
)
|
||||
|
||||
func (api *oliveTinAPI) buildEntityDefinitionsResponse(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
|
||||
if req != nil && req.EntityType != "" {
|
||||
return api.buildFilteredEntityDefinitions(req, entityMap)
|
||||
}
|
||||
|
||||
return api.buildAllEntityDefinitions(entityMap)
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) buildAllEntityDefinitions(entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
|
||||
entityNames := sortedEntityTypeNames(entityMap)
|
||||
entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames))
|
||||
|
||||
for _, name := range entityNames {
|
||||
entityFile := entityFileForType(api.cfg, name)
|
||||
properties := entityPropertiesFromFile(entityFile)
|
||||
instances := buildSortedEntityInstances(name, entityMap[name], properties)
|
||||
|
||||
def := &apiv1.EntityDefinition{
|
||||
Title: name,
|
||||
UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
|
||||
Icon: entityTypeIcon(api.cfg, name),
|
||||
Properties: entityDefinitionProperties(properties),
|
||||
TotalInstances: int32(len(instances)),
|
||||
}
|
||||
|
||||
if len(properties) == 0 {
|
||||
def.Instances = instances
|
||||
}
|
||||
|
||||
entityDefinitions = append(entityDefinitions, def)
|
||||
}
|
||||
|
||||
return entityDefinitions
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) buildFilteredEntityDefinitions(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
|
||||
entityInstances, ok := entityMap[req.EntityType]
|
||||
if !ok || len(entityInstances) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
entityFile := entityFileForType(api.cfg, req.EntityType)
|
||||
properties := entityPropertiesFromFile(entityFile)
|
||||
instances := buildSortedEntityInstances(req.EntityType, entityInstances, properties)
|
||||
filtered := filterEntityInstances(instances, req.Filter)
|
||||
pageSize := normalizeEntityInstancesPageSize(req.PageSize)
|
||||
page := normalizeEntityInstancesPage(req.Page)
|
||||
|
||||
def := &apiv1.EntityDefinition{
|
||||
Title: req.EntityType,
|
||||
UsedOnDashboards: findDashboardsForEntity(req.EntityType, api.cfg.Dashboards),
|
||||
Icon: entityTypeIcon(api.cfg, req.EntityType),
|
||||
Properties: entityDefinitionProperties(properties),
|
||||
TotalInstances: int32(len(filtered)),
|
||||
Instances: paginateEntityInstances(filtered, page, pageSize),
|
||||
}
|
||||
|
||||
return []*apiv1.EntityDefinition{def}
|
||||
}
|
||||
|
||||
func sortedEntityTypeNames(entityMap entities.EntitiesByClass) []string {
|
||||
entityNames := make([]string, 0, len(entityMap))
|
||||
for name := range entityMap {
|
||||
entityNames = append(entityNames, name)
|
||||
}
|
||||
sort.Strings(entityNames)
|
||||
return entityNames
|
||||
}
|
||||
|
||||
func normalizeEntityInstancesPage(page int32) int32 {
|
||||
if page < 1 {
|
||||
return 1
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func normalizeEntityInstancesPageSize(pageSize int32) int32 {
|
||||
if pageSize < 1 {
|
||||
return defaultEntityInstancesPageSize
|
||||
}
|
||||
if pageSize > maxEntityInstancesPageSize {
|
||||
return maxEntityInstancesPageSize
|
||||
}
|
||||
return pageSize
|
||||
}
|
||||
|
||||
func filterEntityInstances(instances []*apiv1.Entity, filter string) []*apiv1.Entity {
|
||||
filter = strings.TrimSpace(strings.ToLower(filter))
|
||||
if filter == "" {
|
||||
return instances
|
||||
}
|
||||
|
||||
filtered := make([]*apiv1.Entity, 0, len(instances))
|
||||
for _, instance := range instances {
|
||||
if entityInstanceMatchesFilter(instance, filter) {
|
||||
filtered = append(filtered, instance)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func entityInstanceMatchesFilter(instance *apiv1.Entity, filter string) bool {
|
||||
if strings.Contains(strings.ToLower(instance.Title), filter) {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, value := range instance.Fields {
|
||||
if strings.Contains(strings.ToLower(value), filter) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func paginateEntityInstances(instances []*apiv1.Entity, page, pageSize int32) []*apiv1.Entity {
|
||||
count := int64(len(instances))
|
||||
start := int64(page-1) * int64(pageSize)
|
||||
if start >= count {
|
||||
return []*apiv1.Entity{}
|
||||
}
|
||||
|
||||
end := start + int64(pageSize)
|
||||
if end > count {
|
||||
end = count
|
||||
}
|
||||
|
||||
return instances[int(start):int(end)]
|
||||
}
|
||||
|
||||
func entityFieldsForResponse(data any, properties []config.EntityProperty) map[string]string {
|
||||
if len(properties) > 0 {
|
||||
return entityListFields(data, properties)
|
||||
}
|
||||
|
||||
return serializeEntityFields(data)
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||
config "github.com/OliveTin/OliveTin/internal/config"
|
||||
"github.com/OliveTin/OliveTin/internal/entities"
|
||||
"github.com/OliveTin/OliveTin/internal/executor"
|
||||
)
|
||||
|
||||
func TestGetEntitiesPaginatesAndFiltersInstances(t *testing.T) {
|
||||
entities.ClearEntitiesOfType("server")
|
||||
entities.AddEntity("server", "0", map[string]any{"name": "alpha", "hostname": "alpha.example.com", "ip": "10.0.0.1"})
|
||||
entities.AddEntity("server", "1", map[string]any{"name": "beta", "hostname": "beta.example.com", "ip": "10.0.0.2"})
|
||||
entities.AddEntity("server", "2", map[string]any{"name": "gamma", "hostname": "gamma.example.com", "ip": "10.0.0.3"})
|
||||
t.Cleanup(func() {
|
||||
entities.ClearEntitiesOfType("server")
|
||||
})
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Entities = []*config.EntityFile{
|
||||
{
|
||||
Name: "server",
|
||||
Properties: []config.EntityProperty{
|
||||
{Name: "hostname", Title: "Hostname"},
|
||||
{Name: "ip", Title: "IP"},
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.Sanitize()
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||
defer ts.Close()
|
||||
|
||||
filteredResp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
|
||||
EntityType: "server",
|
||||
Filter: "beta",
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, filteredResp.Msg.EntityDefinitions, 1)
|
||||
assert.Equal(t, int32(1), filteredResp.Msg.EntityDefinitions[0].TotalInstances)
|
||||
require.Len(t, filteredResp.Msg.EntityDefinitions[0].Instances, 1)
|
||||
assert.Equal(t, "beta.example.com", filteredResp.Msg.EntityDefinitions[0].Instances[0].Fields["hostname"])
|
||||
|
||||
pagedResp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
|
||||
EntityType: "server",
|
||||
Page: 2,
|
||||
PageSize: 1,
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, pagedResp.Msg.EntityDefinitions, 1)
|
||||
assert.Equal(t, int32(3), pagedResp.Msg.EntityDefinitions[0].TotalInstances)
|
||||
require.Len(t, pagedResp.Msg.EntityDefinitions[0].Instances, 1)
|
||||
assert.Equal(t, "1", pagedResp.Msg.EntityDefinitions[0].Instances[0].UniqueKey)
|
||||
}
|
||||
|
||||
func TestGetEntitiesUnfilteredIncludesConfiguredProperties(t *testing.T) {
|
||||
entities.ClearEntitiesOfType("server")
|
||||
entities.AddEntity("server", "0", map[string]any{"name": "alpha", "hostname": "alpha.example.com", "ip": "10.0.0.1"})
|
||||
t.Cleanup(func() {
|
||||
entities.ClearEntitiesOfType("server")
|
||||
})
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Entities = []*config.EntityFile{
|
||||
{
|
||||
Name: "server",
|
||||
Properties: []config.EntityProperty{
|
||||
{Name: "hostname", Title: "Hostname"},
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.Sanitize()
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{}))
|
||||
require.NoError(t, err)
|
||||
|
||||
serverDef := findEntityDefinition(resp.Msg.EntityDefinitions, "server")
|
||||
require.NotNil(t, serverDef)
|
||||
require.Len(t, serverDef.Properties, 1)
|
||||
assert.Equal(t, "hostname", serverDef.Properties[0].Name)
|
||||
assert.Equal(t, int32(1), serverDef.TotalInstances)
|
||||
assert.Empty(t, serverDef.Instances)
|
||||
}
|
||||
|
||||
func TestPaginateEntityInstancesHandlesLargePageValues(t *testing.T) {
|
||||
instances := []*apiv1.Entity{
|
||||
{UniqueKey: "0"},
|
||||
{UniqueKey: "1"},
|
||||
}
|
||||
|
||||
assert.Empty(t, paginateEntityInstances(instances, 1<<30, 1))
|
||||
assert.Empty(t, paginateEntityInstances(instances, 2, 1<<30))
|
||||
assert.Equal(t, []*apiv1.Entity{{UniqueKey: "1"}}, paginateEntityInstances(instances, 2, 1))
|
||||
}
|
||||
|
||||
func findEntityDefinition(definitions []*apiv1.EntityDefinition, title string) *apiv1.EntityDefinition {
|
||||
for _, definition := range definitions {
|
||||
if definition.Title == title {
|
||||
return definition
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetEntityRestrictsFieldsToConfiguredProperties(t *testing.T) {
|
||||
entities.ClearEntitiesOfType("server")
|
||||
entities.AddEntity("server", "0", map[string]any{
|
||||
"name": "alpha",
|
||||
"hostname": "alpha.example.com",
|
||||
"ip": "10.0.0.1",
|
||||
"groups": []string{"admins"},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
entities.ClearEntitiesOfType("server")
|
||||
})
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Entities = []*config.EntityFile{
|
||||
{
|
||||
Name: "server",
|
||||
Properties: []config.EntityProperty{
|
||||
{Name: "hostname", Title: "Hostname"},
|
||||
{Name: "ip", Title: "IP"},
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.Sanitize()
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
|
||||
Type: "server",
|
||||
UniqueKey: "0",
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.Msg)
|
||||
|
||||
assert.Equal(t, "alpha.example.com", resp.Msg.Fields["hostname"])
|
||||
assert.Equal(t, "10.0.0.1", resp.Msg.Fields["ip"])
|
||||
assert.NotContains(t, resp.Msg.Fields, "groups")
|
||||
assert.NotContains(t, resp.Msg.Fields, "name")
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
||||
config "github.com/OliveTin/OliveTin/internal/config"
|
||||
"github.com/OliveTin/OliveTin/internal/entities"
|
||||
"github.com/OliveTin/OliveTin/internal/executor"
|
||||
"github.com/OliveTin/OliveTin/internal/tpl"
|
||||
)
|
||||
|
||||
type relatedActionCandidate struct {
|
||||
binding *executor.ActionBinding
|
||||
prefilled map[string]string
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) relatedActionsForEntity(user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity) []*apiv1.EntityRelatedAction {
|
||||
renderRequest := api.createDashboardRenderRequest(user, entityType, entity.UniqueKey)
|
||||
populateActiveBindingStates(renderRequest)
|
||||
|
||||
candidates := collectRelatedActionCandidates(api, user, entityType, entity)
|
||||
sortRelatedActionCandidates(candidates)
|
||||
|
||||
return buildEntityRelatedActions(candidates, renderRequest)
|
||||
}
|
||||
|
||||
func collectRelatedActionCandidates(api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity) []relatedActionCandidate {
|
||||
seen := make(map[string]bool)
|
||||
candidates := make([]relatedActionCandidate, 0)
|
||||
|
||||
api.executor.MapActionBindingsLock.RLock()
|
||||
defer api.executor.MapActionBindingsLock.RUnlock()
|
||||
|
||||
for _, binding := range api.executor.MapActionBindings {
|
||||
tryAppendRelatedCandidate(&candidates, seen, api, user, entityType, entity, binding)
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
func tryAppendRelatedCandidate(candidates *[]relatedActionCandidate, seen map[string]bool, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity, binding *executor.ActionBinding) {
|
||||
prefilled, ok := relatedPrefillForBinding(binding, entityType, entity)
|
||||
if !ok || !bindingViewableForRelated(seen, api, user, binding) {
|
||||
return
|
||||
}
|
||||
|
||||
seen[binding.ID] = true
|
||||
*candidates = append(*candidates, relatedActionCandidate{
|
||||
binding: binding,
|
||||
prefilled: prefilled,
|
||||
})
|
||||
}
|
||||
|
||||
func bindingViewableForRelated(seen map[string]bool, api *oliveTinAPI, user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) bool {
|
||||
return binding != nil && binding.Action != nil && !seen[binding.ID] && api.userCanViewAction(user, binding.Action)
|
||||
}
|
||||
|
||||
func relatedPrefillForBinding(binding *executor.ActionBinding, entityType string, entity *entities.Entity) (map[string]string, bool) {
|
||||
if isEntityBoundBindingFor(binding, entityType, entity) {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
return argumentEntityPrefill(binding, entityType, entity)
|
||||
}
|
||||
|
||||
func argumentEntityPrefill(binding *executor.ActionBinding, entityType string, entity *entities.Entity) (map[string]string, bool) {
|
||||
if binding == nil || binding.Entity != nil || binding.Action == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
prefilled := buildPrefilledArgumentsForEntity(binding.Action, entityType, entity)
|
||||
return prefilled, len(prefilled) > 0
|
||||
}
|
||||
|
||||
func isEntityBoundBindingFor(binding *executor.ActionBinding, entityType string, entity *entities.Entity) bool {
|
||||
if entity == nil || !bindingHasEntity(binding) {
|
||||
return false
|
||||
}
|
||||
|
||||
return binding.Action.Entity == entityType && binding.Entity.UniqueKey == entity.UniqueKey
|
||||
}
|
||||
|
||||
func bindingHasEntity(binding *executor.ActionBinding) bool {
|
||||
return binding != nil && binding.Entity != nil && binding.Action != nil
|
||||
}
|
||||
|
||||
func buildPrefilledArgumentsForEntity(action *config.Action, entityType string, entity *entities.Entity) map[string]string {
|
||||
prefilled := make(map[string]string)
|
||||
|
||||
for i := range action.Arguments {
|
||||
arg := &action.Arguments[i]
|
||||
if arg.Entity != entityType || len(arg.Choices) != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
prefilled[arg.Name] = tpl.ParseTemplateOfActionBeforeExec(arg.Choices[0].Value, entity)
|
||||
}
|
||||
|
||||
return prefilled
|
||||
}
|
||||
|
||||
func sortRelatedActionCandidates(candidates []relatedActionCandidate) {
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
if candidates[i].binding.ConfigOrder != candidates[j].binding.ConfigOrder {
|
||||
return candidates[i].binding.ConfigOrder < candidates[j].binding.ConfigOrder
|
||||
}
|
||||
|
||||
return candidates[i].binding.ID < candidates[j].binding.ID
|
||||
})
|
||||
}
|
||||
|
||||
func buildEntityRelatedActions(candidates []relatedActionCandidate, rr *DashboardRenderRequest) []*apiv1.EntityRelatedAction {
|
||||
result := make([]*apiv1.EntityRelatedAction, 0, len(candidates))
|
||||
|
||||
for _, candidate := range candidates {
|
||||
action := buildAction(candidate.binding, rr)
|
||||
if action == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, &apiv1.EntityRelatedAction{
|
||||
Action: action,
|
||||
PrefilledArguments: candidate.prefilled,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
||||
config "github.com/OliveTin/OliveTin/internal/config"
|
||||
"github.com/OliveTin/OliveTin/internal/entities"
|
||||
"github.com/OliveTin/OliveTin/internal/executor"
|
||||
)
|
||||
|
||||
func setupHostEntityTestData(t *testing.T) {
|
||||
t.Helper()
|
||||
entities.ClearEntitiesOfType("host")
|
||||
entities.AddEntity("host", "0", map[string]any{"name": "stuffbox", "hostname": "192.168.66.8"})
|
||||
entities.AddEntity("host", "1", map[string]any{"name": "lurker", "hostname": "192.168.66.1"})
|
||||
t.Cleanup(func() {
|
||||
entities.ClearEntitiesOfType("host")
|
||||
})
|
||||
}
|
||||
|
||||
func buildRelatedActionsTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) {
|
||||
t.Helper()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.DefaultPermissions.View = false
|
||||
cfg.DefaultPermissions.Exec = false
|
||||
|
||||
cfg.Actions = append(cfg.Actions,
|
||||
&config.Action{
|
||||
Title: "Secret Entity Action",
|
||||
Shell: "echo secret",
|
||||
Entity: "host",
|
||||
},
|
||||
&config.Action{
|
||||
Title: "Hidden Host Action",
|
||||
Shell: "echo hidden",
|
||||
Entity: "host",
|
||||
Hidden: true,
|
||||
},
|
||||
&config.Action{
|
||||
ID: "run_playbook",
|
||||
Title: "Run Automation Playbook",
|
||||
Shell: "host '{{ ansible_host }}'",
|
||||
Arguments: []config.ActionArgument{
|
||||
{
|
||||
Name: "ansible_host",
|
||||
Title: "Host",
|
||||
Entity: "host",
|
||||
Choices: []config.ActionArgumentChoice{
|
||||
{Title: "{{ host.name }} ({{ host.hostname }})", Value: "{{ host.hostname }}"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
&config.Action{
|
||||
Title: "Public Host Action",
|
||||
Shell: "echo public",
|
||||
Entity: "host",
|
||||
},
|
||||
)
|
||||
|
||||
cfg.AccessControlLists = append(cfg.AccessControlLists,
|
||||
&config.AccessControlList{
|
||||
Name: "restricted",
|
||||
MatchUsernames: []string{"low"},
|
||||
AddToEveryAction: true,
|
||||
Permissions: config.PermissionsList{View: false, Exec: false, Logs: false, Kill: false},
|
||||
},
|
||||
&config.AccessControlList{
|
||||
Name: "full",
|
||||
MatchUsernames: []string{"admin"},
|
||||
AddToEveryAction: true,
|
||||
Permissions: config.PermissionsList{View: true, Exec: true, Logs: true, Kill: true},
|
||||
},
|
||||
)
|
||||
|
||||
cfg.Entities = []*config.EntityFile{
|
||||
{File: "hosts.yaml", Name: "host", Icon: "ssh"},
|
||||
}
|
||||
cfg.Sanitize()
|
||||
|
||||
lowUser := &authpublic.AuthenticatedUser{Username: "low", Acls: []string{"restricted"}}
|
||||
adminUser := &authpublic.AuthenticatedUser{Username: "admin", Acls: []string{"full"}}
|
||||
|
||||
return cfg, lowUser, adminUser
|
||||
}
|
||||
|
||||
func getEntityRelatedActionTitles(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
|
||||
t.Helper()
|
||||
|
||||
entity, ok := entities.GetEntityInstances(entityType)[entityKey]
|
||||
require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
|
||||
|
||||
related := api.relatedActionsForEntity(user, entityType, entity)
|
||||
titles := make([]string, 0, len(related))
|
||||
for _, item := range related {
|
||||
if item.Action != nil {
|
||||
titles = append(titles, item.Action.Title)
|
||||
}
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
||||
func getEntityRelatedBindingIDs(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
|
||||
t.Helper()
|
||||
|
||||
entity, ok := entities.GetEntityInstances(entityType)[entityKey]
|
||||
require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
|
||||
|
||||
related := api.relatedActionsForEntity(user, entityType, entity)
|
||||
ids := make([]string, 0, len(related))
|
||||
for _, item := range related {
|
||||
if item.Action != nil {
|
||||
ids = append(ids, item.Action.BindingId)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestGetEntityRelatedActionsDeniesRestrictedView(t *testing.T) {
|
||||
setupHostEntityTestData(t)
|
||||
cfg, lowUser, _ := buildRelatedActionsTestConfig(t)
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
api := newServer(ex)
|
||||
|
||||
ids := getEntityRelatedBindingIDs(t, api, lowUser, "host", "0")
|
||||
assert.Empty(t, ids)
|
||||
|
||||
titles := getEntityRelatedActionTitles(t, api, lowUser, "host", "0")
|
||||
assert.Empty(t, titles)
|
||||
}
|
||||
|
||||
func TestGetEntityRelatedActionsAllowsAdminView(t *testing.T) {
|
||||
setupHostEntityTestData(t)
|
||||
cfg, _, adminUser := buildRelatedActionsTestConfig(t)
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
api := newServer(ex)
|
||||
|
||||
titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
|
||||
assert.Contains(t, titles, "Run Automation Playbook")
|
||||
assert.Contains(t, titles, "Public Host Action")
|
||||
assert.Contains(t, titles, "Secret Entity Action")
|
||||
}
|
||||
|
||||
func TestGetEntityRelatedActionsExcludesHiddenActions(t *testing.T) {
|
||||
setupHostEntityTestData(t)
|
||||
cfg, _, adminUser := buildRelatedActionsTestConfig(t)
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
api := newServer(ex)
|
||||
|
||||
titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
|
||||
assert.NotContains(t, titles, "Hidden Host Action")
|
||||
}
|
||||
|
||||
func TestGetEntityRelatedActionsEntityBoundMatchesInstanceOnly(t *testing.T) {
|
||||
setupHostEntityTestData(t)
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Actions = append(cfg.Actions, &config.Action{
|
||||
Title: "{{ host.name }} Wake",
|
||||
Shell: "echo wake",
|
||||
Entity: "host",
|
||||
})
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
api := newServer(ex)
|
||||
user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
|
||||
|
||||
titlesHost0 := getEntityRelatedActionTitles(t, api, user, "host", "0")
|
||||
assert.Len(t, titlesHost0, 1)
|
||||
assert.Equal(t, "stuffbox Wake", titlesHost0[0])
|
||||
|
||||
titlesHost1 := getEntityRelatedActionTitles(t, api, user, "host", "1")
|
||||
assert.Len(t, titlesHost1, 1)
|
||||
assert.Equal(t, "lurker Wake", titlesHost1[0])
|
||||
}
|
||||
|
||||
func TestGetEntityRelatedActionsPrefillsArgumentEntityValues(t *testing.T) {
|
||||
setupHostEntityTestData(t)
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Actions = append(cfg.Actions, &config.Action{
|
||||
ID: "run_playbook",
|
||||
Title: "Run Automation Playbook",
|
||||
Shell: "host '{{ ansible_host }}'",
|
||||
Arguments: []config.ActionArgument{
|
||||
{
|
||||
Name: "ansible_host",
|
||||
Title: "Host",
|
||||
Entity: "host",
|
||||
Choices: []config.ActionArgumentChoice{
|
||||
{Title: "{{ host.name }} ({{ host.hostname }})", Value: "{{ host.hostname }}"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
api := newServer(ex)
|
||||
user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
|
||||
|
||||
entity := entities.GetEntityInstances("host")["0"]
|
||||
related := api.relatedActionsForEntity(user, "host", entity)
|
||||
require.Len(t, related, 1)
|
||||
require.NotNil(t, related[0].Action)
|
||||
assert.Equal(t, "run_playbook", related[0].Action.BindingId)
|
||||
assert.Equal(t, "192.168.66.8", related[0].PrefilledArguments["ansible_host"])
|
||||
}
|
||||
|
||||
func TestGetEntityDeniesGuestsWhenLoginRequired(t *testing.T) {
|
||||
setupHostEntityTestData(t)
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.AuthRequireGuestsToLogin = true
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||
defer ts.Close()
|
||||
|
||||
_, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
|
||||
Type: "host",
|
||||
UniqueKey: "0",
|
||||
}))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
|
||||
}
|
||||
|
||||
func TestGetEntityReturnsRelatedActionsForAdmin(t *testing.T) {
|
||||
setupHostEntityTestData(t)
|
||||
cfg, _, adminUser := buildRelatedActionsTestConfig(t)
|
||||
cfg.AuthHttpHeaderUsername = "X-Ot-User"
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||
defer ts.Close()
|
||||
|
||||
req := connect.NewRequest(&apiv1.GetEntityRequest{
|
||||
Type: "host",
|
||||
UniqueKey: "0",
|
||||
})
|
||||
req.Header().Set("X-Ot-User", adminUser.Username)
|
||||
|
||||
resp, err := client.GetEntity(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.Msg)
|
||||
assert.Equal(t, "🔐", resp.Msg.Icon)
|
||||
|
||||
foundPlaybook := false
|
||||
for _, related := range resp.Msg.RelatedActions {
|
||||
if related.Action != nil && related.Action.BindingId == "run_playbook" {
|
||||
foundPlaybook = true
|
||||
assert.Equal(t, "192.168.66.8", related.PrefilledArguments["ansible_host"])
|
||||
}
|
||||
}
|
||||
assert.True(t, foundPlaybook, "admin should see argument-entity related action in GetEntity response")
|
||||
}
|
||||
|
|
@ -9,7 +9,10 @@ import (
|
|||
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
||||
"github.com/OliveTin/OliveTin/internal/config"
|
||||
"github.com/OliveTin/OliveTin/internal/entities"
|
||||
"github.com/OliveTin/OliveTin/internal/executor"
|
||||
"github.com/OliveTin/OliveTin/internal/tpl"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func validateJustificationRequired(action *config.Action, justification string, user *authpublic.AuthenticatedUser) error {
|
||||
|
|
@ -21,7 +24,7 @@ func validateJustificationRequired(action *config.Action, justification string,
|
|||
}
|
||||
|
||||
func actionRequiresJustificationConfig(action *config.Action) bool {
|
||||
return action != nil && action.Justification
|
||||
return action != nil && action.RequiresJustification()
|
||||
}
|
||||
|
||||
func justificationProvided(justification string, user *authpublic.AuthenticatedUser) bool {
|
||||
|
|
@ -40,6 +43,56 @@ func startActionArgumentsFromProto(args []*apiv1.StartActionArgument) map[string
|
|||
return result
|
||||
}
|
||||
|
||||
func resolveStartJustification(action *config.Action, binding *executor.ActionBinding, clientJustification string, args map[string]string) string {
|
||||
if strings.TrimSpace(clientJustification) != "" {
|
||||
return clientJustification
|
||||
}
|
||||
|
||||
return resolveJustificationFromTemplate(action, binding, clientJustification, args)
|
||||
}
|
||||
|
||||
func resolveJustificationFromTemplate(action *config.Action, binding *executor.ActionBinding, fallback string, args map[string]string) string {
|
||||
templateText := action.JustificationTemplateText()
|
||||
if templateText == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
resolved, err := tpl.ParseTemplateWithActionContext(templateText, bindingEntity(binding), args)
|
||||
if err != nil {
|
||||
log.WithFields(justificationTemplateErrorFields(templateText, binding, err)).Warn("Failed to resolve justification template")
|
||||
return fallback
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
func justificationTemplateErrorFields(templateText string, binding *executor.ActionBinding, err error) log.Fields {
|
||||
fields := log.Fields{
|
||||
"template": templateText,
|
||||
"error": err,
|
||||
}
|
||||
|
||||
entity := bindingEntity(binding)
|
||||
if entity == nil {
|
||||
return fields
|
||||
}
|
||||
|
||||
fields["entityKey"] = entity.UniqueKey
|
||||
if binding.Action != nil && binding.Action.Entity != "" {
|
||||
fields["entityType"] = binding.Action.Entity
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func bindingEntity(binding *executor.ActionBinding) *entities.Entity {
|
||||
if binding == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return binding.Entity
|
||||
}
|
||||
|
||||
func restartRequiresJustificationError() error {
|
||||
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("justification is required for this action; use StartAction with a justification instead"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package api
|
|||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -21,7 +20,7 @@ func TestStartActionRequiresJustificationForGuest(t *testing.T) {
|
|||
action := &config.Action{
|
||||
Title: "Send email",
|
||||
ID: "send_email",
|
||||
Justification: true,
|
||||
Justification: config.JustificationRequiredNoTemplate,
|
||||
Shell: "echo done",
|
||||
}
|
||||
cfg.Actions = append(cfg.Actions, action)
|
||||
|
|
@ -49,19 +48,15 @@ func TestStartActionRequiresJustificationForGuest(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.NotEmpty(t, resp.Msg.ExecutionTrackingId)
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
entry, ok := ex.GetLog(resp.Msg.ExecutionTrackingId)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "New user registration foo@example.com", entry.Justification)
|
||||
waitForLogJustification(t, ex, resp.Msg.ExecutionTrackingId, "New user registration foo@example.com")
|
||||
}
|
||||
|
||||
func TestBuildActionExposesJustificationFlag(t *testing.T) {
|
||||
func TestBuildActionExposesJustificationTemplate(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
action := &config.Action{
|
||||
Title: "Audited action",
|
||||
ID: "audited",
|
||||
Justification: true,
|
||||
Justification: "{{ target }}",
|
||||
Shell: "echo hi",
|
||||
}
|
||||
cfg.Actions = append(cfg.Actions, action)
|
||||
|
|
@ -77,12 +72,94 @@ func TestBuildActionExposesJustificationFlag(t *testing.T) {
|
|||
})
|
||||
|
||||
require.NotNil(t, pb)
|
||||
assert.True(t, pb.Justification)
|
||||
assert.Equal(t, "{{ target }}", pb.Justification)
|
||||
}
|
||||
|
||||
func TestBuildActionExposesBlankRequiredJustification(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
action := &config.Action{
|
||||
Title: "Audited action",
|
||||
ID: "audited",
|
||||
Justification: config.JustificationRequiredNoTemplate,
|
||||
Shell: "echo hi",
|
||||
}
|
||||
cfg.Actions = append(cfg.Actions, action)
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
binding := ex.FindBindingWithNoEntity(action)
|
||||
require.NotNil(t, binding)
|
||||
|
||||
pb := buildAction(binding, &DashboardRenderRequest{
|
||||
cfg: cfg,
|
||||
ex: ex,
|
||||
})
|
||||
|
||||
require.NotNil(t, pb)
|
||||
assert.Equal(t, config.JustificationRequiredNoTemplate, pb.Justification)
|
||||
}
|
||||
|
||||
func TestResolveStartJustificationUsesTemplateWhenClientValueEmpty(t *testing.T) {
|
||||
action := &config.Action{
|
||||
Justification: "{{ ansible_host }}",
|
||||
}
|
||||
binding := &executor.ActionBinding{}
|
||||
|
||||
got := resolveStartJustification(action, binding, "", map[string]string{
|
||||
"ansible_host": "192.168.66.8",
|
||||
})
|
||||
assert.Equal(t, "192.168.66.8", got)
|
||||
}
|
||||
|
||||
func TestResolveStartJustificationPrefersClientValue(t *testing.T) {
|
||||
action := &config.Action{
|
||||
Justification: "{{ ansible_host }}",
|
||||
}
|
||||
binding := &executor.ActionBinding{}
|
||||
|
||||
got := resolveStartJustification(action, binding, "manual reason", map[string]string{
|
||||
"ansible_host": "192.168.66.8",
|
||||
})
|
||||
assert.Equal(t, "manual reason", got)
|
||||
}
|
||||
|
||||
func TestStartActionResolvesJustificationTemplateForGuest(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
action := &config.Action{
|
||||
Title: "Run playbook",
|
||||
ID: "run_playbook",
|
||||
Justification: "{{ ansible_host }}",
|
||||
Shell: "echo done",
|
||||
Arguments: []config.ActionArgument{
|
||||
{Name: "ansible_host", Title: "Host"},
|
||||
},
|
||||
}
|
||||
cfg.Actions = append(cfg.Actions, action)
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
binding := ex.FindBindingWithNoEntity(action)
|
||||
require.NotNil(t, binding)
|
||||
|
||||
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
|
||||
BindingId: binding.ID,
|
||||
UniqueTrackingId: uuid.NewString(),
|
||||
Arguments: []*apiv1.StartActionArgument{
|
||||
{Name: "ansible_host", Value: "stuffbox"},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, resp.Msg.ExecutionTrackingId)
|
||||
|
||||
waitForLogJustification(t, ex, resp.Msg.ExecutionTrackingId, "stuffbox")
|
||||
}
|
||||
|
||||
func TestValidateJustificationRequiredAllowsSystemUser(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
action := &config.Action{Title: "Cron job", Justification: true}
|
||||
action := &config.Action{Title: "Cron job", Justification: config.JustificationRequiredNoTemplate}
|
||||
|
||||
err := validateJustificationRequired(action, "", auth.UserFromSystem(cfg, "cron"))
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ func restartArgumentsIncompleteError() error {
|
|||
}
|
||||
|
||||
func validateRestartLogEntry(entry *executor.InternalLogEntry) error {
|
||||
if entry.Binding.Action.Justification && strings.TrimSpace(entry.Justification) == "" {
|
||||
if entry.Binding.Action.RequiresJustification() && strings.TrimSpace(entry.Justification) == "" {
|
||||
return restartRequiresJustificationError()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,22 @@ func waitForLogArguments(t *testing.T, ex *executor.Executor, trackingID string)
|
|||
return nil
|
||||
}
|
||||
|
||||
func waitForLogFinished(t *testing.T, ex *executor.Executor, trackingID string) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
entry, ok := ex.GetLog(trackingID)
|
||||
if ok && entry.ExecutionFinished {
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("timed out waiting for execution to finish on log %s", trackingID)
|
||||
}
|
||||
|
||||
func waitForLogJustification(t *testing.T, ex *executor.Executor, trackingID, expected string) {
|
||||
t.Helper()
|
||||
|
||||
|
|
@ -166,6 +182,7 @@ func TestRestartActionReusesStoredArguments(t *testing.T) {
|
|||
|
||||
originalArgs := waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
|
||||
assert.Equal(t, "server-a", originalArgs["host"])
|
||||
waitForLogFinished(t, ex, startResp.Msg.ExecutionTrackingId)
|
||||
|
||||
restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
|
||||
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
|
||||
|
|
@ -286,7 +303,7 @@ func TestRestartActionRequiresJustificationWhenMissingFromStoredLog(t *testing.T
|
|||
Title: "Dangerous action",
|
||||
Shell: "echo ok",
|
||||
MaxConcurrent: 1,
|
||||
Justification: true,
|
||||
Justification: config.JustificationRequiredNoTemplate,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -319,7 +336,7 @@ func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T)
|
|||
Title: "Dangerous action",
|
||||
Shell: "echo ok",
|
||||
MaxConcurrent: 1,
|
||||
Justification: true,
|
||||
Justification: config.JustificationRequiredNoTemplate,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -338,6 +355,7 @@ func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T)
|
|||
require.NoError(t, err)
|
||||
|
||||
waitForLogJustification(t, ex, startResp.Msg.ExecutionTrackingId, "maintenance window")
|
||||
waitForLogFinished(t, ex, startResp.Msg.ExecutionTrackingId)
|
||||
|
||||
restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
|
||||
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
|
||||
|
|
|
|||
|
|
@ -120,6 +120,15 @@ func TestGetActionsAndStart(t *testing.T) {
|
|||
|
||||
func TestGetEntities(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Entities = []*config.EntityFile{
|
||||
{
|
||||
Name: "server",
|
||||
Properties: []config.EntityProperty{
|
||||
{Name: "hostname", Title: "Hostname"},
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.Sanitize()
|
||||
|
||||
ts, client := getNewTestServerAndClient(cfg)
|
||||
defer ts.Close()
|
||||
|
|
@ -138,6 +147,26 @@ func TestGetEntities(t *testing.T) {
|
|||
validateEntityOrderAndStructure(t, entityDefinitions)
|
||||
validateNoDuplicates(t, entityDefinitions)
|
||||
validateConsistency(t, client, entityDefinitions)
|
||||
validateEntityListProperties(t, client)
|
||||
}
|
||||
|
||||
func validateEntityListProperties(t *testing.T, client apiv1connect.OliveTinApiServiceClient) {
|
||||
resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
|
||||
EntityType: "server",
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Msg.EntityDefinitions, 1)
|
||||
|
||||
serverDef := resp.Msg.EntityDefinitions[0]
|
||||
require.NotNil(t, serverDef, "server entity definition should be present")
|
||||
require.Len(t, serverDef.Properties, 1)
|
||||
assert.Equal(t, "hostname", serverDef.Properties[0].Name)
|
||||
assert.Equal(t, "Hostname", serverDef.Properties[0].Title)
|
||||
assert.Equal(t, int32(3), serverDef.TotalInstances)
|
||||
require.Len(t, serverDef.Instances, 3)
|
||||
assert.Equal(t, "alpha.example.com", serverDef.Instances[0].Fields["hostname"])
|
||||
}
|
||||
|
||||
func setupTestEntities() {
|
||||
|
|
@ -166,10 +195,8 @@ func validateEntityOrderAndStructure(t *testing.T, entityDefinitions []*apiv1.En
|
|||
assert.Equal(t, "postgres", entityDefinitions[1].Instances[1].UniqueKey, "Second database instance should be 'postgres' (alphabetically second)")
|
||||
|
||||
assert.Equal(t, "server", entityDefinitions[2].Title, "Third entity should be 'server' (alphabetically third)")
|
||||
assert.Equal(t, 3, len(entityDefinitions[2].Instances), "Server should have 3 instances")
|
||||
assert.Equal(t, "alpha", entityDefinitions[2].Instances[0].UniqueKey, "First server instance should be 'alpha' (alphabetically first)")
|
||||
assert.Equal(t, "beta", entityDefinitions[2].Instances[1].UniqueKey, "Second server instance should be 'beta' (alphabetically second)")
|
||||
assert.Equal(t, "zebra", entityDefinitions[2].Instances[2].UniqueKey, "Third server instance should be 'zebra' (alphabetically third)")
|
||||
assert.Equal(t, 0, len(entityDefinitions[2].Instances), "Server instances should not be included in bulk list response")
|
||||
assert.Equal(t, int32(3), entityDefinitions[2].TotalInstances, "Server should report total instance count")
|
||||
}
|
||||
|
||||
func validateNoDuplicates(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) {
|
||||
|
|
@ -358,6 +385,54 @@ func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *Dashboard
|
|||
assert.Equal(t, expectedCanExec, actionResult.CanExec, message)
|
||||
}
|
||||
|
||||
// buildExecWithoutLogsTestConfig returns config for GHSA-jm28-2wcr-qf3h: user "runner" may exec but not read logs.
|
||||
func buildExecWithoutLogsTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser) {
|
||||
t.Helper()
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.AuthHttpHeaderUsername = "X-Ot-User"
|
||||
cfg.DefaultPermissions.View = false
|
||||
cfg.DefaultPermissions.Exec = false
|
||||
cfg.DefaultPermissions.Logs = false
|
||||
|
||||
cfg.Actions = append(cfg.Actions, &config.Action{
|
||||
ID: "run_only",
|
||||
Title: "Run Only",
|
||||
Shell: "echo sensitive-output",
|
||||
Icon: "🔒",
|
||||
})
|
||||
|
||||
cfg.AccessControlLists = append(cfg.AccessControlLists, &config.AccessControlList{
|
||||
Name: "runner",
|
||||
MatchUsernames: []string{"runner"},
|
||||
AddToEveryAction: true,
|
||||
Permissions: config.PermissionsList{View: true, Exec: true, Logs: false, Kill: false},
|
||||
})
|
||||
|
||||
runner := &authpublic.AuthenticatedUser{Username: "runner"}
|
||||
runner.BuildUserAcls(cfg)
|
||||
return cfg, runner
|
||||
}
|
||||
|
||||
// TestStartActionAndWaitDeniesLogsPermission (GHSA-jm28-2wcr-qf3h) asserts sync execution endpoints
|
||||
// enforce logs ACL and do not return action output to users allowed to exec but not read logs.
|
||||
func TestStartActionAndWaitDeniesLogsPermission(t *testing.T) {
|
||||
cfg, _ := buildExecWithoutLogsTestConfig(t)
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||
defer ts.Close()
|
||||
|
||||
req := connect.NewRequest(&apiv1.StartActionAndWaitRequest{
|
||||
ActionId: "run_only",
|
||||
})
|
||||
req.Header().Set("X-Ot-User", "runner")
|
||||
|
||||
_, err := client.StartActionAndWait(context.Background(), req)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err),
|
||||
"user with exec:true and logs:false must not receive log output from StartActionAndWait")
|
||||
}
|
||||
|
||||
// buildViewPermissionTestConfig returns config and users for GHSA view-permission tests:
|
||||
// one action "secret_action", ACL "restricted" (view:false, logs:false) for user "low", ACL "full" (view:true, logs:true) for user "admin".
|
||||
func buildViewPermissionTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) {
|
||||
|
|
@ -919,3 +994,26 @@ func TestBuildActionIncludesGroups(t *testing.T) {
|
|||
assert.Equal(t, "missing", actionResult.Groups[1].Name)
|
||||
assert.Equal(t, int32(0), actionResult.Groups[1].MaxConcurrent)
|
||||
}
|
||||
|
||||
func TestBuildChoicesExpandsChecklistEntityChoices(t *testing.T) {
|
||||
entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
|
||||
entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
|
||||
t.Cleanup(func() {
|
||||
entities.ClearEntitiesOfType("room")
|
||||
})
|
||||
|
||||
arg := config.ActionArgument{
|
||||
Type: "checklist",
|
||||
Entity: "room",
|
||||
Choices: []config.ActionArgumentChoice{
|
||||
{Title: "{{ room.hostname }}", Value: "{{ room.hostname }}"},
|
||||
},
|
||||
}
|
||||
|
||||
choices := buildChoices(arg)
|
||||
require.Len(t, choices, 2)
|
||||
assert.Equal(t, "attic", choices[0].Value)
|
||||
assert.Equal(t, "attic", choices[0].Title)
|
||||
assert.Equal(t, "basement", choices[1].Value)
|
||||
assert.Equal(t, "basement", choices[1].Title)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,8 +62,14 @@ type oauth2State struct {
|
|||
providerName string
|
||||
Username string
|
||||
Usergroup string
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
oauthStateMaxAge = 900 // matches olivetin-sid-oauth cookie MaxAge
|
||||
oauthStateMaxEntries = 10000
|
||||
)
|
||||
|
||||
func assignIfEmpty(target *string, value string) {
|
||||
if *target == "" {
|
||||
*target = value
|
||||
|
|
@ -129,6 +135,19 @@ func (h *OAuth2Handler) setOAuthCallbackCookie(w http.ResponseWriter, r *http.Re
|
|||
http.SetCookie(w, cookie)
|
||||
}
|
||||
|
||||
func (h *OAuth2Handler) deleteOAuthStateLocked(state string) {
|
||||
delete(h.registeredStates, state)
|
||||
}
|
||||
|
||||
func (h *OAuth2Handler) sweepExpiredOAuthStatesLocked(now time.Time) {
|
||||
cutoff := now.Add(-oauthStateMaxAge * time.Second)
|
||||
for state, entry := range h.registeredStates {
|
||||
if entry.createdAt.Before(cutoff) {
|
||||
delete(h.registeredStates, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := randString(16)
|
||||
|
||||
|
|
@ -147,10 +166,17 @@ func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request)
|
|||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.sweepExpiredOAuthStatesLocked(time.Now())
|
||||
if len(h.registeredStates) >= oauthStateMaxEntries {
|
||||
h.mu.Unlock()
|
||||
http.Error(w, "OAuth login temporarily unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
h.registeredStates[state] = &oauth2State{
|
||||
providerConfig: provider,
|
||||
providerName: providerName,
|
||||
Username: "",
|
||||
createdAt: time.Now(),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
|
|
@ -177,6 +203,9 @@ func (h *OAuth2Handler) checkOAuthCallbackCookie(w http.ResponseWriter, r *http.
|
|||
|
||||
if !h.validateStateMatch(r.URL.Query().Get("state"), state) {
|
||||
log.Errorf("State mismatch: %v != %v", r.URL.Query().Get("state"), state)
|
||||
h.mu.Lock()
|
||||
h.deleteOAuthStateLocked(state)
|
||||
h.mu.Unlock()
|
||||
http.Error(w, "State mismatch", http.StatusBadRequest)
|
||||
return nil, state, false
|
||||
}
|
||||
|
|
@ -186,6 +215,9 @@ func (h *OAuth2Handler) checkOAuthCallbackCookie(w http.ResponseWriter, r *http.
|
|||
h.mu.RUnlock()
|
||||
if !ok {
|
||||
log.Errorf("State not found in server: %v", state)
|
||||
h.mu.Lock()
|
||||
h.deleteOAuthStateLocked(state)
|
||||
h.mu.Unlock()
|
||||
http.Error(w, "State not found in server", http.StatusBadRequest)
|
||||
return nil, state, false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
package otoauth2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
config "github.com/OliveTin/OliveTin/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestSweepExpiredOAuthStatesLocked(t *testing.T) {
|
||||
h := &OAuth2Handler{
|
||||
registeredStates: make(map[string]*oauth2State),
|
||||
}
|
||||
|
||||
h.registeredStates["fresh"] = &oauth2State{
|
||||
providerName: "test",
|
||||
createdAt: time.Now(),
|
||||
}
|
||||
h.registeredStates["stale"] = &oauth2State{
|
||||
providerName: "test",
|
||||
createdAt: time.Now().Add(-2 * oauthStateMaxAge * time.Second),
|
||||
}
|
||||
|
||||
h.sweepExpiredOAuthStatesLocked(time.Now())
|
||||
|
||||
_, freshFound := h.registeredStates["fresh"]
|
||||
_, staleFound := h.registeredStates["stale"]
|
||||
assert.True(t, freshFound)
|
||||
assert.False(t, staleFound)
|
||||
}
|
||||
|
||||
func TestHandleOAuthLoginRejectsWhenStateMapFull(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.AuthOAuth2Providers = map[string]*config.OAuth2Provider{
|
||||
"test": {
|
||||
Name: "test",
|
||||
ClientID: "id",
|
||||
ClientSecret: "secret",
|
||||
AuthUrl: "https://example.com/auth",
|
||||
TokenUrl: "https://example.com/token",
|
||||
},
|
||||
}
|
||||
|
||||
h := NewOAuth2Handler(cfg)
|
||||
h.registeredStates = make(map[string]*oauth2State, oauthStateMaxEntries)
|
||||
for i := 0; i < oauthStateMaxEntries; i++ {
|
||||
h.registeredStates[strconv.Itoa(i)] = &oauth2State{
|
||||
providerConfig: &oauth2.Config{},
|
||||
providerName: "test",
|
||||
createdAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/oauth/login?provider=test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleOAuthLogin(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||
assert.Equal(t, oauthStateMaxEntries, len(h.registeredStates))
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseChecklistValue parses a checklist argument wire value.
|
||||
// Values must be JSON arrays, or a single choice without commas.
|
||||
func ParseChecklistValue(value string) ([]string, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
return parseJSONChecklistValue(trimmed)
|
||||
}
|
||||
|
||||
if strings.Contains(trimmed, ",") {
|
||||
return nil, fmt.Errorf("checklist value uses legacy comma-separated format; use a JSON array instead")
|
||||
}
|
||||
|
||||
return []string{trimmed}, nil
|
||||
}
|
||||
|
||||
func parseJSONChecklistValue(value string) ([]string, error) {
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(value), &values); err != nil {
|
||||
return nil, fmt.Errorf("invalid checklist JSON value: %w", err)
|
||||
}
|
||||
|
||||
for _, segment := range values {
|
||||
if strings.TrimSpace(segment) == "" {
|
||||
return nil, fmt.Errorf("checklist value contains an empty segment")
|
||||
}
|
||||
}
|
||||
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// FormatChecklistValue serializes selected checklist values for API transport.
|
||||
func FormatChecklistValue(values []string) (string, error) {
|
||||
if len(values) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encoding checklist value: %w", err)
|
||||
}
|
||||
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseChecklistValueJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
values, err := ParseChecklistValue(`["documents","photos"]`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"documents", "photos"}, values)
|
||||
|
||||
values, err = ParseChecklistValue(`["kitchen,bedroom","hallway"]`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"kitchen,bedroom", "hallway"}, values)
|
||||
}
|
||||
|
||||
func TestParseChecklistValueSingleValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
values, err := ParseChecklistValue("documents")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"documents"}, values)
|
||||
}
|
||||
|
||||
func TestParseChecklistValueRejectsLegacyCommaSeparated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ParseChecklistValue("documents, photos")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParseChecklistValueRejectsEmptyJSONSegment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ParseChecklistValue(`["documents","","photos"]`)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFormatChecklistValueJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
encoded, err := FormatChecklistValue([]string{"documents", "photos"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `["documents","photos"]`, encoded)
|
||||
|
||||
encoded, err = FormatChecklistValue([]string{"kitchen,bedroom"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `["kitchen,bedroom"]`, encoded)
|
||||
|
||||
encoded, err = FormatChecklistValue(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, encoded)
|
||||
}
|
||||
|
|
@ -7,6 +7,9 @@ import (
|
|||
// ReservedArgumentNamePrefix is reserved for OliveTin-injected system arguments.
|
||||
const ReservedArgumentNamePrefix = "ot_"
|
||||
|
||||
// JustificationRequiredNoTemplate requires a manual justification with no prefilled template.
|
||||
const JustificationRequiredNoTemplate = " "
|
||||
|
||||
// Action represents the core functionality of OliveTin - commands that show up
|
||||
// as buttons in the UI.
|
||||
type Action struct {
|
||||
|
|
@ -35,7 +38,23 @@ type Action struct {
|
|||
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
|
||||
EnabledExpression string `koanf:"enabledExpression"`
|
||||
Groups []string `koanf:"groups"`
|
||||
Justification bool `koanf:"justification"`
|
||||
Justification string `koanf:"justification"`
|
||||
}
|
||||
|
||||
func (action *Action) RequiresJustification() bool {
|
||||
return action != nil && action.Justification != ""
|
||||
}
|
||||
|
||||
func (action *Action) JustificationTemplateText() string {
|
||||
if !action.RequiresJustification() {
|
||||
return ""
|
||||
}
|
||||
|
||||
if action.Justification == JustificationRequiredNoTemplate {
|
||||
return ""
|
||||
}
|
||||
|
||||
return action.Justification
|
||||
}
|
||||
|
||||
// ActionGroup defines shared limits and metadata for a set of actions.
|
||||
|
|
@ -90,6 +109,13 @@ type EntityFile struct {
|
|||
File string `koanf:"file"`
|
||||
Name string `koanf:"name"`
|
||||
Icon string `koanf:"icon"`
|
||||
Properties []EntityProperty `koanf:"properties"`
|
||||
}
|
||||
|
||||
// EntityProperty defines a column shown when listing entity instances in the UI.
|
||||
type EntityProperty struct {
|
||||
Name string `koanf:"name"`
|
||||
Title string `koanf:"title"`
|
||||
}
|
||||
|
||||
// PermissionsList defines what users can do with an action.
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool {
|
|||
DecoderConfig: &mapstructure.DecoderConfig{
|
||||
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
||||
envDecodeHookFunc,
|
||||
justificationDecodeHookFunc,
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.TextUnmarshallerHookFunc(),
|
||||
),
|
||||
|
|
@ -259,6 +260,18 @@ func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
|
|||
|
||||
var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`)
|
||||
|
||||
func justificationDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) {
|
||||
if to.Kind() != reflect.String || from.Kind() != reflect.Bool {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
if data.(bool) {
|
||||
return JustificationRequiredNoTemplate, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) {
|
||||
log.Debugf("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data)
|
||||
if from.Kind() != reflect.String {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/knadh/koanf/parsers/yaml"
|
||||
"github.com/knadh/koanf/providers/rawbytes"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJustificationDecodeHookMigratesLegacyBooleanFalse(t *testing.T) {
|
||||
cfg := loadJustificationCompatConfig(t, `
|
||||
actions:
|
||||
- title: Legacy disabled
|
||||
shell: echo hi
|
||||
justification: false
|
||||
`)
|
||||
|
||||
require.Len(t, cfg.Actions, 1)
|
||||
assert.Empty(t, cfg.Actions[0].Justification)
|
||||
}
|
||||
|
||||
func TestJustificationDecodeHookMigratesLegacyBooleanTrue(t *testing.T) {
|
||||
cfg := loadJustificationCompatConfig(t, `
|
||||
actions:
|
||||
- title: Legacy required
|
||||
shell: echo hi
|
||||
justification: true
|
||||
`)
|
||||
|
||||
require.Len(t, cfg.Actions, 1)
|
||||
assert.Equal(t, JustificationRequiredNoTemplate, cfg.Actions[0].Justification)
|
||||
}
|
||||
|
||||
func TestSanitizeJustificationMigratesWeaklyTypedLegacyStrings(t *testing.T) {
|
||||
action := &Action{Justification: "false"}
|
||||
action.sanitizeJustification()
|
||||
assert.Empty(t, action.Justification)
|
||||
|
||||
action.Justification = "true"
|
||||
action.sanitizeJustification()
|
||||
assert.Equal(t, JustificationRequiredNoTemplate, action.Justification)
|
||||
}
|
||||
|
||||
func loadJustificationCompatConfig(t *testing.T, yamlBody string) *Config {
|
||||
t.Helper()
|
||||
|
||||
k := koanf.New(".")
|
||||
require.NoError(t, k.Load(rawbytes.Provider([]byte(yamlBody)), yaml.Parser()))
|
||||
|
||||
cfg := DefaultConfig()
|
||||
require.True(t, unmarshalRoot(k, cfg))
|
||||
cfg.Sanitize()
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
|
@ -30,10 +30,15 @@ func (cfg *Config) Sanitize() {
|
|||
|
||||
cfg.sanitizeActionGroups()
|
||||
cfg.sanitizeActionGroupReferences()
|
||||
cfg.sanitizeEntities()
|
||||
|
||||
if err := cfg.validateReservedActionArgumentNames(); err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
if err := cfg.validateChecklistChoiceValues(); err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) validateReservedActionArgumentNames() error {
|
||||
|
|
@ -60,6 +65,48 @@ func (action *Action) validateReservedArgumentNames() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) validateChecklistChoiceValues() error {
|
||||
for _, action := range cfg.Actions {
|
||||
if err := action.validateChecklistChoiceValues(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (action *Action) validateChecklistChoiceValues() error {
|
||||
if action == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, arg := range action.Arguments {
|
||||
if err := validateChecklistChoicesForArgument(action.Title, arg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateChecklistChoicesForArgument(actionTitle string, arg ActionArgument) error {
|
||||
if arg.Type != "checklist" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, choice := range arg.Choices {
|
||||
if strings.TrimSpace(choice.Value) == "" {
|
||||
return fmt.Errorf(
|
||||
`action %q argument %q choice value must not be empty`,
|
||||
actionTitle,
|
||||
arg.Name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) sanitizeDashboardsForInlineActions() {
|
||||
for _, dashboard := range cfg.Dashboards {
|
||||
cfg.sanitizeDashboardComponentForInlineActions(dashboard)
|
||||
|
|
@ -180,6 +227,7 @@ func (action *Action) sanitize(cfg *Config) {
|
|||
action.ID = getActionID(action)
|
||||
action.Icon = lookupHTMLIcon(action.Icon, cfg.DefaultIconForActions)
|
||||
migrateActionOnClick(action)
|
||||
action.sanitizeJustification()
|
||||
action.OnClick = sanitizeOnClick(action.OnClick, cfg)
|
||||
action.PopupOnStart = action.OnClick
|
||||
|
||||
|
|
@ -243,6 +291,25 @@ func (cfg *Config) sanitizeActionGroupReferences() {
|
|||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) sanitizeEntities() {
|
||||
for _, entityFile := range cfg.Entities {
|
||||
if entityFile == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
entityFile.Icon = lookupHTMLIcon(entityFile.Icon, "")
|
||||
sanitizeEntityProperties(entityFile)
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeEntityProperties(entityFile *EntityFile) {
|
||||
for idx := range entityFile.Properties {
|
||||
if entityFile.Properties[idx].Title == "" {
|
||||
entityFile.Properties[idx].Title = entityFile.Properties[idx].Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) warnInvalidActionGroupReference(action *Action, groupName string) {
|
||||
group, found := cfg.ActionGroups[groupName]
|
||||
if !found {
|
||||
|
|
@ -402,6 +469,15 @@ func migrateActionOnClick(action *Action) {
|
|||
}
|
||||
}
|
||||
|
||||
func (action *Action) sanitizeJustification() {
|
||||
switch action.Justification {
|
||||
case "false":
|
||||
action.Justification = ""
|
||||
case "true":
|
||||
action.Justification = JustificationRequiredNoTemplate
|
||||
}
|
||||
}
|
||||
|
||||
func shouldMigrateDefaultOnClickFromPopup(onClick, popupOnStart string) bool {
|
||||
if popupOnStart == "" {
|
||||
return false
|
||||
|
|
@ -441,10 +517,39 @@ func (arg *ActionArgument) sanitize() {
|
|||
}
|
||||
|
||||
arg.sanitizeNoType()
|
||||
arg.sanitizeChecklist()
|
||||
|
||||
// Default value validation runs in executor at config load (validateArgumentDefaults).
|
||||
}
|
||||
|
||||
func (arg *ActionArgument) sanitizeChecklist() {
|
||||
if arg.Type != "checklist" {
|
||||
return
|
||||
}
|
||||
|
||||
arg.warnMissingChecklistChoices()
|
||||
arg.warnInvalidChecklistEntityTemplate()
|
||||
}
|
||||
|
||||
func (arg *ActionArgument) warnMissingChecklistChoices() {
|
||||
if len(arg.Choices) == 0 {
|
||||
log.WithFields(log.Fields{
|
||||
"arg": arg.Name,
|
||||
}).Warn("Checklist argument has no choices defined")
|
||||
}
|
||||
}
|
||||
|
||||
func (arg *ActionArgument) warnInvalidChecklistEntityTemplate() {
|
||||
if arg.Entity == "" || len(arg.Choices) == 1 {
|
||||
return
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"arg": arg.Name,
|
||||
"entity": arg.Entity,
|
||||
}).Warn("Checklist argument with entity should define exactly one choice template")
|
||||
}
|
||||
|
||||
func (arg *ActionArgument) sanitizeNoType() {
|
||||
if len(arg.Choices) == 0 && arg.Type == "" {
|
||||
log.WithFields(log.Fields{
|
||||
|
|
|
|||
|
|
@ -271,3 +271,25 @@ func TestValidateUniqueLocalUserAPIKeys(t *testing.T) {
|
|||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateChecklistChoiceValuesAllowsCommas(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := DefaultConfig()
|
||||
c.Actions = append(c.Actions, &Action{
|
||||
Title: "Checklist commas",
|
||||
Shell: "true",
|
||||
Arguments: []ActionArgument{
|
||||
{
|
||||
Name: "segments",
|
||||
Type: "checklist",
|
||||
Choices: []ActionArgumentChoice{
|
||||
{Value: "kitchen,bedroom"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := c.validateChecklistChoiceValues()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,10 @@ func typecheckActionArgumentFound(value string, arg *config.ActionArgument) erro
|
|||
return typecheckNull(arg)
|
||||
}
|
||||
|
||||
if arg.Type == "checklist" {
|
||||
return typecheckChecklist(value, arg)
|
||||
}
|
||||
|
||||
if len(arg.Choices) > 0 {
|
||||
return typecheckChoice(value, arg)
|
||||
}
|
||||
|
|
@ -211,6 +215,8 @@ func TypeSafetyCheck(name string, value string, argumentType string) error {
|
|||
return nil
|
||||
case "checkbox":
|
||||
return nil
|
||||
case "checklist":
|
||||
return nil
|
||||
case "email":
|
||||
return typeSafetyCheckEmail(value)
|
||||
case "url":
|
||||
|
|
@ -230,6 +236,37 @@ func typecheckNull(arg *config.ActionArgument) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func typecheckChecklist(value string, arg *config.ActionArgument) error {
|
||||
if len(arg.Choices) == 0 {
|
||||
return fmt.Errorf("checklist argument %q requires choices", arg.Name)
|
||||
}
|
||||
|
||||
segments, err := config.ParseChecklistValue(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return typecheckChecklistSegments(segments, arg)
|
||||
}
|
||||
|
||||
func typecheckChecklistSegments(segments []string, arg *config.ActionArgument) error {
|
||||
for _, segment := range segments {
|
||||
if err := typecheckChecklistSegment(segment, arg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func typecheckChecklistSegment(segment string, arg *config.ActionArgument) error {
|
||||
if segment == "" {
|
||||
return fmt.Errorf("checklist argument %q contains an empty segment", arg.Name)
|
||||
}
|
||||
|
||||
return typecheckChoice(segment, arg)
|
||||
}
|
||||
|
||||
func typecheckChoice(value string, arg *config.ActionArgument) error {
|
||||
if arg.Entity != "" {
|
||||
return typecheckChoiceEntity(value, arg)
|
||||
|
|
@ -277,11 +314,17 @@ func typeSafetyCheckDatetime(value string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func anchorCustomRegexPattern(pattern string) string {
|
||||
return "^(?:" + pattern + ")$"
|
||||
}
|
||||
|
||||
func typeSafetyCheckRegex(name string, value string, argumentType string) error {
|
||||
pattern := ""
|
||||
isCustomRegex := strings.HasPrefix(argumentType, "regex:")
|
||||
|
||||
if strings.HasPrefix(argumentType, "regex:") {
|
||||
pattern = strings.Replace(argumentType, "regex:", "", 1)
|
||||
if isCustomRegex {
|
||||
pattern = strings.TrimPrefix(argumentType, "regex:")
|
||||
pattern = anchorCustomRegexPattern(pattern)
|
||||
} else {
|
||||
found := false
|
||||
pattern, found = typecheckRegex[argumentType]
|
||||
|
|
@ -308,21 +351,50 @@ func typeSafetyCheckRegex(name string, value string, argumentType string) error
|
|||
}
|
||||
|
||||
func typeSafetyCheckUrl(value string) error {
|
||||
_, err := url.ParseRequestURI(value)
|
||||
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return fmt.Errorf("url scheme %q is not allowed; only http and https are permitted", parsed.Scheme)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var shellUnsafeArgumentTypes = map[string]struct{}{
|
||||
"url": {},
|
||||
"email": {},
|
||||
"raw_string_multiline": {},
|
||||
"very_dangerous_raw_string": {},
|
||||
"password": {},
|
||||
"html": {},
|
||||
"confirmation": {},
|
||||
}
|
||||
|
||||
func isUnsafeShellArgumentType(arg *config.ActionArgument) bool {
|
||||
if strings.HasPrefix(arg.Type, "regex:") {
|
||||
return true
|
||||
}
|
||||
|
||||
_, inMap := shellUnsafeArgumentTypes[arg.Type]
|
||||
return inMap || (arg.Type == "checkbox" && len(arg.Choices) == 0)
|
||||
}
|
||||
|
||||
func checkShellArgumentSafety(action *config.Action) error {
|
||||
if action.Shell == "" {
|
||||
return nil
|
||||
}
|
||||
unsafe := map[string]struct{}{"url": {}, "email": {}, "raw_string_multiline": {}, "very_dangerous_raw_string": {}, "password": {}}
|
||||
for _, arg := range action.Arguments {
|
||||
if _, bad := unsafe[arg.Type]; bad {
|
||||
|
||||
for i := range action.Arguments {
|
||||
arg := &action.Arguments[i]
|
||||
if isUnsafeShellArgumentType(arg) {
|
||||
return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution. Use 'exec' instead. See https://docs.olivetin.app/action_execution/shellvsexec.html", arg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -333,6 +405,7 @@ func mangleInvalidArgumentValues(req *ExecutionRequest) {
|
|||
}
|
||||
|
||||
mangleCheckboxValues(req, &arg)
|
||||
mangleChecklistValues(req, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -389,16 +462,21 @@ func MangleArgumentValue(arg *config.ActionArgument, value string, actionTitle s
|
|||
return value
|
||||
}
|
||||
|
||||
if arg.Type == "datetime" {
|
||||
return mangleArgumentValueByType(arg, value, actionTitle)
|
||||
}
|
||||
|
||||
func mangleArgumentValueByType(arg *config.ActionArgument, value string, actionTitle string) string {
|
||||
switch arg.Type {
|
||||
case "datetime":
|
||||
return mangleDatetimeValue(arg, value, actionTitle)
|
||||
}
|
||||
|
||||
if arg.Type == "checkbox" {
|
||||
case "checkbox":
|
||||
return mangleCheckboxValue(arg, value, actionTitle)
|
||||
}
|
||||
|
||||
case "checklist":
|
||||
return mangleChecklistValue(arg, value, actionTitle)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func mangleDatetimeValue(arg *config.ActionArgument, value string, actionTitle string) string {
|
||||
if arg == nil {
|
||||
|
|
@ -430,6 +508,96 @@ func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle s
|
|||
return value
|
||||
}
|
||||
|
||||
return mangleChoiceSegment(arg, value, actionTitle)
|
||||
}
|
||||
|
||||
func mangleChecklistValues(req *ExecutionRequest, arg *config.ActionArgument) {
|
||||
if arg.Type != "checklist" {
|
||||
return
|
||||
}
|
||||
|
||||
value, exists := req.Arguments[arg.Name]
|
||||
if !exists || value == "" {
|
||||
return
|
||||
}
|
||||
|
||||
req.Arguments[arg.Name] = mangleChecklistValue(arg, value, req.Binding.Action.Title)
|
||||
}
|
||||
|
||||
func mangleChecklistValue(arg *config.ActionArgument, value string, actionTitle string) string {
|
||||
if arg == nil || value == "" {
|
||||
return value
|
||||
}
|
||||
|
||||
segments, err := config.ParseChecklistValue(value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
|
||||
return mangleChecklistSegments(arg, segments, value, actionTitle)
|
||||
}
|
||||
|
||||
func mangleChecklistSegments(arg *config.ActionArgument, segments []string, fallback string, actionTitle string) string {
|
||||
mangled := make([]string, len(segments))
|
||||
for i, segment := range segments {
|
||||
mangled[i] = mangleChecklistSegment(arg, segment, actionTitle)
|
||||
}
|
||||
|
||||
formatted, err := config.FormatChecklistValue(mangled)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
func mangleChecklistSegment(arg *config.ActionArgument, segment string, actionTitle string) string {
|
||||
trimmed := strings.TrimSpace(segment)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return mangleChoiceSegment(arg, trimmed, actionTitle)
|
||||
}
|
||||
|
||||
func mangleChoiceSegment(arg *config.ActionArgument, value string, actionTitle string) string {
|
||||
if mapped, ok := mangleChoiceSegmentEntity(arg, value, actionTitle); ok {
|
||||
return mapped
|
||||
}
|
||||
|
||||
return mangleChoiceSegmentStatic(arg, value, actionTitle)
|
||||
}
|
||||
|
||||
func mangleChoiceSegmentEntity(arg *config.ActionArgument, value string, actionTitle string) (string, bool) {
|
||||
if arg.Entity == "" || len(arg.Choices) == 0 {
|
||||
return value, false
|
||||
}
|
||||
|
||||
return mangleEntityTemplateChoiceSegment(arg.Choices[0], arg.Entity, arg.Name, value, actionTitle)
|
||||
}
|
||||
|
||||
func mangleEntityTemplateChoiceSegment(templateChoice config.ActionArgumentChoice, entityName string, argName string, value string, actionTitle string) (string, bool) {
|
||||
for _, ent := range entities.GetEntityInstancesOrdered(entityName) {
|
||||
expandedTitle := tpl.ParseTemplateOfActionBeforeExec(templateChoice.Title, ent)
|
||||
if value != expandedTitle {
|
||||
continue
|
||||
}
|
||||
|
||||
expandedValue := tpl.ParseTemplateOfActionBeforeExec(templateChoice.Value, ent)
|
||||
log.WithFields(log.Fields{
|
||||
"arg": argName,
|
||||
"oldValue": value,
|
||||
"newValue": expandedValue,
|
||||
"actionTitle": actionTitle,
|
||||
}).Infof("Mangled entity choice segment")
|
||||
|
||||
return expandedValue, true
|
||||
}
|
||||
|
||||
return value, false
|
||||
}
|
||||
|
||||
func mangleChoiceSegmentStatic(arg *config.ActionArgument, value string, actionTitle string) string {
|
||||
for _, choice := range arg.Choices {
|
||||
if value == choice.Title {
|
||||
log.WithFields(log.Fields{
|
||||
|
|
@ -437,7 +605,7 @@ func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle s
|
|||
"oldValue": value,
|
||||
"newValue": choice.Value,
|
||||
"actionTitle": actionTitle,
|
||||
}).Infof("Mangled checkbox value")
|
||||
}).Infof("Mangled choice segment")
|
||||
|
||||
return choice.Value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,169 @@ func TestValidateArgumentCheckboxWithChoices(t *testing.T) {
|
|||
assert.NotNil(t, err, "Expected unknown checkbox title to be rejected against choices")
|
||||
}
|
||||
|
||||
func checklistTestArg() config.ActionArgument {
|
||||
return config.ActionArgument{
|
||||
Name: "directories",
|
||||
Type: "checklist",
|
||||
Choices: []config.ActionArgumentChoice{
|
||||
{Title: "Documents", Value: "documents"},
|
||||
{Title: "Photos", Value: "photos"},
|
||||
{Title: "Music", Value: "music"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateArgumentChecklistSelections(t *testing.T) {
|
||||
log.SetLevel(log.PanicLevel)
|
||||
|
||||
arg := checklistTestArg()
|
||||
action := config.Action{Title: "Test checklist"}
|
||||
|
||||
err := ValidateArgument(&arg, "documents", &action)
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = ValidateArgument(&arg, `["documents","photos"]`, &action)
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = ValidateArgument(&arg, `["documents","unknown"]`, &action)
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
func TestValidateArgumentChecklistTitleMangling(t *testing.T) {
|
||||
log.SetLevel(log.PanicLevel)
|
||||
|
||||
arg := checklistTestArg()
|
||||
action := config.Action{Title: "Test checklist title mangling"}
|
||||
|
||||
err := ValidateArgument(&arg, `["Documents","Photos"]`, &action)
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestValidateArgumentChecklistEmptySelection(t *testing.T) {
|
||||
log.SetLevel(log.PanicLevel)
|
||||
|
||||
arg := checklistTestArg()
|
||||
action := config.Action{Title: "Test checklist empty"}
|
||||
|
||||
err := ValidateArgument(&arg, "", &action)
|
||||
assert.Nil(t, err)
|
||||
|
||||
arg.RejectNull = true
|
||||
err = ValidateArgument(&arg, "", &action)
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
func TestValidateArgumentChecklistWithoutChoices(t *testing.T) {
|
||||
log.SetLevel(log.PanicLevel)
|
||||
|
||||
arg := config.ActionArgument{
|
||||
Name: "directories",
|
||||
Type: "checklist",
|
||||
}
|
||||
action := config.Action{Title: "Test checklist without choices"}
|
||||
|
||||
err := ValidateArgument(&arg, "documents", &action)
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
func TestValidateArgumentChecklistRejectsEmptySegment(t *testing.T) {
|
||||
log.SetLevel(log.PanicLevel)
|
||||
|
||||
arg := checklistTestArg()
|
||||
action := config.Action{Title: "Test checklist empty segment"}
|
||||
|
||||
err := ValidateArgument(&arg, `["documents","","photos"]`, &action)
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
func TestMangleArgumentValueChecklist(t *testing.T) {
|
||||
log.SetLevel(log.PanicLevel)
|
||||
|
||||
arg := checklistTestArg()
|
||||
|
||||
out := MangleArgumentValue(&arg, `["Documents","Music"]`, "Test action")
|
||||
assert.Equal(t, `["documents","music"]`, out)
|
||||
|
||||
out = MangleArgumentValue(&arg, `["documents","photos"]`, "Test action")
|
||||
assert.Equal(t, `["documents","photos"]`, out)
|
||||
}
|
||||
|
||||
func checklistEntityTestArg() config.ActionArgument {
|
||||
return config.ActionArgument{
|
||||
Name: "rooms",
|
||||
Type: "checklist",
|
||||
Entity: "room",
|
||||
Choices: []config.ActionArgumentChoice{
|
||||
{Title: "{{ room.hostname }}", Value: "{{ room.hostname }}"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateArgumentChecklistEntitySelections(t *testing.T) {
|
||||
log.SetLevel(log.PanicLevel)
|
||||
|
||||
entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
|
||||
entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
|
||||
|
||||
arg := checklistEntityTestArg()
|
||||
action := config.Action{Title: "Test checklist entity"}
|
||||
|
||||
err := ValidateArgument(&arg, "attic", &action)
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = ValidateArgument(&arg, `["attic","basement"]`, &action)
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = ValidateArgument(&arg, `["attic","unknown"]`, &action)
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
func TestMangleArgumentValueChecklistEntityTitles(t *testing.T) {
|
||||
log.SetLevel(log.PanicLevel)
|
||||
|
||||
entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
|
||||
entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
|
||||
|
||||
arg := config.ActionArgument{
|
||||
Name: "rooms",
|
||||
Type: "checklist",
|
||||
Entity: "room",
|
||||
Choices: []config.ActionArgumentChoice{
|
||||
{Title: "{{ room.hostname }} room", Value: "{{ room.hostname }}"},
|
||||
},
|
||||
}
|
||||
|
||||
out := MangleArgumentValue(&arg, `["attic room","basement room"]`, "Test checklist entity titles")
|
||||
assert.Equal(t, `["attic","basement"]`, out)
|
||||
}
|
||||
|
||||
func TestParseActionArgumentsChecklistEmptySelection(t *testing.T) {
|
||||
req := newExecRequest()
|
||||
req.Binding.Action = &config.Action{
|
||||
Title: "Test checklist empty selection",
|
||||
Shell: "echo 'Selected segments: {{ segments }}'",
|
||||
Arguments: []config.ActionArgument{
|
||||
{
|
||||
Name: "segments",
|
||||
Type: "checklist",
|
||||
Choices: []config.ActionArgumentChoice{
|
||||
{Value: "kitchen"},
|
||||
{Value: "bedroom"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
req.Arguments = map[string]string{
|
||||
"segments": "",
|
||||
}
|
||||
|
||||
mangleInvalidArgumentValues(req)
|
||||
out, err := parseActionArguments(req)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "echo 'Selected segments: '", out)
|
||||
}
|
||||
|
||||
func newExecRequest() *ExecutionRequest {
|
||||
return &ExecutionRequest{
|
||||
Arguments: make(map[string]string),
|
||||
|
|
@ -336,13 +499,72 @@ func TestCheckShellArgumentSafetyWithPasswordAndExec(t *testing.T) {
|
|||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestCheckShellArgumentSafetyWithHTML(t *testing.T) {
|
||||
a1 := config.Action{
|
||||
Title: "HTML shell",
|
||||
Shell: "echo {{ body }}",
|
||||
Arguments: []config.ActionArgument{
|
||||
{Name: "body", Type: "html"},
|
||||
},
|
||||
}
|
||||
|
||||
err := checkShellArgumentSafety(&a1)
|
||||
assert.NotNil(t, err)
|
||||
assert.Contains(t, err.Error(), "unsafe argument type 'html'")
|
||||
}
|
||||
|
||||
func TestCheckShellArgumentSafetyWithConfirmation(t *testing.T) {
|
||||
a1 := config.Action{
|
||||
Title: "Confirm shell",
|
||||
Shell: "echo ok",
|
||||
Arguments: []config.ActionArgument{
|
||||
{Name: "agree", Type: "confirmation"},
|
||||
},
|
||||
}
|
||||
|
||||
err := checkShellArgumentSafety(&a1)
|
||||
assert.NotNil(t, err)
|
||||
assert.Contains(t, err.Error(), "unsafe argument type 'confirmation'")
|
||||
}
|
||||
|
||||
func TestCheckShellArgumentSafetyWithChoicelessCheckbox(t *testing.T) {
|
||||
a1 := config.Action{
|
||||
Title: "Checkbox shell",
|
||||
Shell: "echo {{ flag }}",
|
||||
Arguments: []config.ActionArgument{
|
||||
{Name: "flag", Type: "checkbox"},
|
||||
},
|
||||
}
|
||||
|
||||
err := checkShellArgumentSafety(&a1)
|
||||
assert.NotNil(t, err)
|
||||
assert.Contains(t, err.Error(), "unsafe argument type 'checkbox'")
|
||||
}
|
||||
|
||||
func TestCheckShellArgumentSafetyWithCustomRegex(t *testing.T) {
|
||||
a1 := config.Action{
|
||||
Title: "Regex shell",
|
||||
Shell: "curl {{ host }}",
|
||||
Arguments: []config.ActionArgument{
|
||||
{Name: "host", Type: "regex:[a-zA-Z0-9.-]+"},
|
||||
},
|
||||
}
|
||||
|
||||
err := checkShellArgumentSafety(&a1)
|
||||
assert.NotNil(t, err)
|
||||
assert.Contains(t, err.Error(), "unsafe argument type 'regex:[a-zA-Z0-9.-]+'")
|
||||
}
|
||||
|
||||
func TestTypeSafetyCheckUrl(t *testing.T) {
|
||||
assert.Nil(t, TypeSafetyCheck("test1", "http://google.com", "url"), "Test URL: google.com")
|
||||
assert.Nil(t, TypeSafetyCheck("test2", "http://technowax.net:80?foo=bar", "url"), "Test URL: technowax.net with query arguments")
|
||||
assert.Nil(t, TypeSafetyCheck("test3", "http://localhost:80?foo=bar", "url"), "Test URL: localhost with query arguments")
|
||||
assert.Nil(t, TypeSafetyCheck("test7", "https://example.com/path", "url"), "Test URL: https scheme")
|
||||
assert.NotNil(t, TypeSafetyCheck("test4", "http://lo host:80", "url"), "Test a badly formed URL")
|
||||
assert.NotNil(t, TypeSafetyCheck("test5", "12345", "url"), "Test a badly formed URL")
|
||||
assert.NotNil(t, TypeSafetyCheck("test6", "_!23;", "url"), "Test a badly formed URL")
|
||||
assert.NotNil(t, TypeSafetyCheck("test8", "file:///etc/passwd", "url"), "file:// scheme must be rejected")
|
||||
assert.NotNil(t, TypeSafetyCheck("test9", "gopher://example.com", "url"), "gopher:// scheme must be rejected")
|
||||
}
|
||||
|
||||
func TestTypeSafetyCheckRegex(t *testing.T) {
|
||||
|
|
@ -367,6 +589,20 @@ func TestTypeSafetyCheckRegex(t *testing.T) {
|
|||
value: "James1234",
|
||||
hasError: true,
|
||||
},
|
||||
{
|
||||
name: "GHSA-gvxq - reject partial regex match",
|
||||
field: "host",
|
||||
pattern: "regex:[a-zA-Z0-9.-]+",
|
||||
value: "example.com; id",
|
||||
hasError: true,
|
||||
},
|
||||
{
|
||||
name: "reject alternation bypass when pattern looks anchored",
|
||||
field: "host",
|
||||
pattern: "regex:^safe$|bad",
|
||||
value: "xxxbad",
|
||||
hasError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
|
|||
|
|
@ -1238,24 +1238,70 @@ func stepExecAfter(req *ExecutionRequest) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout, stderr *bytes.Buffer) (*exec.Cmd, map[string]string, error) {
|
||||
func shellAfterCompletedAction(req *ExecutionRequest) (*config.Action, bool) {
|
||||
if req == nil {
|
||||
return nil, false
|
||||
}
|
||||
if !hasBindingAndAction(req) {
|
||||
return nil, false
|
||||
}
|
||||
if req.Binding.Action.ShellAfterCompleted == "" {
|
||||
return nil, false
|
||||
}
|
||||
return req.Binding.Action, true
|
||||
}
|
||||
|
||||
func substituteShellAfterCompletedEnvRefs(command string) string {
|
||||
replacements := []struct{ old, new string }{
|
||||
{"{{ output }}", `"$OUTPUT"`},
|
||||
{"{{output}}", `"$OUTPUT"`},
|
||||
{"{{ exitCode }}", `"$EXITCODE"`},
|
||||
{"{{exitCode}}", `"$EXITCODE"`},
|
||||
{"{{ exitCode}}", `"$EXITCODE"`},
|
||||
{"{{exitCode }}", `"$EXITCODE"`},
|
||||
}
|
||||
|
||||
for _, replacement := range replacements {
|
||||
command = strings.ReplaceAll(command, replacement.old, replacement.new)
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func parseShellAfterCompletedCommand(req *ExecutionRequest, commandTemplate string, args map[string]string) (string, error) {
|
||||
finalParsedCommand, err := tpl.ParseTemplateWithActionContext(commandTemplate, req.Binding.Entity, args)
|
||||
if err != nil {
|
||||
msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n"
|
||||
req.mutateLogEntry(func(entry *InternalLogEntry) {
|
||||
entry.Output += msg
|
||||
})
|
||||
log.Warn(msg)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return finalParsedCommand, nil
|
||||
}
|
||||
|
||||
//gocyclo:ignore
|
||||
func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout, stderr *bytes.Buffer) (*exec.Cmd, map[string]string, error) {
|
||||
action, ok := shellAfterCompletedAction(req)
|
||||
if !ok {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
if hasWebhookTag(req) {
|
||||
return nil, nil, fmt.Errorf("webhooks cannot use shellAfterCompleted; use exec without after-completion shell instead. See https://docs.olivetin.app/action_execution/shellvsexec.html")
|
||||
}
|
||||
|
||||
args, err := buildShellAfterArgs(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
finalParsedCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.ShellAfterCompleted, req.Binding.Entity, args)
|
||||
commandTemplate := substituteShellAfterCompletedEnvRefs(action.ShellAfterCompleted)
|
||||
finalParsedCommand, err := parseShellAfterCompletedCommand(req, commandTemplate, args)
|
||||
if err != nil {
|
||||
msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n"
|
||||
req.mutateLogEntry(func(entry *InternalLogEntry) {
|
||||
entry.Output += msg
|
||||
})
|
||||
log.Warn(msg)
|
||||
return nil, nil, nil
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
cmd := wrapCommandInShell(ctx, finalParsedCommand)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -385,6 +387,67 @@ func TestWebhookAllowsExecExecution(t *testing.T) {
|
|||
assert.Contains(t, req.logEntry.Output, "hello")
|
||||
}
|
||||
|
||||
func TestWebhookRejectsShellAfterCompleted(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
e := DefaultExecutor(cfg)
|
||||
a1 := &config.Action{
|
||||
Title: "Webhook After Shell Reject",
|
||||
Exec: []string{"echo", "{{ msg }}"},
|
||||
ShellAfterCompleted: "echo after",
|
||||
Arguments: []config.ActionArgument{
|
||||
{Name: "msg", Type: "ascii"},
|
||||
},
|
||||
}
|
||||
cfg.Actions = append(cfg.Actions, a1)
|
||||
cfg.Sanitize()
|
||||
e.RebuildActionMap()
|
||||
|
||||
req := ExecutionRequest{
|
||||
Tags: []string{"webhook"},
|
||||
AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"),
|
||||
Cfg: cfg,
|
||||
Arguments: map[string]string{"msg": "hello"},
|
||||
Binding: e.FindBindingWithNoEntity(a1),
|
||||
}
|
||||
|
||||
wg, _ := e.ExecRequest(&req)
|
||||
wg.Wait()
|
||||
|
||||
assert.NotNil(t, req.logEntry)
|
||||
assert.Contains(t, req.logEntry.Output, "webhooks cannot use shellAfterCompleted")
|
||||
}
|
||||
|
||||
func TestShellAfterCompletedUsesOutputEnvSafely(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
e := DefaultExecutor(cfg)
|
||||
injectedPath := filepath.Join(t.TempDir(), "olivetin-injected")
|
||||
expectedMainOutput := "'; touch " + injectedPath + "; echo '"
|
||||
a1 := &config.Action{
|
||||
Title: "After completion escape",
|
||||
Shell: "printf %s \"" + expectedMainOutput + "\"",
|
||||
ShellAfterCompleted: "printf %s {{ output }}",
|
||||
}
|
||||
cfg.Actions = append(cfg.Actions, a1)
|
||||
cfg.Sanitize()
|
||||
e.RebuildActionMap()
|
||||
|
||||
req := ExecutionRequest{
|
||||
AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
|
||||
Cfg: cfg,
|
||||
Binding: e.FindBindingWithNoEntity(a1),
|
||||
}
|
||||
|
||||
wg, _ := e.ExecRequest(&req)
|
||||
wg.Wait()
|
||||
|
||||
assert.NotNil(t, req.logEntry)
|
||||
assert.Equal(t, int32(0), req.logEntry.ExitCode)
|
||||
assert.True(t, strings.HasPrefix(req.logEntry.Output, expectedMainOutput))
|
||||
assert.Contains(t, req.logEntry.Output, "OliveTin::shellAfterCompleted stdout\n"+expectedMainOutput)
|
||||
_, err := os.Stat(injectedPath)
|
||||
assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands from output")
|
||||
}
|
||||
|
||||
func TestFilterToDefinedArgumentsOnly(t *testing.T) {
|
||||
req := newExecRequest()
|
||||
req.Binding.Action = &config.Action{
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ func ResolveJustification(req *ExecutionRequest) string {
|
|||
}
|
||||
|
||||
func actionRequiresJustification(req *ExecutionRequest) bool {
|
||||
return req != nil && req.Binding != nil && req.Binding.Action != nil && req.Binding.Action.Justification
|
||||
return req != nil && req.Binding != nil && req.Binding.Action != nil && req.Binding.Action.RequiresJustification()
|
||||
}
|
||||
|
||||
func defaultJustificationForRequest(req *ExecutionRequest) string {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
|
||||
func TestResolveJustificationUsesProvidedValue(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
action := &config.Action{Title: "Send email", Justification: true, Shell: "echo hi"}
|
||||
action := &config.Action{Title: "Send email", Justification: config.JustificationRequiredNoTemplate, Shell: "echo hi"}
|
||||
cfg.Actions = append(cfg.Actions, action)
|
||||
ex := DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
|
|
@ -29,7 +29,7 @@ func TestResolveJustificationUsesProvidedValue(t *testing.T) {
|
|||
|
||||
func TestResolveJustificationCronDefault(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
action := &config.Action{Title: "Nightly backup", Justification: true, Shell: "echo hi"}
|
||||
action := &config.Action{Title: "Nightly backup", Justification: config.JustificationRequiredNoTemplate, Shell: "echo hi"}
|
||||
cfg.Actions = append(cfg.Actions, action)
|
||||
ex := DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
|
|
@ -45,7 +45,7 @@ func TestResolveJustificationCronDefault(t *testing.T) {
|
|||
|
||||
func TestResolveJustificationStartupDefault(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
action := &config.Action{Title: "Init", Justification: true, Shell: "echo hi"}
|
||||
action := &config.Action{Title: "Init", Justification: config.JustificationRequiredNoTemplate, Shell: "echo hi"}
|
||||
cfg.Actions = append(cfg.Actions, action)
|
||||
ex := DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
|
|
@ -61,7 +61,7 @@ func TestResolveJustificationStartupDefault(t *testing.T) {
|
|||
|
||||
func TestResolveJustificationWebhookDefault(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
action := &config.Action{Title: "Deploy", Justification: true, Exec: []string{"echo", "deploy"}}
|
||||
action := &config.Action{Title: "Deploy", Justification: config.JustificationRequiredNoTemplate, Exec: []string{"echo", "deploy"}}
|
||||
cfg.Actions = append(cfg.Actions, action)
|
||||
ex := DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
|
|
@ -95,7 +95,7 @@ func TestJustificationNotPassedToShellArgs(t *testing.T) {
|
|||
cfg := config.DefaultConfig()
|
||||
action := &config.Action{
|
||||
Title: "Echo",
|
||||
Justification: true,
|
||||
Justification: config.JustificationRequiredNoTemplate,
|
||||
Shell: "echo {{ message }}",
|
||||
Arguments: []config.ActionArgument{
|
||||
{Name: "message", Type: "ascii_sentence"},
|
||||
|
|
|
|||
|
|
@ -79,6 +79,29 @@ func TestStorableArgumentsFromRequestStoresMangledCheckboxValue(t *testing.T) {
|
|||
assert.Equal(t, "1", args["mode"])
|
||||
}
|
||||
|
||||
func TestStorableArgumentsFromRequestStoresMangledChecklistValue(t *testing.T) {
|
||||
req := newExecRequest()
|
||||
req.Binding.Action.Arguments = []config.ActionArgument{
|
||||
{
|
||||
Name: "directories",
|
||||
Type: "checklist",
|
||||
Choices: []config.ActionArgumentChoice{
|
||||
{Title: "Documents", Value: "documents"},
|
||||
{Title: "Photos", Value: "photos"},
|
||||
},
|
||||
},
|
||||
}
|
||||
req.Arguments = map[string]string{
|
||||
"directories": `["Documents","Photos"]`,
|
||||
}
|
||||
|
||||
mangleInvalidArgumentValues(req)
|
||||
args := storableArgumentsFromRequest(req)
|
||||
|
||||
require.Len(t, args, 1)
|
||||
assert.Equal(t, `["documents","photos"]`, args["directories"])
|
||||
}
|
||||
|
||||
func TestCopyStorableArgumentsToLogEntry(t *testing.T) {
|
||||
req := newExecRequest()
|
||||
req.logEntry = &InternalLogEntry{}
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ actions:
|
|||
# Using a path under the user's home is more natural on macOS.
|
||||
- title: Delete old backups
|
||||
icon: ashtonished
|
||||
justification: true
|
||||
justification: " "
|
||||
shell: rm -rf "$HOME/Backups/old/"
|
||||
arguments:
|
||||
- name: confirm
|
||||
|
|
|
|||
Loading…
Reference in New Issue