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
|
devcontainer: compile podman-image podman-container
|
||||||
|
|
||||||
webui-dist:
|
webui-dist:
|
||||||
|
$(call delete-files,webui)
|
||||||
$(MAKE) -wC frontend dist
|
$(MAKE) -wC frontend dist
|
||||||
mv frontend/dist webui
|
mv frontend/dist webui
|
||||||
|
|
||||||
|
|
@ -72,4 +73,7 @@ clean:
|
||||||
config-tool:
|
config-tool:
|
||||||
cd service && go run cmd/config-tool/main.go
|
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
|
# Docs: https://docs.olivetin.app/args/input_confirmation.html
|
||||||
- title: Delete old backups
|
- title: Delete old backups
|
||||||
icon: ashtonished
|
icon: ashtonished
|
||||||
justification: true
|
# A single space requires justification with no prefilled template (empty disables it).
|
||||||
|
justification: " "
|
||||||
shell: rm -rf /opt/oliveTinOldBackups/ && sleep 5
|
shell: rm -rf /opt/oliveTinOldBackups/ && sleep 5
|
||||||
arguments:
|
arguments:
|
||||||
- type: html
|
- type: html
|
||||||
|
|
@ -143,6 +144,29 @@ actions:
|
||||||
- type: confirmation
|
- type: confirmation
|
||||||
title: Are you sure?!
|
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
|
# 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.
|
# 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
|
# Docs: https://docs.olivetin.app/entities/intro.html
|
||||||
- file: entities/servers.yaml
|
- file: entities/servers.yaml
|
||||||
name: server
|
name: server
|
||||||
|
icon: ssh
|
||||||
|
properties:
|
||||||
|
- name: hostname
|
||||||
|
title: Hostname
|
||||||
|
- name: ip
|
||||||
|
title: IP
|
||||||
|
|
||||||
- file: entities/containers.json
|
- file: entities/containers.json
|
||||||
name: container
|
name: container
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@
|
||||||
** xref:args/regex.adoc[Input: Regex]
|
** xref:args/regex.adoc[Input: Regex]
|
||||||
** xref:args/password.adoc[Input: Password]
|
** xref:args/password.adoc[Input: Password]
|
||||||
** xref:args/input_checkbox.adoc[Input: Checkbox/Boolean]
|
** 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_dropdown.adoc[Input: Dropdown]
|
||||||
** xref:args/input_datetime.adoc[Input: Date & Time]
|
** xref:args/input_datetime.adoc[Input: Date & Time]
|
||||||
** xref:args/input_confirmation.adoc[Input: Confirmation]
|
** xref:args/input_confirmation.adoc[Input: Confirmation]
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,13 @@ actions:
|
||||||
- title: Check date and send notification via apprise
|
- title: Check date and send notification via apprise
|
||||||
icon: date
|
icon: date
|
||||||
shell: 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;
|
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
|
* `{{ 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
|
* `{{ 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_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.
|
* `{{ .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.
|
| 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.
|
| 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.
|
| 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.
|
| 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
|
| 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.
|
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[];
|
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;
|
* @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>;
|
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
|
* @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;
|
* @generated from field: map<string, string> fields = 5;
|
||||||
*/
|
*/
|
||||||
fields: { [key: string]: string };
|
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
|
* @generated from message olivetin.api.v1.GetEntitiesRequest
|
||||||
*/
|
*/
|
||||||
export declare type GetEntitiesRequest = 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;
|
* @generated from field: repeated string used_on_dashboards = 3;
|
||||||
*/
|
*/
|
||||||
usedOnDashboards: string[];
|
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>;
|
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
|
* @generated from message olivetin.api.v1.GetEntityRequest
|
||||||
*/
|
*/
|
||||||
|
|
@ -2194,3 +2280,4 @@ export declare const OliveTinApiService: GenService<{
|
||||||
output: typeof EntitySchema;
|
output: typeof EntitySchema;
|
||||||
},
|
},
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -61,6 +61,11 @@ const props = defineProps({
|
||||||
type: String,
|
type: String,
|
||||||
required: false,
|
required: false,
|
||||||
default: ''
|
default: ''
|
||||||
|
},
|
||||||
|
prefilledArguments: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: () => ({})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -248,7 +253,16 @@ async function handleClick() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (needsArgumentForm(props.actionData)) {
|
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 {
|
} else {
|
||||||
await startAction()
|
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 searchInputRef = ref(null)
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
|
const isUserFiltering = ref(false)
|
||||||
const highlightedIndex = ref(0)
|
const highlightedIndex = ref(0)
|
||||||
|
|
||||||
const listboxId = computed(() => `${props.id}-listbox`)
|
const listboxId = computed(() => `${props.id}-listbox`)
|
||||||
|
|
@ -109,6 +110,10 @@ const placeholderText = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredChoices = computed(() => {
|
const filteredChoices = computed(() => {
|
||||||
|
if (!isUserFiltering.value) {
|
||||||
|
return props.choices
|
||||||
|
}
|
||||||
|
|
||||||
const search = query.value.trim().toLowerCase()
|
const search = query.value.trim().toLowerCase()
|
||||||
if (!search) {
|
if (!search) {
|
||||||
return props.choices
|
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() {
|
function openList() {
|
||||||
document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } }))
|
document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } }))
|
||||||
|
const wasClosed = !isOpen.value
|
||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
highlightedIndex.value = 0
|
if (wasClosed) {
|
||||||
|
highlightedIndex.value = selectedChoiceIndex(filteredChoices.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeList() {
|
function closeList() {
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
|
isUserFiltering.value = false
|
||||||
syncFromModelValue()
|
syncFromModelValue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,12 +178,14 @@ function selectChoice(choice) {
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
if (!isOpen.value) {
|
if (!isOpen.value) {
|
||||||
syncFromModelValue()
|
syncFromModelValue()
|
||||||
|
isUserFiltering.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
openList()
|
openList()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearchInput(event) {
|
function handleSearchInput(event) {
|
||||||
|
isUserFiltering.value = true
|
||||||
query.value = event.target.value
|
query.value = event.target.value
|
||||||
openList()
|
openList()
|
||||||
highlightedIndex.value = 0
|
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) {
|
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 { needsArgumentForm } from './needsArgumentForm.js'
|
||||||
|
import { actionRequiresJustification } from './justificationTemplate.js'
|
||||||
|
|
||||||
const nonStorableArgumentTypes = new Set([
|
const nonStorableArgumentTypes = new Set([
|
||||||
'password',
|
'password',
|
||||||
|
|
@ -17,7 +18,7 @@ export function logEntryArgumentsToStartActionArgs (logEntry) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rerunNeedsArgumentForm (action, logEntry) {
|
export function rerunNeedsArgumentForm (action, logEntry) {
|
||||||
if (action?.justification && !logEntry?.justification) {
|
if (actionRequiresJustification(action?.justification) && !logEntry?.justification) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,7 +61,7 @@ export function buildRerunStartActionArgs (bindingId, logEntry, action) {
|
||||||
arguments: logEntryArgumentsToStartActionArgs(logEntry)
|
arguments: logEntryArgumentsToStartActionArgs(logEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action?.justification && logEntry?.justification) {
|
if (actionRequiresJustification(action?.justification) && logEntry?.justification) {
|
||||||
startActionArgs.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', () => {
|
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(rerunNeedsArgumentForm(action, {}), true)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|
@ -90,7 +90,7 @@ test('buildRerunStartActionArgs includes stored justification', () => {
|
||||||
arguments: [{ name: 'host', value: 'db-1' }],
|
arguments: [{ name: 'host', value: 'db-1' }],
|
||||||
justification: 'maintenance window'
|
justification: 'maintenance window'
|
||||||
}, {
|
}, {
|
||||||
justification: true,
|
justification: ' ',
|
||||||
arguments: [{ name: 'host', type: 'ascii_identifier' }]
|
arguments: [{ name: 'host', type: 'ascii_identifier' }]
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,9 @@
|
||||||
<td class="duration">{{ formatExecutionDuration(log) }}</td>
|
<td class="duration">{{ formatExecutionDuration(log) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="`/logs/${log.executionTrackingId}`">
|
<router-link :to="`/logs/${log.executionTrackingId}`">
|
||||||
|
<LogActionTitle :justification="log.justification">
|
||||||
{{ log.executionTrackingId }}
|
{{ log.executionTrackingId }}
|
||||||
|
</LogActionTitle>
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td class="tags">
|
<td class="tags">
|
||||||
|
|
@ -134,6 +136,7 @@ import Section from 'picocrank/vue/components/Section.vue'
|
||||||
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||||
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
||||||
import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
|
import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
|
||||||
|
import LogActionTitle from '../components/LogActionTitle.vue'
|
||||||
import { HugeiconsIcon } from '@hugeicons/vue'
|
import { HugeiconsIcon } from '@hugeicons/vue'
|
||||||
import { DashboardSquare01Icon, WorkoutRunIcon } from '@hugeicons/core-free-icons'
|
import { DashboardSquare01Icon, WorkoutRunIcon } from '@hugeicons/core-free-icons'
|
||||||
import { requestReconnectNow } from '../../../js/websocket.js'
|
import { requestReconnectNow } from '../../../js/websocket.js'
|
||||||
|
|
@ -162,7 +165,8 @@ const filteredLogs = computed(() => {
|
||||||
const searchLower = searchText.value.toLowerCase()
|
const searchLower = searchText.value.toLowerCase()
|
||||||
return logs.value.filter(log =>
|
return logs.value.filter(log =>
|
||||||
log.executionTrackingId.toLowerCase().includes(searchLower) ||
|
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>
|
<template>
|
||||||
<section id = "argument-popup">
|
<section id = "argument-popup">
|
||||||
<div class="section-header">
|
<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>
|
||||||
<div class="section-content padding">
|
<div class="section-content padding">
|
||||||
<form @submit="handleSubmit">
|
<form @submit="handleSubmit">
|
||||||
<template v-if="actionArguments.length > 0">
|
<template v-if="actionArguments.length > 0">
|
||||||
|
|
||||||
<template v-for="arg in actionArguments" :key="arg.name">
|
<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) }}
|
{{ formatLabel(arg.title) }}
|
||||||
</label>
|
</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`">
|
<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">
|
<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"
|
:choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
|
||||||
@update:model-value="handleChoiceUpdate(arg, $event)" />
|
@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"
|
<component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name"
|
||||||
:value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
|
:value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
|
||||||
:checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
|
:checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
|
||||||
|
|
@ -40,7 +58,7 @@
|
||||||
|
|
||||||
<template v-if="justificationRequired">
|
<template v-if="justificationRequired">
|
||||||
<label for="justification">Justification:</label>
|
<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>
|
</template>
|
||||||
|
|
||||||
<div v-if="actionArguments.length === 0 && !justificationRequired">
|
<div v-if="actionArguments.length === 0 && !justificationRequired">
|
||||||
|
|
@ -61,10 +79,18 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { useRouter } from 'vue-router'
|
||||||
import { requestReconnectNow } from '../../../js/websocket.js'
|
import { requestReconnectNow } from '../../../js/websocket.js'
|
||||||
import ChoiceCombobox from '../components/ChoiceCombobox.vue'
|
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()
|
const router = useRouter()
|
||||||
|
|
||||||
|
|
@ -80,8 +106,11 @@ const formErrors = ref({})
|
||||||
const actionArguments = ref([])
|
const actionArguments = ref([])
|
||||||
const popupOnStart = ref('')
|
const popupOnStart = ref('')
|
||||||
const formReady = ref(false)
|
const formReady = ref(false)
|
||||||
const justificationRequired = ref(false)
|
const justificationConfig = ref('')
|
||||||
const justificationValue = ref('')
|
const justificationValue = ref('')
|
||||||
|
const justificationEditedManually = ref(false)
|
||||||
|
const justificationRequired = computed(() => actionRequiresJustification(justificationConfig.value))
|
||||||
|
const justificationTemplate = computed(() => actionJustificationTemplate(justificationConfig.value))
|
||||||
let isComponentMounted = true
|
let isComponentMounted = true
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
|
|
@ -109,18 +138,21 @@ async function setup() {
|
||||||
icon.value = action.icon
|
icon.value = action.icon
|
||||||
popupOnStart.value = action.popupOnStart || ''
|
popupOnStart.value = action.popupOnStart || ''
|
||||||
actionArguments.value = action.arguments || []
|
actionArguments.value = action.arguments || []
|
||||||
justificationRequired.value = action.justification || false
|
justificationConfig.value = action.justification || ''
|
||||||
justificationValue.value = ''
|
justificationValue.value = ''
|
||||||
|
justificationEditedManually.value = false
|
||||||
argValues.value = {}
|
argValues.value = {}
|
||||||
formErrors.value = {}
|
formErrors.value = {}
|
||||||
confirmationChecked.value = false
|
confirmationChecked.value = false
|
||||||
hasConfirmation.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 => {
|
actionArguments.value.forEach(arg => {
|
||||||
if (arg.type === 'confirmation') {
|
if (arg.type === 'confirmation') {
|
||||||
hasConfirmation.value = true
|
hasConfirmation.value = true
|
||||||
const paramValue = getQueryParamValue(arg.name)
|
const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
|
||||||
let checkedValue = false
|
let checkedValue = false
|
||||||
if (paramValue !== null) {
|
if (paramValue !== null) {
|
||||||
checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true
|
checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true
|
||||||
|
|
@ -130,7 +162,7 @@ async function setup() {
|
||||||
argValues.value[arg.name] = checkedValue
|
argValues.value[arg.name] = checkedValue
|
||||||
confirmationChecked.value = checkedValue
|
confirmationChecked.value = checkedValue
|
||||||
} else {
|
} else {
|
||||||
const paramValue = getQueryParamValue(arg.name)
|
const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
|
||||||
if (arg.type === 'checkbox') {
|
if (arg.type === 'checkbox') {
|
||||||
// For checkboxes, handle boolean default values properly
|
// For checkboxes, handle boolean default values properly
|
||||||
if (paramValue !== null) {
|
if (paramValue !== null) {
|
||||||
|
|
@ -158,16 +190,13 @@ async function setup() {
|
||||||
formReady.value = true
|
formReady.value = true
|
||||||
document.body.setAttribute('loaded-argument-form', props.bindingId)
|
document.body.setAttribute('loaded-argument-form', props.bindingId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateJustificationFromTemplate()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load argument form:', 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) {
|
function formatLabel(title) {
|
||||||
const lastChar = title.charAt(title.length - 1)
|
const lastChar = title.charAt(title.length - 1)
|
||||||
if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
|
if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
|
||||||
|
|
@ -222,10 +251,16 @@ function getArgumentValue(arg) {
|
||||||
return argValues.value[arg.name] || ''
|
return argValues.value[arg.name] || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleJustificationInput(event) {
|
||||||
|
justificationValue.value = event.target.value
|
||||||
|
justificationEditedManually.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function handleInput(arg, event) {
|
function handleInput(arg, event) {
|
||||||
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
|
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
|
||||||
argValues.value[arg.name] = value
|
argValues.value[arg.name] = value
|
||||||
updateUrlWithArg(arg.name, value)
|
updateUrlWithArg(arg.name, value)
|
||||||
|
updateJustificationFromTemplate()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChange(arg, event) {
|
function handleChange(arg, event) {
|
||||||
|
|
@ -238,10 +273,19 @@ function handleChange(arg, event) {
|
||||||
validateArgument(arg, event.target.value)
|
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) {
|
function handleChoiceUpdate(arg, value) {
|
||||||
argValues.value[arg.name] = value
|
argValues.value[arg.name] = value
|
||||||
updateUrlWithArg(arg.name, value)
|
updateUrlWithArg(arg.name, value)
|
||||||
validateArgument(arg, value)
|
validateArgument(arg, value)
|
||||||
|
updateJustificationFromTemplate()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateArgument(arg, value) {
|
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
|
// Skip validation for datetime - backend will handle mangling values without seconds
|
||||||
if (arg.type === 'datetime') {
|
if (arg.type === 'datetime') {
|
||||||
const inputElement = document.getElementById(arg.name)
|
const inputElement = getValidationElement(arg)
|
||||||
if (inputElement) {
|
if (inputElement) {
|
||||||
inputElement.setCustomValidity('')
|
inputElement.setCustomValidity('')
|
||||||
}
|
}
|
||||||
|
|
@ -261,7 +305,7 @@ async function validateArgument(arg, value) {
|
||||||
|
|
||||||
// Skip validation for checkbox and confirmation - they're always valid
|
// Skip validation for checkbox and confirmation - they're always valid
|
||||||
if (arg.type === 'checkbox' || arg.type === 'confirmation') {
|
if (arg.type === 'checkbox' || arg.type === 'confirmation') {
|
||||||
const inputElement = document.getElementById(arg.name)
|
const inputElement = getValidationElement(arg)
|
||||||
if (inputElement) {
|
if (inputElement) {
|
||||||
inputElement.setCustomValidity('')
|
inputElement.setCustomValidity('')
|
||||||
}
|
}
|
||||||
|
|
@ -279,8 +323,7 @@ async function validateArgument(arg, value) {
|
||||||
|
|
||||||
const validation = await window.client.validateArgumentType(validateArgumentTypeArgs)
|
const validation = await window.client.validateArgumentType(validateArgumentTypeArgs)
|
||||||
|
|
||||||
// Get the input element to set custom validity
|
const inputElement = getValidationElement(arg)
|
||||||
const inputElement = document.getElementById(arg.name)
|
|
||||||
|
|
||||||
if (validation.valid) {
|
if (validation.valid) {
|
||||||
delete formErrors.value[arg.name]
|
delete formErrors.value[arg.name]
|
||||||
|
|
@ -297,8 +340,7 @@ async function validateArgument(arg, value) {
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Validation failed:', err)
|
console.warn('Validation failed:', err)
|
||||||
// On error, clear any custom validity
|
const inputElement = getValidationElement(arg)
|
||||||
const inputElement = document.getElementById(arg.name)
|
|
||||||
if (inputElement) {
|
if (inputElement) {
|
||||||
inputElement.setCustomValidity('')
|
inputElement.setCustomValidity('')
|
||||||
}
|
}
|
||||||
|
|
@ -344,21 +386,39 @@ function formatArgumentValueForApi(arg, rawValue) {
|
||||||
return rawValue ?? ''
|
return rawValue ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function getArgumentValues() {
|
function getSelectedArgumentEntries() {
|
||||||
const ret = []
|
const entries = []
|
||||||
|
|
||||||
for (const arg of actionArguments.value) {
|
for (const arg of actionArguments.value) {
|
||||||
if (!shouldSendArgument(arg)) {
|
if (!shouldSendArgument(arg)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
ret.push({
|
entries.push({
|
||||||
name: arg.name,
|
name: arg.name,
|
||||||
value: formatArgumentValueForApi(arg, argValues.value[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() {
|
function getUniqueId() {
|
||||||
|
|
@ -393,7 +453,7 @@ function saveBrowserSuggestions() {
|
||||||
const value = argValues.value[arg.name]
|
const value = argValues.value[arg.name]
|
||||||
|
|
||||||
// Only save non-empty values for non-checkbox/confirmation/password types
|
// 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 {
|
try {
|
||||||
const key = `olivetin-suggestions-${arg.suggestionsBrowserKey}`
|
const key = `olivetin-suggestions-${arg.suggestionsBrowserKey}`
|
||||||
const stored = localStorage.getItem(key)
|
const stored = localStorage.getItem(key)
|
||||||
|
|
@ -467,7 +527,7 @@ async function handleSubmit(event) {
|
||||||
|
|
||||||
for (const arg of actionArguments.value) {
|
for (const arg of actionArguments.value) {
|
||||||
const value = argValues.value[arg.name]
|
const value = argValues.value[arg.name]
|
||||||
const inputElement = document.getElementById(arg.name)
|
const inputElement = getValidationElement(arg)
|
||||||
|
|
||||||
if (arg.required && (!value || value === '')) {
|
if (arg.required && (!value || value === '')) {
|
||||||
formErrors.value[arg.name] = 'This field is required'
|
formErrors.value[arg.name] = 'This field is required'
|
||||||
|
|
@ -484,6 +544,11 @@ async function handleSubmit(event) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.keys(formErrors.value).length > 0) {
|
||||||
|
console.log('argument form has validation errors')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const argvs = getArgumentValues()
|
const argvs = getArgumentValues()
|
||||||
console.log('argument form has elements that passed validation')
|
console.log('argument form has elements that passed validation')
|
||||||
|
|
||||||
|
|
@ -546,6 +611,27 @@ onUnmounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
form {
|
||||||
grid-template-columns: max-content auto auto;
|
grid-template-columns: max-content auto auto;
|
||||||
|
|
|
||||||
|
|
@ -9,36 +9,18 @@
|
||||||
</p>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Section v-for="def in entityDefinitions" :key="def.title" :title="'Entity: ' + def.title ">
|
<EntityDefinitionSection
|
||||||
<p>{{ def.instances.length }} instances.</p>
|
v-for="def in entityDefinitions"
|
||||||
|
:key="def.title"
|
||||||
<ul>
|
:definition="def"
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import Section from 'picocrank/vue/components/Section.vue'
|
import Section from 'picocrank/vue/components/Section.vue'
|
||||||
|
import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
|
||||||
|
|
||||||
const definitionsLoaded = ref(false)
|
const definitionsLoaded = ref(false)
|
||||||
const entityDefinitions = ref([])
|
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(() => {
|
onMounted(() => {
|
||||||
fetchEntities()
|
fetchEntities()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
<template>
|
<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>
|
<template #toolbar>
|
||||||
<button @click="goBack" class="back-button">
|
<button @click="goBack" class="back-button">
|
||||||
<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
|
<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
|
||||||
|
|
@ -22,15 +29,15 @@
|
||||||
<template v-if="entityDetails.fields">
|
<template v-if="entityDetails.fields">
|
||||||
<template v-for="(value, key) in entityDetails.fields" :key="key">
|
<template v-for="(value, key) in entityDetails.fields" :key="key">
|
||||||
<dt>{{ key }}</dt>
|
<dt>{{ key }}</dt>
|
||||||
<dd v-html="value"></dd>
|
<dd>{{ value }}</dd>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</dl>
|
</dl>
|
||||||
<p v-if="!entityDetails.title && (!entityDetails.fields || Object.keys(entityDetails.fields).length === 0)">No details available for this entity.</p>
|
<p v-if="!entityDetails.title && (!entityDetails.fields || Object.keys(entityDetails.fields).length === 0)">No details available for this entity.</p>
|
||||||
|
</template>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<hr />
|
<Section v-if="entityDetails" title="Dashboard Entity Directories">
|
||||||
|
|
||||||
<h3>Dashboard Entity Directories</h3>
|
|
||||||
<div v-if="filteredDirectories.length > 0" class="directories-section">
|
<div v-if="filteredDirectories.length > 0" class="directories-section">
|
||||||
<ul class="directory-list">
|
<ul class="directory-list">
|
||||||
<li v-for="(directory, idx) in filteredDirectories" :key="idx">
|
<li v-for="(directory, idx) in filteredDirectories" :key="idx">
|
||||||
|
|
@ -49,10 +56,24 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p v-else>No directories found for this entity.
|
<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>
|
</p>
|
||||||
</template>
|
|
||||||
</Section>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -61,6 +82,8 @@
|
||||||
import { HugeiconsIcon } from '@hugeicons/vue'
|
import { HugeiconsIcon } from '@hugeicons/vue'
|
||||||
import { ArrowLeftIcon } from '@hugeicons/core-free-icons'
|
import { ArrowLeftIcon } from '@hugeicons/core-free-icons'
|
||||||
import Section from 'picocrank/vue/components/Section.vue'
|
import Section from 'picocrank/vue/components/Section.vue'
|
||||||
|
import ActionButton from '../ActionButton.vue'
|
||||||
|
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const entityDetails = ref(null)
|
const entityDetails = ref(null)
|
||||||
|
|
@ -77,6 +100,10 @@
|
||||||
return entityDetails.value.directories.filter(d => d)
|
return entityDetails.value.directories.filter(d => d)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const relatedActions = computed(() => entityDetails.value?.relatedActions ?? [])
|
||||||
|
|
||||||
|
const entityIcon = computed(() => entityDetails.value?.icon ?? '')
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.push({ name: 'Entities' })
|
router.push({ name: 'Entities' })
|
||||||
}
|
}
|
||||||
|
|
@ -121,11 +148,6 @@
|
||||||
box-shadow: 0 0 .5em rgba(0, 0, 0, 0.15);
|
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 {
|
.directory-list a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
|
|
@ -149,9 +171,22 @@
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
hr {
|
.section-title-with-icon {
|
||||||
border: 0;
|
display: inline-flex;
|
||||||
border-top: 1px solid var(--border-color, #ccc);
|
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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|
@ -164,11 +199,6 @@ hr {
|
||||||
background-color: var(--bg-hover, #222);
|
background-color: var(--bg-hover, #222);
|
||||||
}
|
}
|
||||||
|
|
||||||
.directories-section {
|
|
||||||
border-top-color: var(--border-color, #333);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.directory-list a:hover {
|
.directory-list a:hover {
|
||||||
background-color: var(--bg-hover, #222);
|
background-color: var(--bg-hover, #222);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
default: test-install test-run
|
default: test-install prep test-run
|
||||||
|
|
||||||
test-install:
|
test-install:
|
||||||
npm install --no-fund
|
npm install --no-fund
|
||||||
|
|
||||||
|
prep:
|
||||||
|
$(MAKE) -wC .. webui-dist
|
||||||
|
$(MAKE) -wC ../service compile-currentenv
|
||||||
|
|
||||||
test-run:
|
test-run:
|
||||||
# GitHub Actions fails badly on the default timeout of 2000ms
|
# GitHub Actions fails badly on the default timeout of 2000ms
|
||||||
npx mocha tests --recursive -t 10000
|
npx mocha tests --recursive -t 10000
|
||||||
|
|
@ -24,4 +28,4 @@ getsnapshot:
|
||||||
rm -rf /opt/OliveTin-snapshot/*
|
rm -rf /opt/OliveTin-snapshot/*
|
||||||
gh run download -D /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:
|
actions:
|
||||||
- title: Test checkbox argument
|
- title: Test checkbox argument
|
||||||
shell: "echo 'Checkbox value: {{ confirm }}'"
|
exec:
|
||||||
|
- echo
|
||||||
|
- "Checkbox value: {{ confirm }}"
|
||||||
icon: ping
|
icon: ping
|
||||||
arguments:
|
arguments:
|
||||||
- name: confirm
|
- name: confirm
|
||||||
|
|
@ -15,5 +17,3 @@ actions:
|
||||||
type: checkbox
|
type: checkbox
|
||||||
description: "When checked: 1, when unchecked: 0"
|
description: "When checked: 1, when unchecked: 0"
|
||||||
default: false
|
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;
|
repeated string exec_on_file_changed_in_dir = 13;
|
||||||
string exec_on_calendar_file = 14;
|
string exec_on_calendar_file = 14;
|
||||||
repeated ActionWebhookExecHint exec_on_webhooks = 15;
|
repeated ActionWebhookExecHint exec_on_webhooks = 15;
|
||||||
bool justification = 16;
|
reserved 16;
|
||||||
|
string justification = 20;
|
||||||
bool has_running_instance = 17;
|
bool has_running_instance = 17;
|
||||||
bool has_queued_instance = 18;
|
bool has_queued_instance = 18;
|
||||||
repeated ActionGroupMembership groups = 19;
|
repeated ActionGroupMembership groups = 19;
|
||||||
|
|
@ -57,12 +58,19 @@ message ActionArgumentChoice {
|
||||||
string title = 2;
|
string title = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message EntityRelatedAction {
|
||||||
|
Action action = 1;
|
||||||
|
map<string, string> prefilled_arguments = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message Entity {
|
message Entity {
|
||||||
string title = 1;
|
string title = 1;
|
||||||
string unique_key = 2;
|
string unique_key = 2;
|
||||||
string type = 3;
|
string type = 3;
|
||||||
repeated string directories = 4;
|
repeated string directories = 4;
|
||||||
map<string, string> fields = 5;
|
map<string, string> fields = 5;
|
||||||
|
repeated EntityRelatedAction related_actions = 6;
|
||||||
|
string icon = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetDashboardResponse {
|
message GetDashboardResponse {
|
||||||
|
|
@ -427,6 +435,10 @@ message GetActionBindingResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetEntitiesRequest {
|
message GetEntitiesRequest {
|
||||||
|
string entity_type = 1;
|
||||||
|
string filter = 2;
|
||||||
|
int32 page = 3;
|
||||||
|
int32 page_size = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetEntitiesResponse {
|
message GetEntitiesResponse {
|
||||||
|
|
@ -437,6 +449,14 @@ message EntityDefinition {
|
||||||
string title = 1;
|
string title = 1;
|
||||||
repeated Entity instances = 2;
|
repeated Entity instances = 2;
|
||||||
repeated string used_on_dashboards = 3;
|
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 {
|
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"
|
ctx "context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
"google.golang.org/protobuf/encoding/protojson"
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
|
@ -16,11 +21,6 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
acl "github.com/OliveTin/OliveTin/internal/acl"
|
acl "github.com/OliveTin/OliveTin/internal/acl"
|
||||||
auth "github.com/OliveTin/OliveTin/internal/auth"
|
auth "github.com/OliveTin/OliveTin/internal/auth"
|
||||||
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
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)
|
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)
|
return nil, connectInvalidJustification(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
execReq := executor.ExecutionRequest{
|
execReq := executor.ExecutionRequest{
|
||||||
Binding: pair,
|
Binding: pair,
|
||||||
TrackingID: req.Msg.UniqueTrackingId,
|
TrackingID: req.Msg.UniqueTrackingId,
|
||||||
Arguments: startActionArgumentsFromProto(req.Msg.Arguments),
|
Arguments: args,
|
||||||
Justification: req.Msg.Justification,
|
Justification: justification,
|
||||||
AuthenticatedUser: authenticatedUser,
|
AuthenticatedUser: authenticatedUser,
|
||||||
Cfg: api.cfg,
|
Cfg: api.cfg,
|
||||||
}
|
}
|
||||||
|
|
@ -280,6 +282,21 @@ func (api *oliveTinAPI) findBindingByIDOrNotFound(bindingId string) (*executor.A
|
||||||
return api.findBindingOrNotFound(bindingId)
|
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) {
|
func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
|
||||||
binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
|
binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -287,16 +304,18 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request
|
||||||
}
|
}
|
||||||
|
|
||||||
user := auth.UserFromApiCall(ctx, req, api.cfg)
|
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)
|
return nil, connectInvalidJustification(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
internalLogEntry, ok := api.startActionAndWaitRun(binding, startActionArgumentsFromProto(req.Msg.Arguments), req.Msg.Justification, user)
|
logEntry, err := api.startActionAndWaitLogEntry(binding, args, justification, user)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
|
return nil, err
|
||||||
}
|
}
|
||||||
return connect.NewResponse(&apiv1.StartActionAndWaitResponse{
|
return connect.NewResponse(&apiv1.StartActionAndWaitResponse{
|
||||||
LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
|
LogEntry: logEntry,
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -323,16 +342,7 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
|
func (api *oliveTinAPI) runBindingAndWait(binding *executor.ActionBinding, args map[string]string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) {
|
||||||
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)
|
|
||||||
|
|
||||||
execReq := executor.ExecutionRequest{
|
execReq := executor.ExecutionRequest{
|
||||||
Binding: binding,
|
Binding: binding,
|
||||||
TrackingID: uuid.NewString(),
|
TrackingID: uuid.NewString(),
|
||||||
|
|
@ -344,15 +354,32 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Re
|
||||||
wg, _ := api.executor.ExecRequest(&execReq)
|
wg, _ := api.executor.ExecRequest(&execReq)
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
internalLogEntry, ok := api.executor.GetLog(execReq.TrackingID)
|
return api.executor.GetLog(execReq.TrackingID)
|
||||||
|
|
||||||
if ok {
|
|
||||||
return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{
|
|
||||||
LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
|
|
||||||
}), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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 {
|
func calculateRateLimitExpires(api *oliveTinAPI, logEntry *executor.InternalLogEntry) string {
|
||||||
if logEntry.Binding == nil || logEntry.Binding.Action == nil {
|
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)
|
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).
|
// 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 {
|
func (api *oliveTinAPI) mayViewExecutionEvent(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
|
|
@ -1373,21 +1407,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
|
||||||
}
|
}
|
||||||
|
|
||||||
entityMap := entities.GetEntities()
|
entityMap := entities.GetEntities()
|
||||||
entityNames := make([]string, 0, len(entityMap))
|
entityDefinitions := api.buildEntityDefinitionsResponse(req.Msg, 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
res := &apiv1.GetEntitiesResponse{
|
res := &apiv1.GetEntitiesResponse{
|
||||||
EntityDefinitions: entityDefinitions,
|
EntityDefinitions: entityDefinitions,
|
||||||
|
|
@ -1396,7 +1416,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
|
||||||
return connect.NewResponse(res), nil
|
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))
|
instanceKeys := make([]string, 0, len(entityInstances))
|
||||||
for key := range entityInstances {
|
for key := range entityInstances {
|
||||||
instanceKeys = append(instanceKeys, key)
|
instanceKeys = append(instanceKeys, key)
|
||||||
|
|
@ -1410,6 +1430,7 @@ func buildSortedEntityInstances(entityType string, entityInstances map[string]*e
|
||||||
Title: e.Title,
|
Title: e.Title,
|
||||||
UniqueKey: e.UniqueKey,
|
UniqueKey: e.UniqueKey,
|
||||||
Type: entityType,
|
Type: entityType,
|
||||||
|
Fields: entityListFields(e.Data, properties),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return instances
|
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))
|
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
|
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{
|
res := &apiv1.Entity{
|
||||||
Title: entity.Title,
|
Title: entity.Title,
|
||||||
UniqueKey: entity.UniqueKey,
|
UniqueKey: entity.UniqueKey,
|
||||||
Type: entityType,
|
Type: entityType,
|
||||||
Directories: findDirectoriesInEntityFieldsets(entityType, dashboards),
|
Directories: findDirectoriesInEntityFieldsets(entityType, cfg.Dashboards),
|
||||||
Fields: serializeEntityFields(entity.Data),
|
Fields: entityFieldsForResponse(entity.Data, properties),
|
||||||
|
Icon: entityTypeIcon(cfg, entityType),
|
||||||
}
|
}
|
||||||
return res
|
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"
|
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||||
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
||||||
"github.com/OliveTin/OliveTin/internal/config"
|
"github.com/OliveTin/OliveTin/internal/config"
|
||||||
|
"github.com/OliveTin/OliveTin/internal/entities"
|
||||||
"github.com/OliveTin/OliveTin/internal/executor"
|
"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 {
|
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 {
|
func actionRequiresJustificationConfig(action *config.Action) bool {
|
||||||
return action != nil && action.Justification
|
return action != nil && action.RequiresJustification()
|
||||||
}
|
}
|
||||||
|
|
||||||
func justificationProvided(justification string, user *authpublic.AuthenticatedUser) bool {
|
func justificationProvided(justification string, user *authpublic.AuthenticatedUser) bool {
|
||||||
|
|
@ -40,6 +43,56 @@ func startActionArgumentsFromProto(args []*apiv1.StartActionArgument) map[string
|
||||||
return result
|
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 {
|
func restartRequiresJustificationError() error {
|
||||||
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("justification is required for this action; use StartAction with a justification instead"))
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
@ -21,7 +20,7 @@ func TestStartActionRequiresJustificationForGuest(t *testing.T) {
|
||||||
action := &config.Action{
|
action := &config.Action{
|
||||||
Title: "Send email",
|
Title: "Send email",
|
||||||
ID: "send_email",
|
ID: "send_email",
|
||||||
Justification: true,
|
Justification: config.JustificationRequiredNoTemplate,
|
||||||
Shell: "echo done",
|
Shell: "echo done",
|
||||||
}
|
}
|
||||||
cfg.Actions = append(cfg.Actions, action)
|
cfg.Actions = append(cfg.Actions, action)
|
||||||
|
|
@ -49,19 +48,15 @@ func TestStartActionRequiresJustificationForGuest(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotEmpty(t, resp.Msg.ExecutionTrackingId)
|
require.NotEmpty(t, resp.Msg.ExecutionTrackingId)
|
||||||
|
|
||||||
time.Sleep(200 * time.Millisecond)
|
waitForLogJustification(t, ex, resp.Msg.ExecutionTrackingId, "New user registration foo@example.com")
|
||||||
|
|
||||||
entry, ok := ex.GetLog(resp.Msg.ExecutionTrackingId)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, "New user registration foo@example.com", entry.Justification)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildActionExposesJustificationFlag(t *testing.T) {
|
func TestBuildActionExposesJustificationTemplate(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
action := &config.Action{
|
action := &config.Action{
|
||||||
Title: "Audited action",
|
Title: "Audited action",
|
||||||
ID: "audited",
|
ID: "audited",
|
||||||
Justification: true,
|
Justification: "{{ target }}",
|
||||||
Shell: "echo hi",
|
Shell: "echo hi",
|
||||||
}
|
}
|
||||||
cfg.Actions = append(cfg.Actions, action)
|
cfg.Actions = append(cfg.Actions, action)
|
||||||
|
|
@ -77,12 +72,94 @@ func TestBuildActionExposesJustificationFlag(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
require.NotNil(t, pb)
|
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) {
|
func TestValidateJustificationRequiredAllowsSystemUser(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
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"))
|
err := validateJustificationRequired(action, "", auth.UserFromSystem(cfg, "cron"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ func restartArgumentsIncompleteError() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateRestartLogEntry(entry *executor.InternalLogEntry) 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()
|
return restartRequiresJustificationError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,22 @@ func waitForLogArguments(t *testing.T, ex *executor.Executor, trackingID string)
|
||||||
return nil
|
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) {
|
func waitForLogJustification(t *testing.T, ex *executor.Executor, trackingID, expected string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|
@ -166,6 +182,7 @@ func TestRestartActionReusesStoredArguments(t *testing.T) {
|
||||||
|
|
||||||
originalArgs := waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
|
originalArgs := waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
|
||||||
assert.Equal(t, "server-a", originalArgs["host"])
|
assert.Equal(t, "server-a", originalArgs["host"])
|
||||||
|
waitForLogFinished(t, ex, startResp.Msg.ExecutionTrackingId)
|
||||||
|
|
||||||
restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
|
restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
|
||||||
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
|
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
|
||||||
|
|
@ -286,7 +303,7 @@ func TestRestartActionRequiresJustificationWhenMissingFromStoredLog(t *testing.T
|
||||||
Title: "Dangerous action",
|
Title: "Dangerous action",
|
||||||
Shell: "echo ok",
|
Shell: "echo ok",
|
||||||
MaxConcurrent: 1,
|
MaxConcurrent: 1,
|
||||||
Justification: true,
|
Justification: config.JustificationRequiredNoTemplate,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -319,7 +336,7 @@ func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T)
|
||||||
Title: "Dangerous action",
|
Title: "Dangerous action",
|
||||||
Shell: "echo ok",
|
Shell: "echo ok",
|
||||||
MaxConcurrent: 1,
|
MaxConcurrent: 1,
|
||||||
Justification: true,
|
Justification: config.JustificationRequiredNoTemplate,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -338,6 +355,7 @@ func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
waitForLogJustification(t, ex, startResp.Msg.ExecutionTrackingId, "maintenance window")
|
waitForLogJustification(t, ex, startResp.Msg.ExecutionTrackingId, "maintenance window")
|
||||||
|
waitForLogFinished(t, ex, startResp.Msg.ExecutionTrackingId)
|
||||||
|
|
||||||
restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
|
restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
|
||||||
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
|
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,15 @@ func TestGetActionsAndStart(t *testing.T) {
|
||||||
|
|
||||||
func TestGetEntities(t *testing.T) {
|
func TestGetEntities(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Entities = []*config.EntityFile{
|
||||||
|
{
|
||||||
|
Name: "server",
|
||||||
|
Properties: []config.EntityProperty{
|
||||||
|
{Name: "hostname", Title: "Hostname"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg.Sanitize()
|
||||||
|
|
||||||
ts, client := getNewTestServerAndClient(cfg)
|
ts, client := getNewTestServerAndClient(cfg)
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
@ -138,6 +147,26 @@ func TestGetEntities(t *testing.T) {
|
||||||
validateEntityOrderAndStructure(t, entityDefinitions)
|
validateEntityOrderAndStructure(t, entityDefinitions)
|
||||||
validateNoDuplicates(t, entityDefinitions)
|
validateNoDuplicates(t, entityDefinitions)
|
||||||
validateConsistency(t, client, 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() {
|
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, "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, "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, 0, len(entityDefinitions[2].Instances), "Server instances should not be included in bulk list response")
|
||||||
assert.Equal(t, "alpha", entityDefinitions[2].Instances[0].UniqueKey, "First server instance should be 'alpha' (alphabetically first)")
|
assert.Equal(t, int32(3), entityDefinitions[2].TotalInstances, "Server should report total instance count")
|
||||||
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)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateNoDuplicates(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) {
|
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)
|
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:
|
// 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".
|
// 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) {
|
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, "missing", actionResult.Groups[1].Name)
|
||||||
assert.Equal(t, int32(0), actionResult.Groups[1].MaxConcurrent)
|
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
|
providerName string
|
||||||
Username string
|
Username string
|
||||||
Usergroup string
|
Usergroup string
|
||||||
|
createdAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
oauthStateMaxAge = 900 // matches olivetin-sid-oauth cookie MaxAge
|
||||||
|
oauthStateMaxEntries = 10000
|
||||||
|
)
|
||||||
|
|
||||||
func assignIfEmpty(target *string, value string) {
|
func assignIfEmpty(target *string, value string) {
|
||||||
if *target == "" {
|
if *target == "" {
|
||||||
*target = value
|
*target = value
|
||||||
|
|
@ -129,6 +135,19 @@ func (h *OAuth2Handler) setOAuthCallbackCookie(w http.ResponseWriter, r *http.Re
|
||||||
http.SetCookie(w, cookie)
|
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) {
|
func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
state, err := randString(16)
|
state, err := randString(16)
|
||||||
|
|
||||||
|
|
@ -147,10 +166,17 @@ func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
h.mu.Lock()
|
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{
|
h.registeredStates[state] = &oauth2State{
|
||||||
providerConfig: provider,
|
providerConfig: provider,
|
||||||
providerName: providerName,
|
providerName: providerName,
|
||||||
Username: "",
|
Username: "",
|
||||||
|
createdAt: time.Now(),
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
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) {
|
if !h.validateStateMatch(r.URL.Query().Get("state"), state) {
|
||||||
log.Errorf("State mismatch: %v != %v", 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)
|
http.Error(w, "State mismatch", http.StatusBadRequest)
|
||||||
return nil, state, false
|
return nil, state, false
|
||||||
}
|
}
|
||||||
|
|
@ -186,6 +215,9 @@ func (h *OAuth2Handler) checkOAuthCallbackCookie(w http.ResponseWriter, r *http.
|
||||||
h.mu.RUnlock()
|
h.mu.RUnlock()
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Errorf("State not found in server: %v", state)
|
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)
|
http.Error(w, "State not found in server", http.StatusBadRequest)
|
||||||
return nil, state, false
|
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.
|
// ReservedArgumentNamePrefix is reserved for OliveTin-injected system arguments.
|
||||||
const ReservedArgumentNamePrefix = "ot_"
|
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
|
// Action represents the core functionality of OliveTin - commands that show up
|
||||||
// as buttons in the UI.
|
// as buttons in the UI.
|
||||||
type Action struct {
|
type Action struct {
|
||||||
|
|
@ -35,7 +38,23 @@ type Action struct {
|
||||||
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
|
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
|
||||||
EnabledExpression string `koanf:"enabledExpression"`
|
EnabledExpression string `koanf:"enabledExpression"`
|
||||||
Groups []string `koanf:"groups"`
|
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.
|
// ActionGroup defines shared limits and metadata for a set of actions.
|
||||||
|
|
@ -90,6 +109,13 @@ type EntityFile struct {
|
||||||
File string `koanf:"file"`
|
File string `koanf:"file"`
|
||||||
Name string `koanf:"name"`
|
Name string `koanf:"name"`
|
||||||
Icon string `koanf:"icon"`
|
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.
|
// PermissionsList defines what users can do with an action.
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool {
|
||||||
DecoderConfig: &mapstructure.DecoderConfig{
|
DecoderConfig: &mapstructure.DecoderConfig{
|
||||||
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
||||||
envDecodeHookFunc,
|
envDecodeHookFunc,
|
||||||
|
justificationDecodeHookFunc,
|
||||||
mapstructure.StringToTimeDurationHookFunc(),
|
mapstructure.StringToTimeDurationHookFunc(),
|
||||||
mapstructure.TextUnmarshallerHookFunc(),
|
mapstructure.TextUnmarshallerHookFunc(),
|
||||||
),
|
),
|
||||||
|
|
@ -259,6 +260,18 @@ func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
|
||||||
|
|
||||||
var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`)
|
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) {
|
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)
|
log.Debugf("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data)
|
||||||
if from.Kind() != reflect.String {
|
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.sanitizeActionGroups()
|
||||||
cfg.sanitizeActionGroupReferences()
|
cfg.sanitizeActionGroupReferences()
|
||||||
|
cfg.sanitizeEntities()
|
||||||
|
|
||||||
if err := cfg.validateReservedActionArgumentNames(); err != nil {
|
if err := cfg.validateReservedActionArgumentNames(); err != nil {
|
||||||
log.Fatalf("%v", err)
|
log.Fatalf("%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := cfg.validateChecklistChoiceValues(); err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *Config) validateReservedActionArgumentNames() error {
|
func (cfg *Config) validateReservedActionArgumentNames() error {
|
||||||
|
|
@ -60,6 +65,48 @@ func (action *Action) validateReservedArgumentNames() error {
|
||||||
return nil
|
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() {
|
func (cfg *Config) sanitizeDashboardsForInlineActions() {
|
||||||
for _, dashboard := range cfg.Dashboards {
|
for _, dashboard := range cfg.Dashboards {
|
||||||
cfg.sanitizeDashboardComponentForInlineActions(dashboard)
|
cfg.sanitizeDashboardComponentForInlineActions(dashboard)
|
||||||
|
|
@ -180,6 +227,7 @@ func (action *Action) sanitize(cfg *Config) {
|
||||||
action.ID = getActionID(action)
|
action.ID = getActionID(action)
|
||||||
action.Icon = lookupHTMLIcon(action.Icon, cfg.DefaultIconForActions)
|
action.Icon = lookupHTMLIcon(action.Icon, cfg.DefaultIconForActions)
|
||||||
migrateActionOnClick(action)
|
migrateActionOnClick(action)
|
||||||
|
action.sanitizeJustification()
|
||||||
action.OnClick = sanitizeOnClick(action.OnClick, cfg)
|
action.OnClick = sanitizeOnClick(action.OnClick, cfg)
|
||||||
action.PopupOnStart = action.OnClick
|
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) {
|
func (cfg *Config) warnInvalidActionGroupReference(action *Action, groupName string) {
|
||||||
group, found := cfg.ActionGroups[groupName]
|
group, found := cfg.ActionGroups[groupName]
|
||||||
if !found {
|
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 {
|
func shouldMigrateDefaultOnClickFromPopup(onClick, popupOnStart string) bool {
|
||||||
if popupOnStart == "" {
|
if popupOnStart == "" {
|
||||||
return false
|
return false
|
||||||
|
|
@ -441,10 +517,39 @@ func (arg *ActionArgument) sanitize() {
|
||||||
}
|
}
|
||||||
|
|
||||||
arg.sanitizeNoType()
|
arg.sanitizeNoType()
|
||||||
|
arg.sanitizeChecklist()
|
||||||
|
|
||||||
// Default value validation runs in executor at config load (validateArgumentDefaults).
|
// 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() {
|
func (arg *ActionArgument) sanitizeNoType() {
|
||||||
if len(arg.Choices) == 0 && arg.Type == "" {
|
if len(arg.Choices) == 0 && arg.Type == "" {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
|
|
|
||||||
|
|
@ -271,3 +271,25 @@ func TestValidateUniqueLocalUserAPIKeys(t *testing.T) {
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
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)
|
return typecheckNull(arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if arg.Type == "checklist" {
|
||||||
|
return typecheckChecklist(value, arg)
|
||||||
|
}
|
||||||
|
|
||||||
if len(arg.Choices) > 0 {
|
if len(arg.Choices) > 0 {
|
||||||
return typecheckChoice(value, arg)
|
return typecheckChoice(value, arg)
|
||||||
}
|
}
|
||||||
|
|
@ -211,6 +215,8 @@ func TypeSafetyCheck(name string, value string, argumentType string) error {
|
||||||
return nil
|
return nil
|
||||||
case "checkbox":
|
case "checkbox":
|
||||||
return nil
|
return nil
|
||||||
|
case "checklist":
|
||||||
|
return nil
|
||||||
case "email":
|
case "email":
|
||||||
return typeSafetyCheckEmail(value)
|
return typeSafetyCheckEmail(value)
|
||||||
case "url":
|
case "url":
|
||||||
|
|
@ -230,6 +236,37 @@ func typecheckNull(arg *config.ActionArgument) error {
|
||||||
return nil
|
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 {
|
func typecheckChoice(value string, arg *config.ActionArgument) error {
|
||||||
if arg.Entity != "" {
|
if arg.Entity != "" {
|
||||||
return typecheckChoiceEntity(value, arg)
|
return typecheckChoiceEntity(value, arg)
|
||||||
|
|
@ -277,11 +314,17 @@ func typeSafetyCheckDatetime(value string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func anchorCustomRegexPattern(pattern string) string {
|
||||||
|
return "^(?:" + pattern + ")$"
|
||||||
|
}
|
||||||
|
|
||||||
func typeSafetyCheckRegex(name string, value string, argumentType string) error {
|
func typeSafetyCheckRegex(name string, value string, argumentType string) error {
|
||||||
pattern := ""
|
pattern := ""
|
||||||
|
isCustomRegex := strings.HasPrefix(argumentType, "regex:")
|
||||||
|
|
||||||
if strings.HasPrefix(argumentType, "regex:") {
|
if isCustomRegex {
|
||||||
pattern = strings.Replace(argumentType, "regex:", "", 1)
|
pattern = strings.TrimPrefix(argumentType, "regex:")
|
||||||
|
pattern = anchorCustomRegexPattern(pattern)
|
||||||
} else {
|
} else {
|
||||||
found := false
|
found := false
|
||||||
pattern, found = typecheckRegex[argumentType]
|
pattern, found = typecheckRegex[argumentType]
|
||||||
|
|
@ -308,21 +351,50 @@ func typeSafetyCheckRegex(name string, value string, argumentType string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func typeSafetyCheckUrl(value string) error {
|
func typeSafetyCheckUrl(value string) error {
|
||||||
_, err := url.ParseRequestURI(value)
|
parsed, err := url.ParseRequestURI(value)
|
||||||
|
if err != nil {
|
||||||
return err
|
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 {
|
func checkShellArgumentSafety(action *config.Action) error {
|
||||||
if action.Shell == "" {
|
if action.Shell == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
unsafe := map[string]struct{}{"url": {}, "email": {}, "raw_string_multiline": {}, "very_dangerous_raw_string": {}, "password": {}}
|
|
||||||
for _, arg := range action.Arguments {
|
for i := range action.Arguments {
|
||||||
if _, bad := unsafe[arg.Type]; bad {
|
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 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,6 +405,7 @@ func mangleInvalidArgumentValues(req *ExecutionRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
mangleCheckboxValues(req, &arg)
|
mangleCheckboxValues(req, &arg)
|
||||||
|
mangleChecklistValues(req, &arg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -389,16 +462,21 @@ func MangleArgumentValue(arg *config.ActionArgument, value string, actionTitle s
|
||||||
return value
|
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)
|
return mangleDatetimeValue(arg, value, actionTitle)
|
||||||
}
|
case "checkbox":
|
||||||
|
|
||||||
if arg.Type == "checkbox" {
|
|
||||||
return mangleCheckboxValue(arg, value, actionTitle)
|
return mangleCheckboxValue(arg, value, actionTitle)
|
||||||
}
|
case "checklist":
|
||||||
|
return mangleChecklistValue(arg, value, actionTitle)
|
||||||
|
default:
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mangleDatetimeValue(arg *config.ActionArgument, value string, actionTitle string) string {
|
func mangleDatetimeValue(arg *config.ActionArgument, value string, actionTitle string) string {
|
||||||
if arg == nil {
|
if arg == nil {
|
||||||
|
|
@ -430,6 +508,96 @@ func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle s
|
||||||
return value
|
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 {
|
for _, choice := range arg.Choices {
|
||||||
if value == choice.Title {
|
if value == choice.Title {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
|
|
@ -437,7 +605,7 @@ func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle s
|
||||||
"oldValue": value,
|
"oldValue": value,
|
||||||
"newValue": choice.Value,
|
"newValue": choice.Value,
|
||||||
"actionTitle": actionTitle,
|
"actionTitle": actionTitle,
|
||||||
}).Infof("Mangled checkbox value")
|
}).Infof("Mangled choice segment")
|
||||||
|
|
||||||
return choice.Value
|
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")
|
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 {
|
func newExecRequest() *ExecutionRequest {
|
||||||
return &ExecutionRequest{
|
return &ExecutionRequest{
|
||||||
Arguments: make(map[string]string),
|
Arguments: make(map[string]string),
|
||||||
|
|
@ -336,13 +499,72 @@ func TestCheckShellArgumentSafetyWithPasswordAndExec(t *testing.T) {
|
||||||
assert.Nil(t, err)
|
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) {
|
func TestTypeSafetyCheckUrl(t *testing.T) {
|
||||||
assert.Nil(t, TypeSafetyCheck("test1", "http://google.com", "url"), "Test URL: google.com")
|
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("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("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("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("test5", "12345", "url"), "Test a badly formed URL")
|
||||||
assert.NotNil(t, TypeSafetyCheck("test6", "_!23;", "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) {
|
func TestTypeSafetyCheckRegex(t *testing.T) {
|
||||||
|
|
@ -367,6 +589,20 @@ func TestTypeSafetyCheckRegex(t *testing.T) {
|
||||||
value: "James1234",
|
value: "James1234",
|
||||||
hasError: true,
|
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 {
|
for _, tt := range tests {
|
||||||
|
|
|
||||||
|
|
@ -1238,24 +1238,70 @@ func stepExecAfter(req *ExecutionRequest) bool {
|
||||||
return true
|
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 == "" {
|
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
|
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)
|
args, err := buildShellAfterArgs(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
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 {
|
if err != nil {
|
||||||
msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n"
|
return nil, nil, err
|
||||||
req.mutateLogEntry(func(entry *InternalLogEntry) {
|
|
||||||
entry.Output += msg
|
|
||||||
})
|
|
||||||
log.Warn(msg)
|
|
||||||
return nil, nil, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := wrapCommandInShell(ctx, finalParsedCommand)
|
cmd := wrapCommandInShell(ctx, finalParsedCommand)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package executor
|
package executor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -385,6 +387,67 @@ func TestWebhookAllowsExecExecution(t *testing.T) {
|
||||||
assert.Contains(t, req.logEntry.Output, "hello")
|
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) {
|
func TestFilterToDefinedArgumentsOnly(t *testing.T) {
|
||||||
req := newExecRequest()
|
req := newExecRequest()
|
||||||
req.Binding.Action = &config.Action{
|
req.Binding.Action = &config.Action{
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ func ResolveJustification(req *ExecutionRequest) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func actionRequiresJustification(req *ExecutionRequest) bool {
|
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 {
|
func defaultJustificationForRequest(req *ExecutionRequest) string {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
|
|
||||||
func TestResolveJustificationUsesProvidedValue(t *testing.T) {
|
func TestResolveJustificationUsesProvidedValue(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
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)
|
cfg.Actions = append(cfg.Actions, action)
|
||||||
ex := DefaultExecutor(cfg)
|
ex := DefaultExecutor(cfg)
|
||||||
ex.RebuildActionMap()
|
ex.RebuildActionMap()
|
||||||
|
|
@ -29,7 +29,7 @@ func TestResolveJustificationUsesProvidedValue(t *testing.T) {
|
||||||
|
|
||||||
func TestResolveJustificationCronDefault(t *testing.T) {
|
func TestResolveJustificationCronDefault(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
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)
|
cfg.Actions = append(cfg.Actions, action)
|
||||||
ex := DefaultExecutor(cfg)
|
ex := DefaultExecutor(cfg)
|
||||||
ex.RebuildActionMap()
|
ex.RebuildActionMap()
|
||||||
|
|
@ -45,7 +45,7 @@ func TestResolveJustificationCronDefault(t *testing.T) {
|
||||||
|
|
||||||
func TestResolveJustificationStartupDefault(t *testing.T) {
|
func TestResolveJustificationStartupDefault(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
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)
|
cfg.Actions = append(cfg.Actions, action)
|
||||||
ex := DefaultExecutor(cfg)
|
ex := DefaultExecutor(cfg)
|
||||||
ex.RebuildActionMap()
|
ex.RebuildActionMap()
|
||||||
|
|
@ -61,7 +61,7 @@ func TestResolveJustificationStartupDefault(t *testing.T) {
|
||||||
|
|
||||||
func TestResolveJustificationWebhookDefault(t *testing.T) {
|
func TestResolveJustificationWebhookDefault(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
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)
|
cfg.Actions = append(cfg.Actions, action)
|
||||||
ex := DefaultExecutor(cfg)
|
ex := DefaultExecutor(cfg)
|
||||||
ex.RebuildActionMap()
|
ex.RebuildActionMap()
|
||||||
|
|
@ -95,7 +95,7 @@ func TestJustificationNotPassedToShellArgs(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
action := &config.Action{
|
action := &config.Action{
|
||||||
Title: "Echo",
|
Title: "Echo",
|
||||||
Justification: true,
|
Justification: config.JustificationRequiredNoTemplate,
|
||||||
Shell: "echo {{ message }}",
|
Shell: "echo {{ message }}",
|
||||||
Arguments: []config.ActionArgument{
|
Arguments: []config.ActionArgument{
|
||||||
{Name: "message", Type: "ascii_sentence"},
|
{Name: "message", Type: "ascii_sentence"},
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,29 @@ func TestStorableArgumentsFromRequestStoresMangledCheckboxValue(t *testing.T) {
|
||||||
assert.Equal(t, "1", args["mode"])
|
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) {
|
func TestCopyStorableArgumentsToLogEntry(t *testing.T) {
|
||||||
req := newExecRequest()
|
req := newExecRequest()
|
||||||
req.logEntry = &InternalLogEntry{}
|
req.logEntry = &InternalLogEntry{}
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ actions:
|
||||||
# Using a path under the user's home is more natural on macOS.
|
# Using a path under the user's home is more natural on macOS.
|
||||||
- title: Delete old backups
|
- title: Delete old backups
|
||||||
icon: ashtonished
|
icon: ashtonished
|
||||||
justification: true
|
justification: " "
|
||||||
shell: rm -rf "$HOME/Backups/old/"
|
shell: rm -rf "$HOME/Backups/old/"
|
||||||
arguments:
|
arguments:
|
||||||
- name: confirm
|
- name: confirm
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue