This commit is contained in:
James Read 2026-06-28 23:45:05 +00:00 committed by GitHub
commit b58f769bda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 2255 additions and 204 deletions

View File

@ -13,6 +13,7 @@ on:
- 'integration-tests/**' - 'integration-tests/**'
- 'proto/**' - 'proto/**'
- 'service/**' - 'service/**'
- 'var/windows/**'
workflow_dispatch: workflow_dispatch:
push: push:
tags: tags:
@ -31,6 +32,7 @@ on:
- 'integration-tests/**' - 'integration-tests/**'
- 'proto/**' - 'proto/**'
- 'service/**' - 'service/**'
- 'var/windows/**'
jobs: jobs:
build: build:
@ -73,13 +75,15 @@ jobs:
run: go version run: go version
- name: Login to Docker Hub - name: Login to Docker Hub
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
uses: docker/login-action@v4 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_KEY }} password: ${{ secrets.DOCKERHUB_KEY }}
- name: Login to ghcr - name: Login to ghcr
uses: docker/login-action@v3 if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@ -110,17 +114,23 @@ jobs:
integration-tests integration-tests
!integration-tests/node_modules !integration-tests/node_modules
- name: Install msitools
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
run: sudo apt-get update && sudo apt-get install -y msitools
- name: Install goreleaser - name: Install goreleaser
uses: goreleaser/goreleaser-action@v6 if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
uses: goreleaser/goreleaser-action@v7
with: with:
install-only: true install-only: true
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
uses: docker/setup-buildx-action@v4
- name: release - name: release
if: github.ref_type != 'tag' if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
uses: cycjimmy/semantic-release-action@v4 uses: cycjimmy/semantic-release-action@v5
with: with:
extra_plugins: | extra_plugins: |
@semantic-release/commit-analyzer @semantic-release/commit-analyzer
@ -131,8 +141,8 @@ jobs:
GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }} GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
- name: Archive binaries - name: Archive binaries
uses: actions/upload-artifact@v4.3.1 if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
uses: actions/upload-artifact@v7
with: with:
name: "OliveTin-snapshot-${{ env.DATE }}-${{ github.sha }}" name: "OliveTin-snapshot-${{ env.DATE }}-${{ github.sha }}"
path: dist/OliveTin*.* path: dist/OliveTin*.*

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
service/OliveTin service/OliveTin
service/OliveTin.armhf service/OliveTin.armhf
service/OliveTin.exe service/OliveTin.exe
service/resource_windows_*.syso
service/reports service/reports
releases/ releases/
dist/ dist/

View File

@ -3,6 +3,7 @@ version: 2
before: before:
hooks: hooks:
- make service-prep - make service-prep
- make windows-resources VERSION={{ .Version }}
builds: builds:
- env: - env:
@ -42,6 +43,8 @@ builds:
checksum: checksum:
name_template: 'checksums.txt' name_template: 'checksums.txt'
extra_files:
- glob: ./dist/OliveTin-windows-amd64.msi
snapshot: snapshot:
version_template: "{{ .Branch }}-{{ .ShortCommit }}" version_template: "{{ .Branch }}-{{ .ShortCommit }}"
changelog: changelog:
@ -175,6 +178,8 @@ nfpms:
dst: /usr/share/man/man1/OliveTin.1.gz dst: /usr/share/man/man1/OliveTin.1.gz
release: release:
extra_files:
- glob: ./dist/OliveTin-windows-amd64.msi
footer: | footer: |
## Container images (from GitHub) ## Container images (from GitHub)

View File

@ -9,6 +9,6 @@ plugins:
- '@semantic-release/git' - '@semantic-release/git'
- - "@semantic-release/exec" - - "@semantic-release/exec"
- publishCmd: | - publishCmd: |
goreleaser release --clean --timeout 60m VERSION=${nextRelease.version} GORELEASER_TIMEOUT=60m ./var/windows/goreleaser-release-with-msi.sh
tagFormat: '${version}' tagFormat: '${version}'

View File

@ -8,6 +8,12 @@ service:
service-prep: service-prep:
$(MAKE) -wC service prep $(MAKE) -wC service prep
windows-resources:
VERSION="$(VERSION)" ./var/windows/generate-resources.sh
windows-msi:
VERSION="$(VERSION)" ./var/windows/build-msi.sh
service-unittests: service-unittests:
$(MAKE) -wC service unittests $(MAKE) -wC service unittests
@ -59,10 +65,11 @@ clean:
$(call delete-files,OliveTin) $(call delete-files,OliveTin)
$(call delete-files,OliveTin.armhf) $(call delete-files,OliveTin.armhf)
$(call delete-files,OliveTin.exe) $(call delete-files,OliveTin.exe)
rm -f service/resource_windows_*.syso
$(call delete-files,reports) $(call delete-files,reports)
$(call delete-files,gen) $(call delete-files,gen)
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 .PHONY: proto service windows-resources windows-msi

View File

@ -118,7 +118,7 @@ actions:
# Docs: https://docs.olivetin.app/solutions/container-control-panel/index.html # Docs: https://docs.olivetin.app/solutions/container-control-panel/index.html
- title: Restart Docker Container - title: Restart Docker Container
icon: restart icon: restart
shell: docker restart {{ .CurrentEntity }} shell: docker restart {{ container }}
arguments: arguments:
- name: container - name: container
title: Container name title: Container name

View File

@ -25,16 +25,60 @@ This will give you metrics available at http://yourserver:1337/metrics. The page
[source] [source]
---- ----
# HELP olivetin_actions_requested_count The actions requested count # HELP olivetin_actions_requested_count The actions requested count
# TYPE olivetin_actions_requested_count gauge # TYPE olivetin_actions_requested_count counter
olivetin_actions_requested_count 0 olivetin_actions_requested_count 0
# HELP olivetin_action_executions_total Total number of finished action executions grouped by result.
# TYPE olivetin_action_executions_total counter
olivetin_action_executions_total{result="success"} 0
olivetin_action_executions_total{result="failed"} 0
olivetin_action_executions_total{result="blocked"} 0
olivetin_action_executions_total{result="timeout"} 0
olivetin_action_executions_total{result="error"} 0
# HELP olivetin_action_execution_duration_seconds Action execution duration in seconds from start to finish.
# TYPE olivetin_action_execution_duration_seconds histogram
olivetin_action_execution_duration_seconds_bucket{le="0.1"} 0
# HELP olivetin_config_action_count Then number of actions in the config file # HELP olivetin_config_action_count Then number of actions in the config file
# TYPE olivetin_config_action_count gauge # TYPE olivetin_config_action_count gauge
olivetin_config_action_count 18 olivetin_config_action_count 18
# HELP olivetin_config_reloaded_count The number of times the config has been reloaded # HELP olivetin_config_reloaded_count The number of times the config has been reloaded
# TYPE olivetin_config_reloaded_count counter # TYPE olivetin_config_reloaded_count counter
olivetin_config_reloaded_count 1 olivetin_config_reloaded_count 1
# HELP olivetin_sv_count The number entries in the sv map ----
# TYPE olivetin_sv_count gauge
olivetin_sv_count 49 === Failed job monitoring
Finished action executions are counted in `olivetin_action_executions_total` with a `result` label:
[cols="1,2"]
|===
| `success` | Command finished with exit code 0
| `failed` | Command ran but exited with a non-zero code
| `timeout` | Command exceeded its configured timeout
| `blocked` | Execution was blocked (ACL, rate limit, concurrency, or queue limit)
| `error` | Execution failed before the command ran (for example, invalid arguments)
|===
`olivetin_action_execution_duration_seconds` records how long each finished execution took.
Example Prometheus alert rules:
[source,yaml]
----
groups:
- name: olivetin
rules:
- alert: OliveTinActionFailed
expr: increase(olivetin_action_executions_total{result="failed"}[15m]) > 0
labels:
severity: warning
annotations:
summary: OliveTin action failed with non-zero exit code
- alert: OliveTinActionTimedOut
expr: increase(olivetin_action_executions_total{result="timeout"}[15m]) > 0
labels:
severity: warning
annotations:
summary: OliveTin action timed out
---- ----

View File

@ -1,18 +1,31 @@
= Example: Some actions require admin = Example: Some actions require admin
A common use case for OliveTin with security is to expose some actions to guests, and have some actions that require login to be able to use. This page brings together the configuration options that are needed to achieve this. The most important configuration option is setting `authRequireGuestsToLogin` to `true`. A common use case for OliveTin with security is to expose some actions to guests, and have some actions that require login to be able to use. This page brings together the configuration options that are needed to achieve this.
== How ACL permissions work
OliveTin ACLs are *allow lists*, not deny lists. Each action starts with `defaultPermissions`, and then any ACLs listed on that action can *grant* access for matching users. An ACL with `view: false` does not deny access — it simply does not grant it. If no relevant ACL grants a permission, OliveTin falls back to `defaultPermissions`.
The default `defaultPermissions` allow guests to view and execute every action. To restrict some actions to logged-in admins while leaving others open to guests, set `defaultPermissions` to deny access by default, then use ACLs to explicitly grant access on each action.
See xref:security/acl.adoc[Access Control Lists] for the full ACL reference.
== Full example configuration == Full example configuration
```yaml ```yaml
logLevel: "INFO" logLevel: "INFO"
accessControlLists: defaultPermissions:
- name: "noguests"
permissions:
view: false view: false
exec: false exec: false
logs: false logs: false
accessControlLists:
- name: "guests"
permissions:
view: true
exec: true
logs: false
matchUsernames: [ "guest" ] matchUsernames: [ "guest" ]
- name: "admins" - name: "admins"
@ -32,11 +45,12 @@ authLocalUsers:
actions: actions:
- title: "Date" - title: "Date"
shell: date shell: date
acls:
- "guests"
- title: "Reboot" - title: "Reboot"
shell: reboot" # Note that this won't work inside a container shell: reboot # Note that this won't work inside a container
acls: acls:
- "noguests"
- "admins" - "admins"
dashboards: dashboards:
@ -50,3 +64,33 @@ dashboards:
``` ```
Note, to use this configuration, you will need to replace `-- your password hash here --` with a password hash. You can generate a password hash by looking at the options in the xref:security/local.adoc[local-users] configuration section. Note, to use this configuration, you will need to replace `-- your password hash here --` with a password hash. You can generate a password hash by looking at the options in the xref:security/local.adoc[local-users] configuration section.
With this configuration:
* Guests (not logged in) can view and run the *Date* action only.
* Logged-in users in the `admins` usergroup can view and run the *Reboot* action.
* Guests are not forced to log in — they simply do not see or cannot run actions that only list the `admins` ACL.
== Common mistake: using a deny ACL for guests
A configuration like the one below does *not* work as a deny rule when guests are allowed to browse without logging in:
[source,yaml]
----
accessControlLists:
- name: "noguests"
permissions:
view: false
exec: false
matchUsernames: [ "guest" ]
actions:
- title: "Reboot"
acls:
- "noguests"
- "admins"
----
Because `view: false` does not deny access, guests still fall back to `defaultPermissions` (which default to `true`) and can see the action. Setting `authRequireGuestsToLogin: true` makes that pattern appear to work, but only because it forces all guests to log in first and sets all `defaultPermissions` to `false`. If you need mixed guest and admin access without forcing login, use the allow-list pattern in the full example above instead.
If you want *every* action to require login, see xref:security/example_login_required.adoc[Example: Force Login].

View File

@ -1,4 +1,4 @@
// @generated by protoc-gen-es v2.12.0 // @generated by protoc-gen-es v2.12.1
// @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3) // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
/* eslint-disable */ /* eslint-disable */
@ -751,6 +751,11 @@ export declare type LogEntry = Message<"olivetin.api.v1.LogEntry"> & {
* @generated from field: string justification = 23; * @generated from field: string justification = 23;
*/ */
justification: string; justification: string;
/**
* @generated from field: repeated olivetin.api.v1.StartActionArgument arguments = 24;
*/
arguments: StartActionArgument[];
}; };
/** /**

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,345 @@
<template>
<div class="choice-combobox" ref="rootRef">
<input
ref="searchInputRef"
:id="id"
type="text"
class="choice-combobox-input"
role="combobox"
autocomplete="off"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-activedescendant="activeDescendantId"
:placeholder="placeholderText"
:value="query"
:required="required"
@focus="handleFocus"
@input="handleSearchInput"
@keydown="handleKeydown"
@blur="handleBlur"
/>
<input
:name="name"
type="hidden"
:value="modelValue"
/>
<ul
v-if="isOpen && filteredChoices.length > 0"
:id="listboxId"
role="listbox"
class="choice-combobox-list"
>
<li
v-for="(choice, index) in filteredChoices"
:id="`${listboxId}-option-${index}`"
:key="choice.value"
role="option"
:aria-selected="choice.value === modelValue"
:class="{
highlighted: index === highlightedIndex,
selected: choice.value === modelValue
}"
@mousedown.prevent="selectChoice(choice)"
>
{{ choiceLabel(choice) }}
</li>
</ul>
<div v-else-if="isOpen && query" class="choice-combobox-list choice-combobox-empty">
No matching options
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
choiceDisplayLabel,
syncStateFromModelValue
} from './choiceComboboxHelpers.js'
const props = defineProps({
id: {
type: String,
required: true
},
name: {
type: String,
required: true
},
choices: {
type: Array,
required: true
},
modelValue: {
type: String,
default: ''
},
required: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const closeOthersEvent = 'olivetin-choice-combobox-close-others'
const rootRef = ref(null)
const searchInputRef = ref(null)
const isOpen = ref(false)
const query = ref('')
const highlightedIndex = ref(0)
const listboxId = computed(() => `${props.id}-listbox`)
const activeDescendantId = computed(() => {
if (!isOpen.value || filteredChoices.value.length === 0) {
return undefined
}
return `${listboxId.value}-option-${highlightedIndex.value}`
})
const placeholderText = computed(() => {
if (props.required) {
return 'Search and select...'
}
return 'Search options...'
})
const filteredChoices = computed(() => {
const search = query.value.trim().toLowerCase()
if (!search) {
return props.choices
}
return props.choices.filter(choice => {
const label = choiceLabel(choice).toLowerCase()
const value = String(choice.value).toLowerCase()
return label.includes(search) || value.includes(search)
})
})
watch([() => props.modelValue, () => props.choices], () => {
if (!isOpen.value) {
syncFromModelValue()
}
}, { immediate: true })
function choiceLabel(choice) {
return choiceDisplayLabel(choice)
}
function syncFromModelValue() {
const next = syncStateFromModelValue(props.choices, props.modelValue)
query.value = next.query
if (next.modelValue !== props.modelValue) {
emitValue(next.modelValue)
}
}
function openList() {
document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } }))
isOpen.value = true
highlightedIndex.value = 0
}
function closeList() {
isOpen.value = false
syncFromModelValue()
}
function emitValue(value) {
emit('update:modelValue', value)
}
function selectChoice(choice) {
emitValue(choice.value)
query.value = choiceLabel(choice)
isOpen.value = false
}
function handleFocus() {
if (!isOpen.value) {
syncFromModelValue()
}
openList()
}
function handleSearchInput(event) {
query.value = event.target.value
openList()
highlightedIndex.value = 0
}
function moveHighlight(delta) {
if (filteredChoices.value.length === 0) {
return
}
const nextIndex = highlightedIndex.value + delta
if (nextIndex < 0) {
highlightedIndex.value = filteredChoices.value.length - 1
return
}
if (nextIndex >= filteredChoices.value.length) {
highlightedIndex.value = 0
return
}
highlightedIndex.value = nextIndex
}
function handleKeydown(event) {
if (event.key === 'ArrowDown') {
event.preventDefault()
const wasOpen = isOpen.value
openList()
if (wasOpen) {
moveHighlight(1)
}
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
const wasOpen = isOpen.value
openList()
if (wasOpen) {
moveHighlight(-1)
} else if (filteredChoices.value.length > 0) {
highlightedIndex.value = filteredChoices.value.length - 1
}
return
}
if (event.key === 'Enter') {
if (!isOpen.value || filteredChoices.value.length === 0) {
return
}
event.preventDefault()
selectChoice(filteredChoices.value[highlightedIndex.value])
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeList()
searchInputRef.value?.blur()
}
}
function handleBlur() {
closeList()
}
function handleCloseOthers(event) {
if (event.detail.id !== props.id) {
closeList()
}
}
function handleOutsideMouseDown(event) {
if (!isOpen.value || rootRef.value?.contains(event.target)) {
return
}
closeList()
}
watch(isOpen, open => {
if (open) {
document.addEventListener('mousedown', handleOutsideMouseDown, true)
return
}
document.removeEventListener('mousedown', handleOutsideMouseDown, true)
})
onMounted(() => {
document.addEventListener(closeOthersEvent, handleCloseOthers)
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleOutsideMouseDown, true)
document.removeEventListener(closeOthersEvent, handleCloseOthers)
})
</script>
<style scoped>
.choice-combobox {
position: relative;
width: 100%;
}
.choice-combobox:focus-within {
z-index: 11;
}
.choice-combobox-input {
width: 100%;
}
.choice-combobox-list {
position: absolute;
z-index: 10;
left: 0;
right: 0;
max-height: 12rem;
overflow-y: auto;
margin: 0.125rem 0 0;
padding: 0;
list-style: none;
border: 1px solid var(--border-color, #ccc);
border-radius: 0.25rem;
background: var(--standout-bg-color, #fff);
color: var(--text-color, inherit);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.choice-combobox-list li {
padding: 0.375rem 0.5rem;
cursor: pointer;
}
.choice-combobox-list li.highlighted,
.choice-combobox-list li:hover {
background: var(--hover-background-color, #eef3ff);
color: var(--hover-text-color, inherit);
}
.choice-combobox-list li.selected {
font-weight: 600;
}
.choice-combobox-empty {
padding: 0.375rem 0.5rem;
color: var(--disabled-text-color, #666);
font-size: 0.875rem;
}
@media (prefers-color-scheme: dark) {
.choice-combobox-list,
.choice-combobox-empty {
background-color: #4e4e4e;
color: #ddd;
border-color: var(--border-color, #595959);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
}
.choice-combobox-list li.highlighted,
.choice-combobox-list li:hover {
background-color: var(--hover-background-color, #1d345c);
color: #fff;
}
.choice-combobox-empty {
color: var(--disabled-text-color, #999);
}
}
</style>

View File

@ -0,0 +1,33 @@
export function findSelectedChoice (choices, modelValue) {
if (!modelValue) {
return null
}
return choices.find(choice => choice.value === modelValue) ?? null
}
export function choiceDisplayLabel (choice) {
return choice.title || choice.value
}
export function displayLabelForModelValue (choices, modelValue) {
const match = findSelectedChoice(choices, modelValue)
return match ? choiceDisplayLabel(match) : ''
}
export function normalizedModelValue (choices, modelValue) {
if (!modelValue) {
return ''
}
return findSelectedChoice(choices, modelValue) ? modelValue : ''
}
export function syncStateFromModelValue (choices, modelValue) {
const normalizedValue = normalizedModelValue(choices, modelValue)
return {
query: displayLabelForModelValue(choices, normalizedValue),
modelValue: normalizedValue
}
}

View File

@ -0,0 +1,40 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
displayLabelForModelValue,
normalizedModelValue,
syncStateFromModelValue
} from './choiceComboboxHelpers.js'
const choices = [
{ title: 'Production', value: 'prod' },
{ title: 'Staging', value: 'stage' }
]
test('displayLabelForModelValue returns the choice label for valid values', () => {
assert.equal(displayLabelForModelValue(choices, 'prod'), 'Production')
})
test('displayLabelForModelValue clears invalid enum values instead of echoing them', () => {
assert.equal(displayLabelForModelValue(choices, 'missing'), '')
})
test('normalizedModelValue keeps valid values and clears invalid ones', () => {
assert.equal(normalizedModelValue(choices, 'stage'), 'stage')
assert.equal(normalizedModelValue(choices, 'missing'), '')
assert.equal(normalizedModelValue(choices, ''), '')
})
test('syncStateFromModelValue clears invalid selections for closed-state sync', () => {
assert.deepEqual(syncStateFromModelValue(choices, 'missing'), {
query: '',
modelValue: ''
})
})
test('syncStateFromModelValue preserves valid selections for closed-state sync', () => {
assert.deepEqual(syncStateFromModelValue(choices, 'stage'), {
query: 'Staging',
modelValue: 'stage'
})
})

View File

@ -0,0 +1,68 @@
import { needsArgumentForm } from './needsArgumentForm.js'
const nonStorableArgumentTypes = new Set([
'password',
'very_dangerous_raw_string'
])
function isNonStorableArgumentType (type) {
return nonStorableArgumentTypes.has(type)
}
export function logEntryArgumentsToStartActionArgs (logEntry) {
return (logEntry?.arguments ?? []).map((arg) => ({
name: arg.name,
value: arg.value
}))
}
export function rerunNeedsArgumentForm (action, logEntry) {
if (action?.justification && !logEntry?.justification) {
return true
}
if (!needsArgumentForm(action)) {
return false
}
return hasMissingRerunArguments(action, logEntry?.arguments ?? [])
}
export function hasMissingRerunArguments (action, storedArgs) {
const stored = new Map(storedArgs.map((arg) => [arg.name, arg.value]))
for (const arg of action?.arguments ?? []) {
if (isNonStorableArgumentType(arg.type)) {
return true
}
if (arg.required && !stored.has(arg.name)) {
return true
}
}
return false
}
export function buildArgumentFormQuery (logEntry) {
const query = {}
for (const arg of logEntry?.arguments ?? []) {
query[arg.name] = arg.value
}
return query
}
export function buildRerunStartActionArgs (bindingId, logEntry, action) {
const startActionArgs = {
bindingId,
arguments: logEntryArgumentsToStartActionArgs(logEntry)
}
if (action?.justification && logEntry?.justification) {
startActionArgs.justification = logEntry.justification
}
return startActionArgs
}

View File

@ -0,0 +1,117 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildArgumentFormQuery,
buildRerunStartActionArgs,
hasMissingRerunArguments,
logEntryArgumentsToStartActionArgs,
rerunNeedsArgumentForm
} from '../utils/rerunArguments.js'
test('logEntryArgumentsToStartActionArgs maps proto arguments for StartAction', () => {
assert.deepEqual(
logEntryArgumentsToStartActionArgs({
arguments: [
{ name: 'host', value: 'example.com' },
{ name: 'port', value: '443' }
]
}),
[
{ name: 'host', value: 'example.com' },
{ name: 'port', value: '443' }
]
)
})
test('hasMissingRerunArguments requires password fields to be re-entered', () => {
const action = {
arguments: [
{ name: 'user', type: 'ascii_identifier', required: true },
{ name: 'pass', type: 'password', required: true }
]
}
assert.equal(
hasMissingRerunArguments(action, [{ name: 'user', value: 'alice' }]),
true
)
})
test('hasMissingRerunArguments requires very_dangerous_raw_string fields to be re-entered', () => {
const action = {
arguments: [
{ name: 'host', type: 'ascii_identifier', required: true },
{ name: 'payload', type: 'very_dangerous_raw_string', required: false }
]
}
assert.equal(
hasMissingRerunArguments(action, [{ name: 'host', value: 'db-1' }]),
true
)
})
test('hasMissingRerunArguments detects missing required stored arguments', () => {
const action = {
arguments: [{ name: 'host', type: 'ascii_identifier', required: true }]
}
assert.equal(hasMissingRerunArguments(action, []), true)
assert.equal(
hasMissingRerunArguments(action, [{ name: 'host', value: 'db-1' }]),
false
)
})
test('rerunNeedsArgumentForm can start directly when stored args are complete', () => {
const action = {
arguments: [{ name: 'host', type: 'ascii_identifier', required: true }]
}
const logEntry = {
arguments: [{ name: 'host', value: 'db-1' }]
}
assert.equal(rerunNeedsArgumentForm(action, logEntry), false)
})
test('rerunNeedsArgumentForm opens the form when justification is missing', () => {
const action = { justification: true, arguments: [] }
assert.equal(rerunNeedsArgumentForm(action, {}), true)
assert.equal(
rerunNeedsArgumentForm(action, { justification: 'approved change' }),
false
)
})
test('buildRerunStartActionArgs includes stored justification', () => {
assert.deepEqual(
buildRerunStartActionArgs('binding-1', {
arguments: [{ name: 'host', value: 'db-1' }],
justification: 'maintenance window'
}, {
justification: true,
arguments: [{ name: 'host', type: 'ascii_identifier' }]
}),
{
bindingId: 'binding-1',
arguments: [{ name: 'host', value: 'db-1' }],
justification: 'maintenance window'
}
)
})
test('buildArgumentFormQuery prefills non-password stored arguments', () => {
assert.deepEqual(
buildArgumentFormQuery({
arguments: [
{ name: 'host', value: 'db-1' },
{ name: 'port', value: '5432' }
]
}),
{
host: 'db-1',
port: '5432'
}
)
})

View File

@ -21,12 +21,9 @@
</option> </option>
</datalist> </datalist>
<select v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)" <ChoiceCombobox v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name"
:required="arg.required" @input="handleInput(arg, $event)" @change="handleChange(arg, $event)"> :choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
<option v-for="choice in arg.choices" :key="choice.value" :value="choice.value"> @update:model-value="handleChoiceUpdate(arg, $event)" />
{{ choice.title || choice.value }}
</option>
</select>
<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)"
@ -67,6 +64,7 @@
import { ref, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue' import { ref, 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'
const router = useRouter() const router = useRouter()
@ -240,6 +238,12 @@ function handleChange(arg, event) {
validateArgument(arg, event.target.value) validateArgument(arg, event.target.value)
} }
function handleChoiceUpdate(arg, value) {
argValues.value[arg.name] = value
updateUrlWithArg(arg.name, value)
validateArgument(arg, value)
}
async function validateArgument(arg, value) { async function validateArgument(arg, value) {
if (!arg.type || arg.type.startsWith('regex:')) { if (!arg.type || arg.type.startsWith('regex:')) {
return return

View File

@ -1,10 +1,12 @@
import { describe, it, before, after } from 'mocha' import { describe, it, before, after } from 'mocha'
import { expect } from 'chai' import { expect } from 'chai'
import { By, until, Condition } from 'selenium-webdriver' import { By, until, Condition, Key } from 'selenium-webdriver'
import { import {
getRootAndWait, getRootAndWait,
getActionButtons, getActionButtons,
takeScreenshotOnFailure, takeScreenshotOnFailure,
waitForArgumentFormPage,
waitForArgumentFormReady,
} from '../../lib/elements.js' } from '../../lib/elements.js'
@ -46,16 +48,30 @@ describe('config: multipleDropdowns', function () {
await button.click() await button.click()
// Wait for navigation to argument form page await waitForArgumentFormPage(8000)
await webdriver.wait(new Condition('wait for argument form page', async () => { await waitForArgumentFormReady(10000)
const url = await webdriver.getCurrentUrl()
return url.includes('/actionBinding/') && url.includes('/argumentForm')
}), 8000)
const selects = await webdriver.findElements(By.css('main select')) await webdriver.wait(new Condition('wait for choice comboboxes', async () => {
const boxes = await webdriver.findElements(By.css('main .choice-combobox'))
return boxes.length >= 2
}), 10000)
expect(selects).to.have.length(2) const comboboxes = await webdriver.findElements(By.css('main .choice-combobox'))
expect(await selects[0].findElements(By.tagName('option'))).to.have.length(2)
expect(await selects[1].findElements(By.tagName('option'))).to.have.length(3) expect(comboboxes).to.have.length(2)
const firstInput = await comboboxes[0].findElement(By.css('.choice-combobox-input'))
await firstInput.click()
await webdriver.wait(new Condition('wait for first combobox list', async () => {
const lists = await comboboxes[0].findElements(By.css('.choice-combobox-list li'))
return lists.length === 2
}), 2000)
await firstInput.sendKeys(Key.TAB)
await webdriver.wait(new Condition('wait for second combobox list', async () => {
const lists = await comboboxes[1].findElements(By.css('.choice-combobox-list li'))
return lists.length === 3
}), 2000)
}) })
}) })

View File

@ -8,6 +8,8 @@ import {
let metrics = [ let metrics = [
{'name': 'olivetin_actions_requested_count', 'type': 'counter', 'desc': 'The actions requested count'}, {'name': 'olivetin_actions_requested_count', 'type': 'counter', 'desc': 'The actions requested count'},
{'name': 'olivetin_action_executions_total', 'type': 'counter', 'desc': 'Total number of finished action executions grouped by result\\.'},
{'name': 'olivetin_action_execution_duration_seconds', 'type': 'histogram', 'desc': 'Action execution duration in seconds from start to finish\\.'},
{'name': 'olivetin_config_action_count', 'type': 'gauge', 'desc': 'The number of actions in the config file'}, {'name': 'olivetin_config_action_count', 'type': 'gauge', 'desc': 'The number of actions in the config file'},
{'name': 'olivetin_config_reloaded_count', 'type': 'counter', 'desc': 'The number of times the config has been reloaded'}, {'name': 'olivetin_config_reloaded_count', 'type': 'counter', 'desc': 'The number of times the config has been reloaded'},
] ]

View File

@ -174,6 +174,7 @@ message LogEntry {
bool queued = 21; bool queued = 21;
string queued_for_group = 22; string queued_for_group = 22;
string justification = 23; string justification = 23;
repeated StartActionArgument arguments = 24;
} }
message GetLogsResponse { message GetLogsResponse {

View File

@ -19,10 +19,11 @@ compile-x64-lin:
go build -o OliveTin go build -o OliveTin
go env -u GOOS go env -u GOOS
compile-x64-win: compile-x64-win: windows-resources
go env -w GOOS=windows GOARCH=amd64 GOOS=windows GOARCH=amd64 go build -o OliveTin.exe
go build -o OliveTin.exe
go env -u GOOS GOARCH windows-resources:
$(MAKE) -wC .. windows-resources
compile: compile-armhf compile-x64-lin compile-x64-win compile: compile-armhf compile-x64-lin compile-x64-win

View File

@ -1424,6 +1424,7 @@ type LogEntry struct {
Queued bool `protobuf:"varint,21,opt,name=queued,proto3" json:"queued,omitempty"` Queued bool `protobuf:"varint,21,opt,name=queued,proto3" json:"queued,omitempty"`
QueuedForGroup string `protobuf:"bytes,22,opt,name=queued_for_group,json=queuedForGroup,proto3" json:"queued_for_group,omitempty"` QueuedForGroup string `protobuf:"bytes,22,opt,name=queued_for_group,json=queuedForGroup,proto3" json:"queued_for_group,omitempty"`
Justification string `protobuf:"bytes,23,opt,name=justification,proto3" json:"justification,omitempty"` Justification string `protobuf:"bytes,23,opt,name=justification,proto3" json:"justification,omitempty"`
Arguments []*StartActionArgument `protobuf:"bytes,24,rep,name=arguments,proto3" json:"arguments,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -1605,6 +1606,13 @@ func (x *LogEntry) GetJustification() string {
return "" return ""
} }
func (x *LogEntry) GetArguments() []*StartActionArgument {
if x != nil {
return x.Arguments
}
return nil
}
type GetLogsResponse struct { type GetLogsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Logs []*LogEntry `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"` Logs []*LogEntry `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"`
@ -4691,7 +4699,7 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\vdate_filter\x18\x02 \x01(\tR\n" + "\vdate_filter\x18\x02 \x01(\tR\n" +
"dateFilter\x12\x1b\n" + "dateFilter\x12\x1b\n" +
"\tpage_size\x18\x03 \x01(\x03R\bpageSize\x12\x16\n" + "\tpage_size\x18\x03 \x01(\x03R\bpageSize\x12\x16\n" +
"\x06filter\x18\x04 \x01(\tR\x06filter\"\xf1\x05\n" + "\x06filter\x18\x04 \x01(\tR\x06filter\"\xb5\x06\n" +
"\bLogEntry\x12)\n" + "\bLogEntry\x12)\n" +
"\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" + "\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" +
"\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" + "\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" +
@ -4717,7 +4725,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"binding_id\x18\x14 \x01(\tR\tbindingId\x12\x16\n" + "binding_id\x18\x14 \x01(\tR\tbindingId\x12\x16\n" +
"\x06queued\x18\x15 \x01(\bR\x06queued\x12(\n" + "\x06queued\x18\x15 \x01(\bR\x06queued\x12(\n" +
"\x10queued_for_group\x18\x16 \x01(\tR\x0equeuedForGroup\x12$\n" + "\x10queued_for_group\x18\x16 \x01(\tR\x0equeuedForGroup\x12$\n" +
"\rjustification\x18\x17 \x01(\tR\rjustification\"\xca\x01\n" + "\rjustification\x18\x17 \x01(\tR\rjustification\x12B\n" +
"\targuments\x18\x18 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\"\xca\x01\n" +
"\x0fGetLogsResponse\x12-\n" + "\x0fGetLogsResponse\x12-\n" +
"\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\n" + "\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\n" +
"\x0fcount_remaining\x18\x02 \x01(\x03R\x0ecountRemaining\x12\x1b\n" + "\x0fcount_remaining\x18\x02 \x01(\x03R\x0ecountRemaining\x12\x1b\n" +
@ -5055,88 +5064,89 @@ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
12, // 13: olivetin.api.v1.StartActionAndWaitRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument 12, // 13: olivetin.api.v1.StartActionAndWaitRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument
21, // 14: olivetin.api.v1.StartActionAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry 21, // 14: olivetin.api.v1.StartActionAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
21, // 15: olivetin.api.v1.StartActionByGetAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry 21, // 15: olivetin.api.v1.StartActionByGetAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
21, // 16: olivetin.api.v1.GetLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry 12, // 16: olivetin.api.v1.LogEntry.arguments:type_name -> olivetin.api.v1.StartActionArgument
21, // 17: olivetin.api.v1.GetActionLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry 21, // 17: olivetin.api.v1.GetLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
21, // 18: olivetin.api.v1.ExecutionQueueAction.entries:type_name -> olivetin.api.v1.LogEntry 21, // 18: olivetin.api.v1.GetActionLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
26, // 19: olivetin.api.v1.ExecutionQueueGroup.actions:type_name -> olivetin.api.v1.ExecutionQueueAction 21, // 19: olivetin.api.v1.ExecutionQueueAction.entries:type_name -> olivetin.api.v1.LogEntry
27, // 20: olivetin.api.v1.GetExecutionQueueResponse.groups:type_name -> olivetin.api.v1.ExecutionQueueGroup 26, // 20: olivetin.api.v1.ExecutionQueueGroup.actions:type_name -> olivetin.api.v1.ExecutionQueueAction
21, // 21: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry 27, // 21: olivetin.api.v1.GetExecutionQueueResponse.groups:type_name -> olivetin.api.v1.ExecutionQueueGroup
34, // 22: olivetin.api.v1.ExecutionStatusResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget 21, // 22: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
80, // 23: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry 34, // 23: olivetin.api.v1.ExecutionStatusResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
81, // 24: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry 80, // 24: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
50, // 25: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged 81, // 25: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
51, // 26: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged 50, // 26: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged
53, // 27: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished 51, // 27: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged
54, // 28: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted 53, // 28: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
49, // 29: olivetin.api.v1.EventStreamResponse.output_chunk:type_name -> olivetin.api.v1.EventOutputChunk 54, // 29: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted
52, // 30: olivetin.api.v1.EventStreamResponse.heartbeat:type_name -> olivetin.api.v1.EventHeartbeat 49, // 30: olivetin.api.v1.EventStreamResponse.output_chunk:type_name -> olivetin.api.v1.EventOutputChunk
21, // 31: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry 52, // 31: olivetin.api.v1.EventStreamResponse.heartbeat:type_name -> olivetin.api.v1.EventHeartbeat
21, // 32: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry 21, // 32: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
68, // 33: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider 21, // 33: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
67, // 34: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink 68, // 34: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
7, // 35: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy 67, // 35: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
0, // 36: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action 7, // 36: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
34, // 37: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget 0, // 37: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
73, // 38: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition 34, // 38: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
5, // 39: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity 73, // 39: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
42, // 40: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding 5, // 40: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
8, // 41: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest 42, // 41: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
11, // 42: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest 8, // 42: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
14, // 43: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest 11, // 43: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
16, // 44: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest 14, // 44: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
18, // 45: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest 16, // 45: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
75, // 46: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest 18, // 46: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
55, // 47: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest 75, // 47: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
33, // 48: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest 55, // 48: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
20, // 49: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest 33, // 49: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
23, // 50: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest 20, // 50: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
25, // 51: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest 23, // 51: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
29, // 52: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest 25, // 52: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest
36, // 53: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest 29, // 53: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
38, // 54: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest 36, // 54: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
40, // 55: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest 38, // 55: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest
43, // 56: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest 40, // 56: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
45, // 57: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest 43, // 57: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
57, // 58: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest 45, // 58: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
59, // 59: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest 57, // 59: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
61, // 60: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest 59, // 60: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
47, // 61: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest 61, // 61: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
63, // 62: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest 47, // 62: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
65, // 63: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest 63, // 63: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
69, // 64: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest 65, // 64: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
71, // 65: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest 69, // 65: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
74, // 66: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest 71, // 66: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
6, // 67: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse 74, // 67: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
13, // 68: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse 6, // 68: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
15, // 69: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse 13, // 69: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
17, // 70: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse 15, // 70: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
19, // 71: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse 17, // 71: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
13, // 72: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse 19, // 72: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
56, // 73: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse 13, // 73: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
35, // 74: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse 56, // 74: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
22, // 75: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse 35, // 75: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
24, // 76: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse 22, // 76: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
28, // 77: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse 24, // 77: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
30, // 78: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse 28, // 78: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse
37, // 79: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse 30, // 79: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
39, // 80: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse 37, // 80: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
41, // 81: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse 39, // 81: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse
44, // 82: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse 41, // 82: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
46, // 83: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse 44, // 83: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
58, // 84: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse 46, // 84: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
60, // 85: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse 58, // 85: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
62, // 86: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse 60, // 86: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
48, // 87: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse 62, // 87: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
64, // 88: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse 48, // 88: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
66, // 89: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse 64, // 89: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
70, // 90: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse 66, // 90: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
72, // 91: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse 70, // 91: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
5, // 92: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity 72, // 92: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
67, // [67:93] is the sub-list for method output_type 5, // 93: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
41, // [41:67] is the sub-list for method input_type 68, // [68:94] is the sub-list for method output_type
41, // [41:41] is the sub-list for extension type_name 42, // [42:68] is the sub-list for method input_type
41, // [41:41] is the sub-list for extension extendee 42, // [42:42] is the sub-list for extension type_name
0, // [0:41] is the sub-list for field type_name 42, // [42:42] is the sub-list for extension extendee
0, // [0:42] is the sub-list for field type_name
} }
func init() { file_olivetin_api_v1_olivetin_proto_init() } func init() { file_olivetin_api_v1_olivetin_proto_init() }

View File

@ -388,6 +388,7 @@ func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry
BindingId: logEntry.GetBindingId(), BindingId: logEntry.GetBindingId(),
DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry), DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
Justification: logEntry.Justification, Justification: logEntry.Justification,
Arguments: logEntryArgumentsToProto(logEntry.Arguments),
} }
if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil { if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil {
@ -1548,14 +1549,15 @@ func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv
return nil, err return nil, err
} }
if execReqLogEntry.Binding.Action.Justification { if err := validateRestartLogEntry(execReqLogEntry); err != nil {
return nil, restartRequiresJustificationError() return nil, err
} }
authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg) authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
execReq := executor.ExecutionRequest{ execReq := executor.ExecutionRequest{
Binding: execReqLogEntry.Binding, Binding: execReqLogEntry.Binding,
Arguments: make(map[string]string), Arguments: copyStringMap(execReqLogEntry.Arguments),
Justification: execReqLogEntry.Justification,
AuthenticatedUser: authenticatedUser, AuthenticatedUser: authenticatedUser,
Cfg: api.cfg, Cfg: api.cfg,
} }

View File

@ -0,0 +1,59 @@
package api
import (
"fmt"
"sort"
"strings"
"connectrpc.com/connect"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
"github.com/OliveTin/OliveTin/internal/executor"
)
func logEntryArgumentsToProto(args map[string]string) []*apiv1.StartActionArgument {
if len(args) == 0 {
return nil
}
names := make([]string, 0, len(args))
for name := range args {
names = append(names, name)
}
sort.Strings(names)
out := make([]*apiv1.StartActionArgument, 0, len(names))
for _, name := range names {
out = append(out, &apiv1.StartActionArgument{
Name: name,
Value: args[name],
})
}
return out
}
func copyStringMap(source map[string]string) map[string]string {
copied := make(map[string]string, len(source))
for key, value := range source {
copied[key] = value
}
return copied
}
func restartArgumentsIncompleteError() error {
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("stored arguments are incomplete for restart; use StartAction with the required arguments instead"))
}
func validateRestartLogEntry(entry *executor.InternalLogEntry) error {
if entry.Binding.Action.Justification && strings.TrimSpace(entry.Justification) == "" {
return restartRequiresJustificationError()
}
if executor.RestartArgumentsIncomplete(entry.Binding.Action, entry.Binding.Entity, entry.Arguments) {
return restartArgumentsIncompleteError()
}
return nil
}

View File

@ -0,0 +1,382 @@
package api
import (
"context"
"testing"
"time"
"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/executor"
)
func argumentAction(title, shell string, args []config.ActionArgument) *config.Action {
return &config.Action{
Title: title,
Shell: shell,
MaxConcurrent: 1,
Arguments: args,
}
}
func waitForLogArguments(t *testing.T, ex *executor.Executor, trackingID string) map[string]string {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
entry, ok := ex.GetLog(trackingID)
if ok && entry.Arguments != nil {
return entry.Arguments
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for arguments on log %s", trackingID)
return nil
}
func TestExecutionStatusIncludesStoredArguments(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
{Name: "host", Type: "ascii_identifier"},
}),
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
Arguments: []*apiv1.StartActionArgument{
{Name: "host", Value: "example.com"},
},
}))
require.NoError(t, err)
waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
statusResp, err := client.ExecutionStatus(context.Background(), connect.NewRequest(&apiv1.ExecutionStatusRequest{
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
}))
require.NoError(t, err)
require.NotNil(t, statusResp.Msg.LogEntry)
require.Len(t, statusResp.Msg.LogEntry.Arguments, 1)
assert.Equal(t, "host", statusResp.Msg.LogEntry.Arguments[0].Name)
assert.Equal(t, "example.com", statusResp.Msg.LogEntry.Arguments[0].Value)
}
func TestExecutionStatusOmitsPasswordArguments(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{
Title: "Connect",
Exec: []string{"echo", "{{ user }}"},
MaxConcurrent: 1,
Arguments: []config.ActionArgument{
{Name: "user", Type: "ascii_identifier"},
{Name: "pass", Type: "password"},
},
},
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
Arguments: []*apiv1.StartActionArgument{
{Name: "user", Value: "alice"},
{Name: "pass", Value: "secret"},
},
}))
require.NoError(t, err)
waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
statusResp, err := client.ExecutionStatus(context.Background(), connect.NewRequest(&apiv1.ExecutionStatusRequest{
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
}))
require.NoError(t, err)
require.NotNil(t, statusResp.Msg.LogEntry)
for _, arg := range statusResp.Msg.LogEntry.Arguments {
assert.NotEqual(t, "pass", arg.Name)
}
require.Len(t, statusResp.Msg.LogEntry.Arguments, 1)
assert.Equal(t, "user", statusResp.Msg.LogEntry.Arguments[0].Name)
assert.Equal(t, "alice", statusResp.Msg.LogEntry.Arguments[0].Value)
}
func TestRestartActionReusesStoredArguments(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
{Name: "host", Type: "ascii_identifier"},
}),
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
Arguments: []*apiv1.StartActionArgument{
{Name: "host", Value: "server-a"},
},
}))
require.NoError(t, err)
originalArgs := waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
assert.Equal(t, "server-a", originalArgs["host"])
restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
}))
require.NoError(t, err)
require.NotEmpty(t, restartResp.Msg.ExecutionTrackingId)
assert.NotEqual(t, startResp.Msg.ExecutionTrackingId, restartResp.Msg.ExecutionTrackingId)
restartedArgs := waitForLogArguments(t, ex, restartResp.Msg.ExecutionTrackingId)
assert.Equal(t, "server-a", restartedArgs["host"])
}
func TestRestartActionRejectsIncompleteStoredArguments(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{
Title: "Connect",
Exec: []string{"echo", "{{ user }}"},
MaxConcurrent: 1,
Arguments: []config.ActionArgument{
{Name: "user", Type: "ascii_identifier"},
{Name: "pass", Type: "password"},
},
},
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
Arguments: []*apiv1.StartActionArgument{
{Name: "user", Value: "alice"},
{Name: "pass", Value: "secret"},
},
}))
require.NoError(t, err)
_, err = client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
}))
require.Error(t, err)
assert.Contains(t, err.Error(), "stored arguments are incomplete for restart")
}
func TestRestartActionRejectsMissingRequiredStoredArguments(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
{Name: "host", Type: "ascii_identifier"},
}),
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
trackingID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
ex.SetLog(trackingID, &executor.InternalLogEntry{
Binding: binding,
ExecutionFinished: true,
ExecutionTrackingID: trackingID,
Arguments: map[string]string{},
})
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
_, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
ExecutionTrackingId: trackingID,
}))
require.Error(t, err)
assert.Contains(t, err.Error(), "stored arguments are incomplete for restart")
}
func TestLogEntryArgumentsToProto(t *testing.T) {
assert.Nil(t, logEntryArgumentsToProto(nil))
assert.Nil(t, logEntryArgumentsToProto(map[string]string{}))
out := logEntryArgumentsToProto(map[string]string{
"host": "example.com",
"port": "443",
})
require.Len(t, out, 2)
values := map[string]string{}
for _, arg := range out {
values[arg.Name] = arg.Value
}
assert.Equal(t, "example.com", values["host"])
assert.Equal(t, "443", values["port"])
}
func TestCopyStringMap(t *testing.T) {
source := map[string]string{"host": "example.com"}
copied := copyStringMap(source)
assert.Equal(t, source, copied)
source["host"] = "changed"
assert.Equal(t, "example.com", copied["host"])
empty := copyStringMap(nil)
assert.NotNil(t, empty)
assert.Empty(t, empty)
}
func TestRestartActionRequiresJustificationWhenMissingFromStoredLog(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{
Title: "Dangerous action",
Shell: "echo ok",
MaxConcurrent: 1,
Justification: true,
},
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
trackingID := "manual-log-without-justification"
ex.SetLog(trackingID, &executor.InternalLogEntry{
Binding: binding,
ExecutionFinished: true,
ExecutionTrackingID: trackingID,
})
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
_, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
ExecutionTrackingId: trackingID,
}))
require.Error(t, err)
assert.Contains(t, err.Error(), "justification")
}
func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{
Title: "Dangerous action",
Shell: "echo ok",
MaxConcurrent: 1,
Justification: true,
},
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
Justification: "maintenance window",
}))
require.NoError(t, err)
originalLog, ok := ex.GetLog(startResp.Msg.ExecutionTrackingId)
require.True(t, ok)
assert.Equal(t, "maintenance window", originalLog.Justification)
restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
}))
require.NoError(t, err)
restartedLog, ok := ex.GetLog(restartResp.Msg.ExecutionTrackingId)
require.True(t, ok)
assert.Equal(t, "maintenance window", restartedLog.Justification)
}
func TestGetLogsIncludesStoredArguments(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
{Name: "host", Type: "ascii_identifier"},
}),
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
Arguments: []*apiv1.StartActionArgument{
{Name: "host", Value: "db-1"},
},
}))
require.NoError(t, err)
require.NotEmpty(t, startResp.Msg.ExecutionTrackingId)
waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
logsResp, err := client.GetLogs(context.Background(), connect.NewRequest(&apiv1.GetLogsRequest{}))
require.NoError(t, err)
require.NotEmpty(t, logsResp.Msg.Logs)
var matched bool
for _, entry := range logsResp.Msg.Logs {
if entry.ExecutionTrackingId != startResp.Msg.ExecutionTrackingId {
continue
}
matched = true
require.Len(t, entry.Arguments, 1)
assert.Equal(t, "host", entry.Arguments[0].Name)
assert.Equal(t, "db-1", entry.Arguments[0].Value)
}
assert.True(t, matched, "expected log entry with stored arguments")
}

View File

@ -8,6 +8,7 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/go-viper/mapstructure/v2"
"github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2" "github.com/knadh/koanf/v2"
@ -51,6 +52,14 @@ func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) {
func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool { func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool {
err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{ err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
Tag: "koanf", Tag: "koanf",
DecoderConfig: &mapstructure.DecoderConfig{
DecodeHook: mapstructure.ComposeDecodeHookFunc(
envDecodeHookFunc,
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.TextUnmarshallerHookFunc(),
),
WeaklyTypedInput: true,
},
}) })
if err != nil { if err != nil {

View File

@ -89,50 +89,48 @@ var envConfigTests = []struct {
}}, }},
} }
func setTestEnvInput(t *testing.T, input string) {
t.Helper()
if input != "" {
if err := os.Setenv("INPUT", input); err != nil {
t.Fatalf("Setenv INPUT: %v", err)
}
return
}
if err := os.Unsetenv("INPUT"); err != nil {
t.Fatalf("Unsetenv INPUT: %v", err)
}
}
func loadConfigFromYAML(t *testing.T, yamlStr string, cfg *Config) bool {
t.Helper()
k := koanf.New(".")
if err := k.Load(rawbytes.Provider([]byte(yamlStr)), yaml.Parser()); err != nil {
t.Errorf("Error loading YAML: %v", err)
return false
}
return unmarshalRoot(k, cfg)
}
func TestEnvInConfig(t *testing.T) { func TestEnvInConfig(t *testing.T) {
t.Skip("Skipping test in 3k") originalInput, hadInput := os.LookupEnv("INPUT")
t.Cleanup(func() {
if hadInput {
setTestEnvInput(t, originalInput)
return
}
setTestEnvInput(t, "")
})
for _, tt := range envConfigTests { for _, tt := range envConfigTests {
cfg := DefaultConfig() cfg := DefaultConfig()
setIfNotEmpty("INPUT", tt.input) setTestEnvInput(t, tt.input)
processed := processYamlWithEnv(tt.yaml) if !loadConfigFromYAML(t, tt.yaml, cfg) {
k, err := loadKoanf(processed) t.Errorf("Error unmarshalling config for env=%q", tt.input)
if err != nil {
t.Errorf("Error loading YAML: %v", err)
continue
}
if err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
Tag: "koanf",
}); err != nil {
t.Errorf("Error unmarshalling config: %v", err)
continue continue
} }
field := tt.selector(cfg) field := tt.selector(cfg)
assert.Equal(t, tt.output, field, "Unmarshaled config field doesn't match expected value: env=\"%s\"", tt.input) assert.Equal(t, tt.output, field,
os.Unsetenv("INPUT") "Unmarshaled config field doesn't match expected value: env=%q", tt.input)
} }
} }
func setIfNotEmpty(key, val string) {
if val != "" {
os.Setenv(key, val)
}
}
func processYamlWithEnv(content string) string {
return envRegex.ReplaceAllStringFunc(content, func(match string) string {
submatches := envRegex.FindStringSubmatch(match)
key := submatches[1]
val, _ := os.LookupEnv(key)
return val
})
}
func loadKoanf(processed string) (*koanf.Koanf, error) {
k := koanf.New(".")
if err := k.Load(rawbytes.Provider([]byte(processed)), yaml.Parser()); err != nil {
return nil, err
}
return k, nil
}

View File

@ -11,8 +11,6 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"bytes" "bytes"
@ -40,13 +38,6 @@ func isValidTrackingID(id string) bool {
return id != "" && len(id) <= MaxTrackingIDLength && validTrackingIDPattern.MatchString(id) return id != "" && len(id) <= MaxTrackingIDLength && validTrackingIDPattern.MatchString(id)
} }
var (
metricActionsRequested = promauto.NewCounter(prometheus.CounterOpts{
Name: "olivetin_actions_requested_count",
Help: "The actions requested count",
})
)
type ActionBinding struct { type ActionBinding struct {
ID string ID string
Action *config.Action Action *config.Action
@ -171,6 +162,7 @@ type InternalLogEntry struct {
ActionTitle string ActionTitle string
ActionIcon string ActionIcon string
Justification string Justification string
Arguments map[string]string
} }
// .Binding can be nil, so we need to handle that. // .Binding can be nil, so we need to handle that.
@ -705,6 +697,8 @@ func (e *Executor) finishExecChain(req *ExecutionRequest) {
entry.ExecutionFinished = true entry.ExecutionFinished = true
}) })
recordExecutionMetrics(req.logEntry)
notifyListenersFinished(req) notifyListenersFinished(req)
e.drainGroupQueue() e.drainGroupQueue()
} }
@ -863,6 +857,7 @@ func stepParseArgs(req *ExecutionRequest) bool {
return fail(req, err) return fail(req, err)
} }
mangleInvalidArgumentValues(req) mangleInvalidArgumentValues(req)
copyStorableArgumentsToLogEntry(req)
if hasExec(req) { if hasExec(req) {
return handleExecBranch(req) return handleExecBranch(req)
@ -1019,7 +1014,7 @@ func stepRequestActionPopulateLogEntry(req *ExecutionRequest) {
entry.Binding = req.Binding entry.Binding = req.Binding
entry.ActionConfigTitle = req.Binding.Action.Title entry.ActionConfigTitle = req.Binding.Action.Title
entry.ActionTitle = tpl.ParseTemplateOfActionBeforeExec(req.Binding.Action.Title, req.Binding.Entity) entry.ActionTitle = tpl.ParseTemplateOfActionBeforeExec(req.Binding.Action.Title, req.Binding.Entity)
entry.ActionIcon = req.Binding.Action.Icon entry.ActionIcon = tpl.ParseTemplateOfActionBeforeExec(req.Binding.Action.Icon, req.Binding.Entity)
entry.Tags = req.Tags entry.Tags = req.Tags
entry.Justification = ResolveJustification(req) entry.Justification = ResolveJustification(req)
if req.Binding.Entity != nil { if req.Binding.Entity != nil {

View File

@ -10,6 +10,7 @@ import (
"github.com/OliveTin/OliveTin/internal/auth" "github.com/OliveTin/OliveTin/internal/auth"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
) )
func testingExecutor() (*Executor, *config.Config) { func testingExecutor() (*Executor, *config.Config) {
@ -59,6 +60,32 @@ func TestCreateExecutorAndExec(t *testing.T) {
assert.Equal(t, int32(0), req.logEntry.ExitCode, "Exit code is zero") assert.Equal(t, int32(0), req.logEntry.ExitCode, "Exit code is zero")
} }
func TestStepRequestActionPopulateLogEntryResolvesEntityTemplates(t *testing.T) {
req := &ExecutionRequest{
logEntry: &InternalLogEntry{},
Binding: &ActionBinding{
Action: &config.Action{
Title: "Do something with {{ project.name }}",
Icon: "{{ project.icon }}",
},
Entity: &entities.Entity{
Data: map[string]any{
"name": "foo",
"icon": "🐰",
},
UniqueKey: "foo-key",
},
},
}
stepRequestActionPopulateLogEntry(req)
assert.Equal(t, "Do something with foo", req.logEntry.ActionTitle)
assert.Equal(t, "🐰", req.logEntry.ActionIcon)
assert.Equal(t, "Do something with {{ project.name }}", req.logEntry.ActionConfigTitle)
assert.Equal(t, "foo-key", req.logEntry.EntityPrefix)
}
func TestExecNonExistant(t *testing.T) { func TestExecNonExistant(t *testing.T) {
e, cfg := testingExecutor() e, cfg := testingExecutor()

View File

@ -508,36 +508,33 @@ func TestGroupQueueBlocksWhenQueueFull(t *testing.T) {
}, },
) )
trackings, waitGroups := execAllGroupActions(t, e, cfg, actions) wg1, tracking1 := e.ExecRequest(&ExecutionRequest{
Binding: e.FindBindingWithNoEntity(actions[0]),
Cfg: cfg,
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
})
waitUntilExecutionStarted(t, e, tracking1)
require.Eventually(t, func() bool { trackings := []string{tracking1}
return countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Blocked }) == 1 && waitGroups := []*sync.WaitGroup{wg1}
countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Queued }) == 2 &&
countSnapshots(e, trackings, isRunningSnapshot) == 1
}, 2*time.Second, 20*time.Millisecond)
for _, wg := range waitGroups { for _, action := range actions[1:] {
wg.Wait()
}
}
func execAllGroupActions(t *testing.T, e *Executor, cfg *config.Config, actions []*config.Action) ([]string, []*sync.WaitGroup) {
t.Helper()
trackings := make([]string, len(actions))
waitGroups := make([]*sync.WaitGroup, len(actions))
for idx, action := range actions {
wg, tracking := e.ExecRequest(&ExecutionRequest{ wg, tracking := e.ExecRequest(&ExecutionRequest{
Binding: e.FindBindingWithNoEntity(action), Binding: e.FindBindingWithNoEntity(action),
Cfg: cfg, Cfg: cfg,
AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
}) })
trackings[idx] = tracking trackings = append(trackings, tracking)
waitGroups[idx] = wg waitGroups = append(waitGroups, wg)
} }
return trackings, waitGroups require.Eventually(t, func() bool {
return groupExecutionDistributionMatches(e, trackings, 1, 2, 1)
}, 2*time.Second, 20*time.Millisecond)
for _, wg := range waitGroups {
wg.Wait()
}
} }
func groupExecutionDistributionMatches(e *Executor, trackings []string, wantRunning, wantQueued, wantBlocked int) bool { func groupExecutionDistributionMatches(e *Executor, trackings []string, wantRunning, wantQueued, wantBlocked int) bool {

View File

@ -0,0 +1,141 @@
package executor
import (
"strings"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
"github.com/OliveTin/OliveTin/internal/tpl"
)
func argumentTypeStorableInLog(argType string) bool {
switch argType {
case "password", "very_dangerous_raw_string":
return false
default:
return true
}
}
func storableArgumentNames(action *config.Action) map[string]struct{} {
if action == nil {
return nil
}
names := make(map[string]struct{}, len(action.Arguments))
for i := range action.Arguments {
arg := &action.Arguments[i]
if !argumentTypeStorableInLog(arg.Type) {
continue
}
names[arg.Name] = struct{}{}
}
return names
}
func storableArgumentNamesFromRequest(req *ExecutionRequest) map[string]struct{} {
if req == nil || req.Binding == nil || req.Binding.Action == nil {
return nil
}
return storableArgumentNames(req.Binding.Action)
}
func isStorableArgumentName(name string, allowedNames map[string]struct{}) bool {
if strings.HasPrefix(name, config.ReservedArgumentNamePrefix) {
return false
}
_, ok := allowedNames[name]
return ok
}
func collectStorableArguments(args map[string]string, allowedNames map[string]struct{}) map[string]string {
result := make(map[string]string)
for name, value := range args {
if isStorableArgumentName(name, allowedNames) {
result[name] = value
}
}
return result
}
func filterStorableArguments(args map[string]string, allowedNames map[string]struct{}) map[string]string {
if len(args) == 0 {
return nil
}
result := collectStorableArguments(args, allowedNames)
if len(result) == 0 {
return nil
}
return result
}
func storableArgumentsFromRequest(req *ExecutionRequest) map[string]string {
allowedNames := storableArgumentNamesFromRequest(req)
if len(allowedNames) == 0 {
return nil
}
return filterStorableArguments(req.Arguments, allowedNames)
}
func copyStorableArgumentsToLogEntry(req *ExecutionRequest) {
args := storableArgumentsFromRequest(req)
if args == nil || req.logEntry == nil {
return
}
req.mutateLogEntry(func(entry *InternalLogEntry) {
entry.Arguments = args
})
}
func restartArgumentMissingFromStored(arg *config.ActionArgument, entity *entities.Entity, storedArgs map[string]string) bool {
if !argumentTypeStorableInLog(arg.Type) {
return true
}
if !restartArgumentRequired(arg, entity) {
return false
}
if storedArgs == nil {
return true
}
_, ok := storedArgs[arg.Name]
return !ok
}
func RestartArgumentsIncomplete(action *config.Action, entity *entities.Entity, storedArgs map[string]string) bool {
if action == nil {
return false
}
for i := range action.Arguments {
if restartArgumentMissingFromStored(&action.Arguments[i], entity, storedArgs) {
return true
}
}
return false
}
func restartArgumentRequired(arg *config.ActionArgument, entity *entities.Entity) bool {
if argumentSkipsValidation(arg) {
return false
}
defaultValue := arg.Default
if defaultValue != "" {
defaultValue = tpl.ParseTemplateOfActionBeforeExec(defaultValue, entity)
}
return defaultValue == ""
}

View File

@ -0,0 +1,157 @@
package executor
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
auth "github.com/OliveTin/OliveTin/internal/auth"
config "github.com/OliveTin/OliveTin/internal/config"
)
func TestArgumentTypeStorableInLog(t *testing.T) {
assert.True(t, argumentTypeStorableInLog("ascii"))
assert.True(t, argumentTypeStorableInLog("shell_safe_identifier"))
assert.False(t, argumentTypeStorableInLog("password"))
assert.False(t, argumentTypeStorableInLog("very_dangerous_raw_string"))
}
func TestStorableArgumentsFromRequestExcludesSensitiveAndSystemArgs(t *testing.T) {
req := newExecRequest()
req.Binding.Action.Arguments = []config.ActionArgument{
{Name: "host", Type: "ascii_identifier"},
{Name: "secret", Type: "password"},
{Name: "payload", Type: "very_dangerous_raw_string"},
}
req.Arguments = map[string]string{
"host": "example.com",
"secret": "hunter2",
"payload": "rm -rf /",
"ot_executionTrackingId": "track-123",
"ot_username": "alice",
"extra_undefined": "drop-me",
}
args := storableArgumentsFromRequest(req)
require.Len(t, args, 1)
assert.Equal(t, "example.com", args["host"])
assert.NotContains(t, args, "secret")
assert.NotContains(t, args, "payload")
assert.NotContains(t, args, "ot_executionTrackingId")
assert.NotContains(t, args, "ot_username")
assert.NotContains(t, args, "extra_undefined")
}
func TestStorableArgumentsFromRequestReturnsNilWhenEmpty(t *testing.T) {
req := newExecRequest()
req.Binding.Action.Arguments = []config.ActionArgument{
{Name: "secret", Type: "password"},
}
req.Arguments = map[string]string{
"secret": "hunter2",
}
assert.Nil(t, storableArgumentsFromRequest(req))
}
func TestStorableArgumentsFromRequestStoresMangledCheckboxValue(t *testing.T) {
req := newExecRequest()
req.Binding.Action.Arguments = []config.ActionArgument{
{
Name: "mode",
Type: "checkbox",
Choices: []config.ActionArgumentChoice{
{Title: "Enabled", Value: "1"},
{Title: "Disabled", Value: "0"},
},
},
}
req.Arguments = map[string]string{
"mode": "Enabled",
}
mangleInvalidArgumentValues(req)
args := storableArgumentsFromRequest(req)
require.Len(t, args, 1)
assert.Equal(t, "1", args["mode"])
}
func TestCopyStorableArgumentsToLogEntry(t *testing.T) {
req := newExecRequest()
req.logEntry = &InternalLogEntry{}
req.Binding.Action.Arguments = []config.ActionArgument{
{Name: "target", Type: "ascii_identifier"},
}
req.Arguments = map[string]string{
"target": "server-a",
}
copyStorableArgumentsToLogEntry(req)
require.NotNil(t, req.logEntry.Arguments)
assert.Equal(t, "server-a", req.logEntry.Arguments["target"])
}
func TestExecRequestStoresArgumentsOnLogEntry(t *testing.T) {
e, cfg := testingExecutor()
e.RebuildActionMap()
binding := e.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
req := ExecutionRequest{
Binding: binding,
Cfg: cfg,
AuthenticatedUser: auth.UserGuest(cfg),
Arguments: map[string]string{
"person": "yourself",
},
}
wg, trackingID := e.ExecRequest(&req)
wg.Wait()
logEntry, ok := e.GetLog(trackingID)
require.True(t, ok)
require.NotNil(t, logEntry.Arguments)
assert.Equal(t, "yourself", logEntry.Arguments["person"])
}
func TestRestartArgumentsIncompleteDetectsNonStorableArguments(t *testing.T) {
action := &config.Action{
Arguments: []config.ActionArgument{
{Name: "host", Type: "ascii_identifier"},
{Name: "pass", Type: "password"},
},
}
assert.True(t, RestartArgumentsIncomplete(action, nil, map[string]string{
"host": "db-1",
}))
}
func TestRestartArgumentsIncompleteDetectsMissingRequiredStoredArguments(t *testing.T) {
action := &config.Action{
Arguments: []config.ActionArgument{
{Name: "host", Type: "ascii_identifier"},
},
}
assert.True(t, RestartArgumentsIncomplete(action, nil, map[string]string{}))
assert.False(t, RestartArgumentsIncomplete(action, nil, map[string]string{
"host": "db-1",
}))
}
func TestRestartArgumentsIncompleteAllowsOptionalArgumentsWithDefaults(t *testing.T) {
action := &config.Action{
Arguments: []config.ActionArgument{
{Name: "host", Type: "ascii_identifier", Default: "example.com"},
},
}
assert.False(t, RestartArgumentsIncomplete(action, nil, map[string]string{}))
}

View File

@ -0,0 +1,95 @@
package executor
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
const (
executionResultSuccess = "success"
executionResultFailed = "failed"
executionResultBlocked = "blocked"
executionResultTimeout = "timeout"
executionResultError = "error"
)
var (
metricActionsRequested = promauto.NewCounter(prometheus.CounterOpts{
Name: "olivetin_actions_requested_count",
Help: "The actions requested count",
})
metricActionExecutionsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "olivetin_action_executions_total",
Help: "Total number of finished action executions grouped by result.",
}, []string{"result"})
metricActionExecutionDuration = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "olivetin_action_execution_duration_seconds",
Help: "Action execution duration in seconds from start to finish.",
Buckets: []float64{0.1, 0.5, 1, 2, 5, 10, 30, 60, 120, 300, 600},
})
executionResultLabels = []string{
executionResultSuccess,
executionResultFailed,
executionResultBlocked,
executionResultTimeout,
executionResultError,
}
)
func init() {
for _, result := range executionResultLabels {
metricActionExecutionsTotal.WithLabelValues(result)
}
}
func executionResultLabel(entry *InternalLogEntry) string {
if entry.Blocked {
return executionResultBlocked
}
return finishedExecutionResultLabel(entry)
}
func finishedExecutionResultLabel(entry *InternalLogEntry) string {
if entry.TimedOut {
return executionResultTimeout
}
switch {
case entry.ExitCode == 0:
return executionResultSuccess
case isPreExecutionError(entry):
return executionResultError
default:
return executionResultFailed
}
}
func isPreExecutionError(entry *InternalLogEntry) bool {
return entry.ExitCode == DefaultExitCodeNotExecuted || !entry.ExecutionStarted
}
func recordExecutionMetrics(entry *InternalLogEntry) {
if entry == nil || entry.Queued {
return
}
metricActionExecutionsTotal.WithLabelValues(executionResultLabel(entry)).Inc()
recordExecutionDuration(entry)
}
func recordExecutionDuration(entry *InternalLogEntry) {
if entry.DatetimeFinished.IsZero() || entry.DatetimeStarted.IsZero() {
return
}
duration := entry.DatetimeFinished.Sub(entry.DatetimeStarted).Seconds()
if duration < 0 {
return
}
metricActionExecutionDuration.Observe(duration)
}

View File

@ -0,0 +1,76 @@
package executor
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestExecutionResultLabel(t *testing.T) {
tests := []struct {
name string
entry *InternalLogEntry
want string
}{
{
name: "success",
entry: &InternalLogEntry{
ExecutionStarted: true,
ExecutionFinished: true,
ExitCode: 0,
},
want: executionResultSuccess,
},
{
name: "failed nonzero exit",
entry: &InternalLogEntry{
ExecutionStarted: true,
ExecutionFinished: true,
ExitCode: 1,
},
want: executionResultFailed,
},
{
name: "blocked",
entry: &InternalLogEntry{
Blocked: true,
ExecutionFinished: true,
ExitCode: 0,
},
want: executionResultBlocked,
},
{
name: "timeout",
entry: &InternalLogEntry{
ExecutionStarted: true,
ExecutionFinished: true,
TimedOut: true,
ExitCode: -1,
},
want: executionResultTimeout,
},
{
name: "error before execution",
entry: &InternalLogEntry{
ExecutionFinished: true,
ExitCode: DefaultExitCodeNotExecuted,
},
want: executionResultError,
},
{
name: "error never started",
entry: &InternalLogEntry{
ExecutionStarted: false,
ExecutionFinished: true,
ExitCode: 2,
},
want: executionResultError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, executionResultLabel(tt.entry))
})
}
}

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
version="1.0.0.0"
processorArchitecture="*"
name="OliveTin.OliveTin"
type="win32" />
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*" />
</dependentAssembly>
</dependency>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>

BIN
var/windows/OliveTin.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

46
var/windows/OliveTin.wxs Normal file
View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product
Id="*"
Name="OliveTin $(var.Version)"
Language="1033"
Version="$(var.Version)"
Manufacturer="James Read"
UpgradeCode="8B5E3F2A-1C4D-4E6F-9A0B-2D3C4E5F6071">
<Package
InstallerVersion="500"
Compressed="yes"
InstallScope="perMachine"
Description="OliveTin web interface for running shell commands"
Comments="https://github.com/OliveTin/OliveTin" />
<MajorUpgrade
AllowSameVersionUpgrades="yes"
DowngradeErrorMessage="A newer version of OliveTin is already installed." />
<MediaTemplate />
<Feature Id="ProductFeature" Title="OliveTin" Level="1">
<ComponentGroupRef Id="CG.AppFiles" />
<ComponentRef Id="ConfigFile" />
</Feature>
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLDIR" Name="OliveTin" />
</Directory>
<Directory Id="CommonAppDataFolder">
<Directory Id="ConfigDir" Name="OliveTin" />
</Directory>
</Directory>
<DirectoryRef Id="ConfigDir">
<Component Id="ConfigFile" Guid="A1B2C3D4-E5F6-7890-ABCD-EF1234567890" Win64="yes" Permanent="yes" NeverOverwrite="yes">
<File
Id="ConfigYaml"
Source="$(var.ConfigSource)"
Name="config.yaml"
KeyPath="yes" />
</Component>
</DirectoryRef>
</Product>
</Wix>

92
var/windows/build-msi.sh Executable file
View File

@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
DIST_DIR="${DIST_DIR:-${REPO_ROOT}/dist}"
ARCH="${ARCH:-amd64}"
ZIP_NAME="OliveTin-windows-${ARCH}.zip"
ZIP_PATH="${DIST_DIR}/${ZIP_NAME}"
MSI_NAME="OliveTin-windows-${ARCH}.msi"
MSI_PATH="${DIST_DIR}/${MSI_NAME}"
if [[ ! -f "${ZIP_PATH}" ]]; then
echo "Windows archive not found: ${ZIP_PATH}" >&2
exit 1
fi
if ! command -v wixl >/dev/null || ! command -v wixl-heat >/dev/null; then
echo "wixl and wixl-heat are required (install the wixl/msitools package)" >&2
exit 1
fi
normalize_msi_version() {
local raw="${1#v}"
raw="${raw%%-*}"
if [[ ! "${raw}" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]]; then
echo "Invalid MSI version (expected major[.minor[.patch[.build]]]): ${1}" >&2
return 1
fi
local -a parts=()
IFS='.' read -r -a parts <<<"${raw}"
local major="${parts[0]:-0}"
local minor="${parts[1]:-0}"
local patch="${parts[2]:-0}"
printf '%s.%s.%s' "${major}" "${minor}" "${patch}"
}
VERSION="${VERSION:-}"
if [[ -z "${VERSION}" ]]; then
VERSION="$(git -C "${REPO_ROOT}" describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true)"
fi
if [[ -z "${VERSION}" ]]; then
echo "Could not determine release version; set VERSION explicitly" >&2
exit 1
fi
MSI_VERSION="$(normalize_msi_version "${VERSION}")" || exit 1
STAGING="$(mktemp -d)"
APP_STAGING="$(mktemp -d)"
HEAT_WXS="$(mktemp)"
trap 'rm -rf "${STAGING}" "${APP_STAGING}" "${HEAT_WXS}"' EXIT
unzip -q "${ZIP_PATH}" -d "${STAGING}"
SOURCE_ROOT="${STAGING}/OliveTin-windows-${ARCH}"
if [[ ! -f "${SOURCE_ROOT}/OliveTin.exe" ]]; then
echo "OliveTin.exe not found in ${SOURCE_ROOT}" >&2
exit 1
fi
if [[ ! -f "${SOURCE_ROOT}/config.yaml" ]]; then
echo "config.yaml not found in ${SOURCE_ROOT}" >&2
exit 1
fi
mkdir -p "${APP_STAGING}/webui"
cp "${SOURCE_ROOT}/OliveTin.exe" "${APP_STAGING}/"
cp -a "${SOURCE_ROOT}/webui/." "${APP_STAGING}/webui/"
(
cd "${APP_STAGING}"
find . -type f | sed 's|^\./||'
) | wixl-heat \
-p "" \
--component-group CG.AppFiles \
--var var.SourceDir \
--directory-ref INSTALLDIR \
--win64 \
> "${HEAT_WXS}"
wixl \
-v \
-a x64 \
-D "Version=${MSI_VERSION}" \
-D "Win64=yes" \
-D "SourceDir=${APP_STAGING}" \
-D "ConfigSource=${SOURCE_ROOT}/config.yaml" \
-o "${MSI_PATH}" \
"${SCRIPT_DIR}/OliveTin.wxs" \
"${HEAT_WXS}"
echo "Built ${MSI_PATH}"

View File

@ -0,0 +1,94 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
SERVICE_DIR="${REPO_ROOT}/service"
VERSIONINFO_JSON="${SCRIPT_DIR}/versioninfo.json"
ICON_PATH="${SCRIPT_DIR}/OliveTin.ico"
MANIFEST_PATH="${SCRIPT_DIR}/OliveTin.exe.manifest"
GOVERSIONINFO_VERSION="${GOVERSIONINFO_VERSION:-v1.5.0}"
usage() {
cat <<EOF
Usage: $(basename "$0") [version]
Generate Windows resource (.syso) files for embedding in OliveTin.exe.
version Release version (e.g. 3.0.0 or v3.0.0). Defaults to VERSION env,
then the latest git tag, then 0.0.0.
EOF
}
normalize_windows_version() {
local raw="${1#v}"
raw="${raw%%-*}"
if [[ ! "${raw}" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]]; then
echo "0.0.0.0"
return
fi
local -a parts=()
IFS='.' read -r -a parts <<<"${raw}"
local major="${parts[0]:-0}"
local minor="${parts[1]:-0}"
local patch="${parts[2]:-0}"
local build="${parts[3]:-0}"
printf '%s.%s.%s.%s' "${major}" "${minor}" "${patch}" "${build}"
}
resolve_version() {
if [[ $# -gt 0 && -n "${1:-}" ]]; then
echo "${1}"
return
fi
if [[ -n "${VERSION:-}" ]]; then
echo "${VERSION}"
return
fi
if git -C "${REPO_ROOT}" describe --tags --abbrev=0 >/dev/null 2>&1; then
git -C "${REPO_ROOT}" describe --tags --abbrev=0
return
fi
echo "0.0.0"
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if [[ ! -f "${VERSIONINFO_JSON}" ]]; then
echo "versioninfo.json not found: ${VERSIONINFO_JSON}" >&2
exit 1
fi
if [[ ! -f "${ICON_PATH}" ]]; then
echo "icon not found: ${ICON_PATH}" >&2
exit 1
fi
WINDOWS_VERSION="$(normalize_windows_version "$(resolve_version "${1:-}")")"
echo "Generating Windows resources for version ${WINDOWS_VERSION}"
TOOL_BIN="$(mktemp -d)/bin"
export GOBIN="${TOOL_BIN}"
go install "github.com/josephspurrier/goversioninfo/cmd/goversioninfo@${GOVERSIONINFO_VERSION}"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "${WORK_DIR}" "${TOOL_BIN%/*}"' EXIT
(
cd "${WORK_DIR}"
"${TOOL_BIN}/goversioninfo" \
-64 \
-platform-specific \
-icon="${ICON_PATH}" \
-manifest="${MANIFEST_PATH}" \
-file-version="${WINDOWS_VERSION}" \
-product-version="${WINDOWS_VERSION}" \
"${VERSIONINFO_JSON}"
)
rm -f "${SERVICE_DIR}"/resource_windows_*.syso
mv "${WORK_DIR}"/resource_windows_*.syso "${SERVICE_DIR}/"
echo "Wrote Windows resource files to ${SERVICE_DIR}"

View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TIMEOUT="${GORELEASER_TIMEOUT:-60m}"
if [[ -z "${VERSION:-}" ]]; then
echo "VERSION is required to build the Windows MSI" >&2
exit 1
fi
goreleaser release --clean --timeout "${TIMEOUT}" --skip=checksum,publish "$@"
"${SCRIPT_DIR}/build-msi.sh"
goreleaser release --timeout "${TIMEOUT}" \
--skip=validate,before,build,archive,nfpm,docker,sign,sbom,after,announce "$@"

View File

@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
DIST_DIR="${DIST_DIR:-${REPO_ROOT}/dist}"
ARCH="${ARCH:-amd64}"
MSI_NAME="OliveTin-windows-${ARCH}.msi"
MSI_PATH="${DIST_DIR}/${MSI_NAME}"
TAG="${1:-}"
if [[ -z "${TAG}" ]]; then
echo "Usage: $(basename "$0") <release-tag>" >&2
exit 1
fi
if [[ ! -f "${MSI_PATH}" ]]; then
echo "MSI not found: ${MSI_PATH}" >&2
exit 1
fi
if ! command -v gh >/dev/null; then
echo "gh is required to upload the MSI to GitHub releases" >&2
exit 1
fi
checksums_path="${DIST_DIR}/checksums.txt"
new_checksum="$(cd "${DIST_DIR}" && sha256sum "${MSI_NAME}")"
if [[ -f "${checksums_path}" ]] && grep -qF " ${MSI_NAME}" "${checksums_path}"; then
tmp="$(mktemp)"
grep -vF " ${MSI_NAME}" "${checksums_path}" > "${tmp}" || true
printf '%s\n' "${new_checksum}" >> "${tmp}"
mv "${tmp}" "${checksums_path}"
elif [[ -f "${checksums_path}" ]]; then
printf '%s\n' "${new_checksum}" >> "${checksums_path}"
else
printf '%s\n' "${new_checksum}" > "${checksums_path}"
fi
gh release upload "${TAG}" "${MSI_PATH}" --clobber
if [[ -f "${checksums_path}" ]]; then
gh release upload "${TAG}" "${checksums_path}" --clobber
fi
echo "Uploaded ${MSI_NAME} to release ${TAG}"

View File

@ -0,0 +1,41 @@
{
"FixedFileInfo": {
"FileVersion": {
"Major": 0,
"Minor": 0,
"Patch": 0,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 0,
"Patch": 0,
"Build": 0
},
"FileFlagsMask": "3f",
"FileFlags ": "00",
"FileOS": "040004",
"FileType": "01",
"FileSubType": "00"
},
"StringFileInfo": {
"Comments": "https://github.com/OliveTin/OliveTin",
"CompanyName": "James Read",
"FileDescription": "OliveTin web interface for running shell commands",
"FileVersion": "0.0.0.0",
"InternalName": "OliveTin",
"LegalCopyright": "Copyright (C) James Read. Licensed under AGPL-3.0.",
"LegalTrademarks": "",
"OriginalFilename": "OliveTin.exe",
"PrivateBuild": "",
"ProductName": "OliveTin",
"ProductVersion": "0.0.0.0",
"SpecialBuild": ""
},
"VarFileInfo": {
"Translation": {
"LangID": "0409",
"CharsetID": "04B0"
}
}
}