James Read 5 дней назад
Родитель
Сommit
b58f769bda
41 измененных файлов с 2253 добавлено и 202 удалено
  1. 17 7
      .github/workflows/build-and-release.yml
  2. 1 0
      .gitignore
  3. 5 0
      .goreleaser.yml
  4. 1 1
      .releaserc.yaml
  5. 8 1
      Makefile
  6. 1 1
      config.yaml
  7. 48 4
      docs/modules/ROOT/pages/advanced_configuration/prometheus.adoc
  8. 51 7
      docs/modules/ROOT/pages/security/example_some_admin_actions.adoc
  9. 6 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  10. 1 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  11. 345 0
      frontend/resources/vue/components/ChoiceCombobox.vue
  12. 33 0
      frontend/resources/vue/components/choiceComboboxHelpers.js
  13. 40 0
      frontend/resources/vue/components/choiceComboboxHelpers.test.mjs
  14. 68 0
      frontend/resources/vue/utils/rerunArguments.js
  15. 117 0
      frontend/resources/vue/utils/rerunArguments.test.mjs
  16. 10 6
      frontend/resources/vue/views/ArgumentForm.vue
  17. 30 14
      integration-tests/tests/multipleDropdowns/multipleDropdowns.js
  18. 2 0
      integration-tests/tests/prometheus/prometheus.mjs
  19. 1 0
      proto/olivetin/api/v1/olivetin.proto
  20. 6 5
      service/Makefile
  21. 94 84
      service/gen/olivetin/api/v1/olivetin.pb.go
  22. 5 3
      service/internal/api/api.go
  23. 59 0
      service/internal/api/api_log_arguments.go
  24. 382 0
      service/internal/api/api_log_arguments_test.go
  25. 9 0
      service/internal/config/config_reloader.go
  26. 34 36
      service/internal/config/config_reloader_test.go
  27. 5 10
      service/internal/executor/executor.go
  28. 27 0
      service/internal/executor/executor_test.go
  29. 18 21
      service/internal/executor/group_concurrency_test.go
  30. 141 0
      service/internal/executor/log_arguments.go
  31. 157 0
      service/internal/executor/log_arguments_test.go
  32. 95 0
      service/internal/executor/prometheus.go
  33. 76 0
      service/internal/executor/prometheus_test.go
  34. 25 0
      var/windows/OliveTin.exe.manifest
  35. BIN
      var/windows/OliveTin.ico
  36. 46 0
      var/windows/OliveTin.wxs
  37. 92 0
      var/windows/build-msi.sh
  38. 94 0
      var/windows/generate-resources.sh
  39. 17 0
      var/windows/goreleaser-release-with-msi.sh
  40. 45 0
      var/windows/upload-msi-release.sh
  41. 41 0
      var/windows/versioninfo.json

+ 17 - 7
.github/workflows/build-and-release.yml

@@ -13,6 +13,7 @@ on:
       - 'integration-tests/**'
       - 'proto/**'
       - 'service/**'
+      - 'var/windows/**'
   workflow_dispatch:
   push:
     tags:
@@ -31,6 +32,7 @@ on:
       - 'integration-tests/**'
       - 'proto/**'
       - 'service/**'
+      - 'var/windows/**'
 
 jobs:
   build:
@@ -73,13 +75,15 @@ jobs:
         run: go version
 
       - name: Login to Docker Hub
+        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
         uses: docker/login-action@v4
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_KEY }}
 
       - 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:
           registry: ghcr.io
           username: ${{ github.actor }}
@@ -110,17 +114,23 @@ jobs:
             integration-tests
             !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
-        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:
           install-only: true
 
       - 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
-        if: github.ref_type != 'tag'
-        uses: cycjimmy/semantic-release-action@v4
+        if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
+        uses: cycjimmy/semantic-release-action@v5
         with:
           extra_plugins: |
             @semantic-release/commit-analyzer
@@ -131,8 +141,8 @@ jobs:
           GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
 
       - 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:
           name: "OliveTin-snapshot-${{ env.DATE }}-${{ github.sha }}"
           path: dist/OliveTin*.*
-

+ 1 - 0
.gitignore

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

+ 5 - 0
.goreleaser.yml

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

+ 1 - 1
.releaserc.yaml

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

+ 8 - 1
Makefile

@@ -8,6 +8,12 @@ service:
 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:
 	$(MAKE) -wC service unittests
 
@@ -59,10 +65,11 @@ clean:
 	$(call delete-files,OliveTin)
 	$(call delete-files,OliveTin.armhf)
 	$(call delete-files,OliveTin.exe)
+	rm -f service/resource_windows_*.syso
 	$(call delete-files,reports)
 	$(call delete-files,gen)
 
 config-tool:
 	cd service && go run cmd/config-tool/main.go
 
-.PHONY: proto service
+.PHONY: proto service windows-resources windows-msi

+ 1 - 1
config.yaml

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

+ 48 - 4
docs/modules/ROOT/pages/advanced_configuration/prometheus.adoc

@@ -25,16 +25,60 @@ This will give you metrics available at http://yourserver:1337/metrics. The page
 [source]
 ----
 # 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
+# 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
 # TYPE olivetin_config_action_count gauge
 olivetin_config_action_count 18
 # HELP olivetin_config_reloaded_count The number of times the config has been reloaded
 # TYPE olivetin_config_reloaded_count counter
 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
 ----
 

+ 51 - 7
docs/modules/ROOT/pages/security/example_some_admin_actions.adoc

@@ -1,17 +1,30 @@
 = 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
 
 ```yaml
 logLevel: "INFO"
 
+defaultPermissions:
+  view: false
+  exec: false
+  logs: false
+
 accessControlLists:
-  - name: "noguests"
-    permissions: 
-      view: false
-      exec: false
+  - name: "guests"
+    permissions:
+      view: true
+      exec: true
       logs: false
     matchUsernames: [ "guest" ]
 
@@ -32,11 +45,12 @@ authLocalUsers:
 actions:
   - title: "Date"
     shell: date
+    acls:
+      - "guests"
 
   - 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:
-      - "noguests"
       - "admins"
 
 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.
+
+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].

+ 6 - 1
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -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)
 /* eslint-disable */
 
@@ -751,6 +751,11 @@ export declare type LogEntry = Message<"olivetin.api.v1.LogEntry"> & {
    * @generated from field: string justification = 23;
    */
   justification: string;
+
+  /**
+   * @generated from field: repeated olivetin.api.v1.StartActionArgument arguments = 24;
+   */
+  arguments: StartActionArgument[];
 };
 
 /**

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 345 - 0
frontend/resources/vue/components/ChoiceCombobox.vue

@@ -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>

+ 33 - 0
frontend/resources/vue/components/choiceComboboxHelpers.js

@@ -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
+  }
+}

+ 40 - 0
frontend/resources/vue/components/choiceComboboxHelpers.test.mjs

@@ -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'
+  })
+})

+ 68 - 0
frontend/resources/vue/utils/rerunArguments.js

@@ -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
+}

+ 117 - 0
frontend/resources/vue/utils/rerunArguments.test.mjs

@@ -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'
+    }
+  )
+})

+ 10 - 6
frontend/resources/vue/views/ArgumentForm.vue

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

+ 30 - 14
integration-tests/tests/multipleDropdowns/multipleDropdowns.js

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

+ 2 - 0
integration-tests/tests/prometheus/prometheus.mjs

@@ -8,6 +8,8 @@ import {
 
 let metrics = [
   {'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_reloaded_count', 'type': 'counter', 'desc': 'The number of times the config has been reloaded'},
 ]

+ 1 - 0
proto/olivetin/api/v1/olivetin.proto

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

+ 6 - 5
service/Makefile

@@ -19,10 +19,11 @@ compile-x64-lin:
 	go build -o OliveTin
 	go env -u GOOS
 
-compile-x64-win:
-	go env -w GOOS=windows GOARCH=amd64
-	go build -o OliveTin.exe
-	go env -u GOOS GOARCH
+compile-x64-win: windows-resources
+	GOOS=windows GOARCH=amd64 go build -o OliveTin.exe
+
+windows-resources:
+	$(MAKE) -wC .. windows-resources
 
 compile: compile-armhf compile-x64-lin compile-x64-win
 
@@ -33,7 +34,7 @@ codestyle: go-tools
 	gocritic check ./...
 
 test: unittests
-	
+
 tests: unittests
 
 unittests:

+ 94 - 84
service/gen/olivetin/api/v1/olivetin.pb.go

@@ -1424,6 +1424,7 @@ type LogEntry struct {
 	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"`
 	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
 	sizeCache                protoimpl.SizeCache
 }
@@ -1605,6 +1606,13 @@ func (x *LogEntry) GetJustification() string {
 	return ""
 }
 
+func (x *LogEntry) GetArguments() []*StartActionArgument {
+	if x != nil {
+		return x.Arguments
+	}
+	return nil
+}
+
 type GetLogsResponse struct {
 	state          protoimpl.MessageState `protogen:"open.v1"`
 	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" +
 	"dateFilter\x12\x1b\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" +
 	"\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\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" +
 	"\x06queued\x18\x15 \x01(\bR\x06queued\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" +
 	"\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\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
 	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, // 16: olivetin.api.v1.GetLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
-	21, // 17: olivetin.api.v1.GetActionLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
-	21, // 18: olivetin.api.v1.ExecutionQueueAction.entries:type_name -> olivetin.api.v1.LogEntry
-	26, // 19: olivetin.api.v1.ExecutionQueueGroup.actions:type_name -> olivetin.api.v1.ExecutionQueueAction
-	27, // 20: olivetin.api.v1.GetExecutionQueueResponse.groups:type_name -> olivetin.api.v1.ExecutionQueueGroup
-	21, // 21: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
-	34, // 22: olivetin.api.v1.ExecutionStatusResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
-	80, // 23: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
-	81, // 24: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
-	50, // 25: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged
-	51, // 26: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged
-	53, // 27: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
-	54, // 28: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted
-	49, // 29: olivetin.api.v1.EventStreamResponse.output_chunk:type_name -> olivetin.api.v1.EventOutputChunk
-	52, // 30: olivetin.api.v1.EventStreamResponse.heartbeat:type_name -> olivetin.api.v1.EventHeartbeat
-	21, // 31: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
-	21, // 32: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
-	68, // 33: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
-	67, // 34: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
-	7,  // 35: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
-	0,  // 36: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
-	34, // 37: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
-	73, // 38: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
-	5,  // 39: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
-	42, // 40: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
-	8,  // 41: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
-	11, // 42: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
-	14, // 43: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
-	16, // 44: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
-	18, // 45: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
-	75, // 46: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
-	55, // 47: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
-	33, // 48: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
-	20, // 49: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
-	23, // 50: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
-	25, // 51: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest
-	29, // 52: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
-	36, // 53: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
-	38, // 54: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest
-	40, // 55: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
-	43, // 56: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
-	45, // 57: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
-	57, // 58: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
-	59, // 59: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
-	61, // 60: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
-	47, // 61: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
-	63, // 62: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
-	65, // 63: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
-	69, // 64: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
-	71, // 65: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
-	74, // 66: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
-	6,  // 67: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
-	13, // 68: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
-	15, // 69: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
-	17, // 70: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
-	19, // 71: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
-	13, // 72: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
-	56, // 73: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
-	35, // 74: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
-	22, // 75: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
-	24, // 76: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
-	28, // 77: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse
-	30, // 78: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
-	37, // 79: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
-	39, // 80: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse
-	41, // 81: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
-	44, // 82: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
-	46, // 83: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
-	58, // 84: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
-	60, // 85: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
-	62, // 86: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
-	48, // 87: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
-	64, // 88: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
-	66, // 89: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
-	70, // 90: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
-	72, // 91: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
-	5,  // 92: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
-	67, // [67:93] is the sub-list for method output_type
-	41, // [41:67] is the sub-list for method input_type
-	41, // [41:41] is the sub-list for extension type_name
-	41, // [41:41] is the sub-list for extension extendee
-	0,  // [0:41] is the sub-list for field type_name
+	12, // 16: olivetin.api.v1.LogEntry.arguments:type_name -> olivetin.api.v1.StartActionArgument
+	21, // 17: olivetin.api.v1.GetLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
+	21, // 18: olivetin.api.v1.GetActionLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
+	21, // 19: olivetin.api.v1.ExecutionQueueAction.entries:type_name -> olivetin.api.v1.LogEntry
+	26, // 20: olivetin.api.v1.ExecutionQueueGroup.actions:type_name -> olivetin.api.v1.ExecutionQueueAction
+	27, // 21: olivetin.api.v1.GetExecutionQueueResponse.groups:type_name -> olivetin.api.v1.ExecutionQueueGroup
+	21, // 22: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
+	34, // 23: olivetin.api.v1.ExecutionStatusResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
+	80, // 24: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
+	81, // 25: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
+	50, // 26: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged
+	51, // 27: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged
+	53, // 28: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
+	54, // 29: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted
+	49, // 30: olivetin.api.v1.EventStreamResponse.output_chunk:type_name -> olivetin.api.v1.EventOutputChunk
+	52, // 31: olivetin.api.v1.EventStreamResponse.heartbeat:type_name -> olivetin.api.v1.EventHeartbeat
+	21, // 32: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
+	21, // 33: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
+	68, // 34: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
+	67, // 35: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
+	7,  // 36: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
+	0,  // 37: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
+	34, // 38: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
+	73, // 39: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
+	5,  // 40: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
+	42, // 41: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
+	8,  // 42: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
+	11, // 43: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
+	14, // 44: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
+	16, // 45: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
+	18, // 46: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
+	75, // 47: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
+	55, // 48: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
+	33, // 49: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
+	20, // 50: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
+	23, // 51: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
+	25, // 52: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest
+	29, // 53: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
+	36, // 54: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
+	38, // 55: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest
+	40, // 56: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
+	43, // 57: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
+	45, // 58: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
+	57, // 59: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
+	59, // 60: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
+	61, // 61: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
+	47, // 62: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
+	63, // 63: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
+	65, // 64: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
+	69, // 65: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
+	71, // 66: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
+	74, // 67: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
+	6,  // 68: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
+	13, // 69: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
+	15, // 70: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
+	17, // 71: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
+	19, // 72: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
+	13, // 73: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
+	56, // 74: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
+	35, // 75: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
+	22, // 76: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
+	24, // 77: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
+	28, // 78: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse
+	30, // 79: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
+	37, // 80: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
+	39, // 81: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse
+	41, // 82: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
+	44, // 83: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
+	46, // 84: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
+	58, // 85: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
+	60, // 86: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
+	62, // 87: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
+	48, // 88: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
+	64, // 89: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
+	66, // 90: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
+	70, // 91: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
+	72, // 92: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
+	5,  // 93: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
+	68, // [68:94] is the sub-list for method output_type
+	42, // [42:68] is the sub-list for method input_type
+	42, // [42:42] is the sub-list for extension 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() }

+ 5 - 3
service/internal/api/api.go

@@ -388,6 +388,7 @@ func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry
 		BindingId:                logEntry.GetBindingId(),
 		DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
 		Justification:            logEntry.Justification,
+		Arguments:                logEntryArgumentsToProto(logEntry.Arguments),
 	}
 
 	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
 	}
 
-	if execReqLogEntry.Binding.Action.Justification {
-		return nil, restartRequiresJustificationError()
+	if err := validateRestartLogEntry(execReqLogEntry); err != nil {
+		return nil, err
 	}
 
 	authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
 	execReq := executor.ExecutionRequest{
 		Binding:           execReqLogEntry.Binding,
-		Arguments:         make(map[string]string),
+		Arguments:         copyStringMap(execReqLogEntry.Arguments),
+		Justification:     execReqLogEntry.Justification,
 		AuthenticatedUser: authenticatedUser,
 		Cfg:               api.cfg,
 	}

+ 59 - 0
service/internal/api/api_log_arguments.go

@@ -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
+}

+ 382 - 0
service/internal/api/api_log_arguments_test.go

@@ -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")
+}

+ 9 - 0
service/internal/config/config_reloader.go

@@ -8,6 +8,7 @@ import (
 	"sort"
 	"strings"
 
+	"github.com/go-viper/mapstructure/v2"
 	"github.com/knadh/koanf/parsers/yaml"
 	"github.com/knadh/koanf/providers/file"
 	"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 {
 	err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
 		Tag: "koanf",
+		DecoderConfig: &mapstructure.DecoderConfig{
+			DecodeHook: mapstructure.ComposeDecodeHookFunc(
+				envDecodeHookFunc,
+				mapstructure.StringToTimeDurationHookFunc(),
+				mapstructure.TextUnmarshallerHookFunc(),
+			),
+			WeaklyTypedInput: true,
+		},
 	})
 
 	if err != nil {

+ 34 - 36
service/internal/config/config_reloader_test.go

@@ -89,50 +89,48 @@ var envConfigTests = []struct {
 	}},
 }
 
-func TestEnvInConfig(t *testing.T) {
-	t.Skip("Skipping test in 3k")
-
-	for _, tt := range envConfigTests {
-		cfg := DefaultConfig()
-		setIfNotEmpty("INPUT", tt.input)
-		processed := processYamlWithEnv(tt.yaml)
-		k, err := loadKoanf(processed)
-		if err != nil {
-			t.Errorf("Error loading YAML: %v", err)
-			continue
+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)
 		}
-
-		if err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
-			Tag: "koanf",
-		}); err != nil {
-			t.Errorf("Error unmarshalling config: %v", err)
-			continue
-		}
-		field := tt.selector(cfg)
-		assert.Equal(t, tt.output, field, "Unmarshaled config field doesn't match expected value: env=\"%s\"", tt.input)
-		os.Unsetenv("INPUT")
+		return
+	}
+	if err := os.Unsetenv("INPUT"); err != nil {
+		t.Fatalf("Unsetenv INPUT: %v", err)
 	}
 }
 
-func setIfNotEmpty(key, val string) {
-	if val != "" {
-		os.Setenv(key, val)
+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 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 TestEnvInConfig(t *testing.T) {
+	originalInput, hadInput := os.LookupEnv("INPUT")
+	t.Cleanup(func() {
+		if hadInput {
+			setTestEnvInput(t, originalInput)
+			return
+		}
+		setTestEnvInput(t, "")
 	})
-}
 
-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
+	for _, tt := range envConfigTests {
+		cfg := DefaultConfig()
+		setTestEnvInput(t, tt.input)
+		if !loadConfigFromYAML(t, tt.yaml, cfg) {
+			t.Errorf("Error unmarshalling config for env=%q", tt.input)
+			continue
+		}
+		field := tt.selector(cfg)
+		assert.Equal(t, tt.output, field,
+			"Unmarshaled config field doesn't match expected value: env=%q", tt.input)
 	}
-	return k, nil
 }

+ 5 - 10
service/internal/executor/executor.go

@@ -11,8 +11,6 @@ import (
 	"github.com/google/uuid"
 	log "github.com/sirupsen/logrus"
 
-	"github.com/prometheus/client_golang/prometheus"
-	"github.com/prometheus/client_golang/prometheus/promauto"
 	"gopkg.in/yaml.v3"
 
 	"bytes"
@@ -40,13 +38,6 @@ func isValidTrackingID(id string) bool {
 	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 {
 	ID           string
 	Action       *config.Action
@@ -171,6 +162,7 @@ type InternalLogEntry struct {
 	ActionTitle   string
 	ActionIcon    string
 	Justification string
+	Arguments     map[string]string
 }
 
 // .Binding can be nil, so we need to handle that.
@@ -705,6 +697,8 @@ func (e *Executor) finishExecChain(req *ExecutionRequest) {
 		entry.ExecutionFinished = true
 	})
 
+	recordExecutionMetrics(req.logEntry)
+
 	notifyListenersFinished(req)
 	e.drainGroupQueue()
 }
@@ -863,6 +857,7 @@ func stepParseArgs(req *ExecutionRequest) bool {
 		return fail(req, err)
 	}
 	mangleInvalidArgumentValues(req)
+	copyStorableArgumentsToLogEntry(req)
 
 	if hasExec(req) {
 		return handleExecBranch(req)
@@ -1019,7 +1014,7 @@ func stepRequestActionPopulateLogEntry(req *ExecutionRequest) {
 		entry.Binding = req.Binding
 		entry.ActionConfigTitle = req.Binding.Action.Title
 		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.Justification = ResolveJustification(req)
 		if req.Binding.Entity != nil {

+ 27 - 0
service/internal/executor/executor_test.go

@@ -10,6 +10,7 @@ import (
 	"github.com/OliveTin/OliveTin/internal/auth"
 	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/entities"
 )
 
 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")
 }
 
+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) {
 	e, cfg := testingExecutor()
 

+ 18 - 21
service/internal/executor/group_concurrency_test.go

@@ -508,36 +508,33 @@ func TestGroupQueueBlocksWhenQueueFull(t *testing.T) {
 		},
 	)
 
-	trackings, waitGroups := execAllGroupActions(t, e, cfg, actions)
-
-	require.Eventually(t, func() bool {
-		return countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Blocked }) == 1 &&
-			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 {
-		wg.Wait()
-	}
-}
-
-func execAllGroupActions(t *testing.T, e *Executor, cfg *config.Config, actions []*config.Action) ([]string, []*sync.WaitGroup) {
-	t.Helper()
+	wg1, tracking1 := e.ExecRequest(&ExecutionRequest{
+		Binding:           e.FindBindingWithNoEntity(actions[0]),
+		Cfg:               cfg,
+		AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
+	})
+	waitUntilExecutionStarted(t, e, tracking1)
 
-	trackings := make([]string, len(actions))
-	waitGroups := make([]*sync.WaitGroup, len(actions))
+	trackings := []string{tracking1}
+	waitGroups := []*sync.WaitGroup{wg1}
 
-	for idx, action := range actions {
+	for _, action := range actions[1:] {
 		wg, tracking := e.ExecRequest(&ExecutionRequest{
 			Binding:           e.FindBindingWithNoEntity(action),
 			Cfg:               cfg,
 			AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
 		})
-		trackings[idx] = tracking
-		waitGroups[idx] = wg
+		trackings = append(trackings, tracking)
+		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 {

+ 141 - 0
service/internal/executor/log_arguments.go

@@ -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 == ""
+}

+ 157 - 0
service/internal/executor/log_arguments_test.go

@@ -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{}))
+}

+ 95 - 0
service/internal/executor/prometheus.go

@@ -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)
+}

+ 76 - 0
service/internal/executor/prometheus_test.go

@@ -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))
+		})
+	}
+}

+ 25 - 0
var/windows/OliveTin.exe.manifest

@@ -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


+ 46 - 0
var/windows/OliveTin.wxs

@@ -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 - 0
var/windows/build-msi.sh

@@ -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}"

+ 94 - 0
var/windows/generate-resources.sh

@@ -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}"

+ 17 - 0
var/windows/goreleaser-release-with-msi.sh

@@ -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 "$@"

+ 45 - 0
var/windows/upload-msi-release.sh

@@ -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}"

+ 41 - 0
var/windows/versioninfo.json

@@ -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"
+    }
+  }
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов