Explorar el Código

Merge branch 'next' into feat/better-prometheus-metrics

James Read hace 5 días
padre
commit
5fca2d9482
Se han modificado 31 ficheros con 1550 adiciones y 128 borrados
  1. 6 0
      .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. 6 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  7. 1 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  8. 20 12
      frontend/resources/vue/components/ChoiceCombobox.vue
  9. 33 0
      frontend/resources/vue/components/choiceComboboxHelpers.js
  10. 40 0
      frontend/resources/vue/components/choiceComboboxHelpers.test.mjs
  11. 68 0
      frontend/resources/vue/utils/rerunArguments.js
  12. 117 0
      frontend/resources/vue/utils/rerunArguments.test.mjs
  13. 9 5
      integration-tests/tests/multipleDropdowns/multipleDropdowns.js
  14. 1 0
      proto/olivetin/api/v1/olivetin.proto
  15. 6 5
      service/Makefile
  16. 94 84
      service/gen/olivetin/api/v1/olivetin.pb.go
  17. 5 3
      service/internal/api/api.go
  18. 59 0
      service/internal/api/api_log_arguments.go
  19. 376 0
      service/internal/api/api_log_arguments_test.go
  20. 34 15
      service/internal/config/config_reloader_test.go
  21. 2 0
      service/internal/executor/executor.go
  22. 141 0
      service/internal/executor/log_arguments.go
  23. 157 0
      service/internal/executor/log_arguments_test.go
  24. 25 0
      var/windows/OliveTin.exe.manifest
  25. BIN
      var/windows/OliveTin.ico
  26. 46 0
      var/windows/OliveTin.wxs
  27. 92 0
      var/windows/build-msi.sh
  28. 94 0
      var/windows/generate-resources.sh
  29. 17 0
      var/windows/goreleaser-release-with-msi.sh
  30. 45 0
      var/windows/upload-msi-release.sh
  31. 41 0
      var/windows/versioninfo.json

+ 6 - 0
.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:
@@ -112,6 +114,10 @@ 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
         if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
         uses: goreleaser/goreleaser-action@v6

+ 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

+ 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[];
 };
 
 /**

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 1
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 20 - 12
frontend/resources/vue/components/ChoiceCombobox.vue

@@ -52,6 +52,10 @@
 
 <script setup>
 import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import {
+  choiceDisplayLabel,
+  syncStateFromModelValue
+} from './choiceComboboxHelpers.js'
 
 const props = defineProps({
   id: {
@@ -117,23 +121,23 @@ const filteredChoices = computed(() => {
   })
 })
 
-watch(() => props.modelValue, () => {
+watch([() => props.modelValue, () => props.choices], () => {
   if (!isOpen.value) {
-    query.value = selectedLabel()
+    syncFromModelValue()
   }
 }, { immediate: true })
 
 function choiceLabel(choice) {
-  return choice.title || choice.value
+  return choiceDisplayLabel(choice)
 }
 
-function selectedLabel() {
-  const match = props.choices.find(choice => choice.value === props.modelValue)
-  if (!match) {
-    return props.modelValue || ''
-  }
+function syncFromModelValue() {
+  const next = syncStateFromModelValue(props.choices, props.modelValue)
+  query.value = next.query
 
-  return choiceLabel(match)
+  if (next.modelValue !== props.modelValue) {
+    emitValue(next.modelValue)
+  }
 }
 
 function openList() {
@@ -144,7 +148,7 @@ function openList() {
 
 function closeList() {
   isOpen.value = false
-  query.value = selectedLabel()
+  syncFromModelValue()
 }
 
 function emitValue(value) {
@@ -153,11 +157,15 @@ function emitValue(value) {
 
 function selectChoice(choice) {
   emitValue(choice.value)
-  closeList()
+  query.value = choiceLabel(choice)
+  isOpen.value = false
 }
 
 function handleFocus() {
-  query.value = isOpen.value ? query.value : selectedLabel()
+  if (!isOpen.value) {
+    syncFromModelValue()
+  }
+
   openList()
 }
 

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

+ 9 - 5
integration-tests/tests/multipleDropdowns/multipleDropdowns.js

@@ -5,6 +5,8 @@ import {
   getRootAndWait,
   getActionButtons,
   takeScreenshotOnFailure,
+  waitForArgumentFormPage,
+  waitForArgumentFormReady,
 } from '../../lib/elements.js'
 
 
@@ -46,11 +48,13 @@ 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)
+    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'))
 

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

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

@@ -0,0 +1,376 @@
+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)
+
+	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)
+
+	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)
+
+	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")
+}

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

@@ -89,29 +89,48 @@ var envConfigTests = []struct {
 	}},
 }
 
+func setTestEnvInput(t *testing.T, input string) {
+	t.Helper()
+	if input != "" {
+		if err := os.Setenv("INPUT", input); err != nil {
+			t.Fatalf("Setenv INPUT: %v", err)
+		}
+		return
+	}
+	if err := os.Unsetenv("INPUT"); err != nil {
+		t.Fatalf("Unsetenv INPUT: %v", err)
+	}
+}
+
+func loadConfigFromYAML(t *testing.T, yamlStr string, cfg *Config) bool {
+	t.Helper()
+	k := koanf.New(".")
+	if err := k.Load(rawbytes.Provider([]byte(yamlStr)), yaml.Parser()); err != nil {
+		t.Errorf("Error loading YAML: %v", err)
+		return false
+	}
+	return unmarshalRoot(k, cfg)
+}
+
 func TestEnvInConfig(t *testing.T) {
+	originalInput, hadInput := os.LookupEnv("INPUT")
+	t.Cleanup(func() {
+		if hadInput {
+			setTestEnvInput(t, originalInput)
+			return
+		}
+		setTestEnvInput(t, "")
+	})
+
 	for _, tt := range envConfigTests {
 		cfg := DefaultConfig()
-		setIfNotEmpty("INPUT", tt.input)
-		k := koanf.New(".")
-		err := k.Load(rawbytes.Provider([]byte(tt.yaml)), yaml.Parser())
-		if err != nil {
-			t.Errorf("Error loading YAML: %v", err)
-			continue
-		}
-		if !unmarshalRoot(k, cfg) {
+		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)
-		os.Unsetenv("INPUT")
-	}
-}
-
-func setIfNotEmpty(key, val string) {
-	if val != "" {
-		os.Setenv(key, val)
 	}
 }

+ 2 - 0
service/internal/executor/executor.go

@@ -162,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.
@@ -856,6 +857,7 @@ func stepParseArgs(req *ExecutionRequest) bool {
 		return fail(req, err)
 	}
 	mangleInvalidArgumentValues(req)
+	copyStorableArgumentsToLogEntry(req)
 
 	if hasExec(req) {
 		return handleExecBranch(req)

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

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

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio