From 2fe6d306aa87eae9625d9b35b73f76b0630f8233 Mon Sep 17 00:00:00 2001 From: Andrew Savinykh <658865+AndrewSav@users.noreply.github.com> Date: Sun, 15 Feb 2026 20:36:03 +1300 Subject: [PATCH 01/16] substitute environment variables when loading yaml config --- service/internal/config/config_reloader.go | 9 +++++ .../internal/config/config_reloader_test.go | 34 +++---------------- 2 files changed, 14 insertions(+), 29 deletions(-) diff --git a/service/internal/config/config_reloader.go b/service/internal/config/config_reloader.go index 3bb8ee0..b931e80 100644 --- a/service/internal/config/config_reloader.go +++ b/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 { diff --git a/service/internal/config/config_reloader_test.go b/service/internal/config/config_reloader_test.go index 5cece40..f96544e 100644 --- a/service/internal/config/config_reloader_test.go +++ b/service/internal/config/config_reloader_test.go @@ -90,26 +90,19 @@ 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) + 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 err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{ - Tag: "koanf", - }); err != nil { - t.Errorf("Error unmarshalling config: %v", err) - continue - } + unmarshalRoot(k, cfg) field := tt.selector(cfg) - assert.Equal(t, tt.output, field, "Unmarshaled config field doesn't match expected value: env=\"%s\"", tt.input) + assert.Equal(t, tt.output, field, + "Unmarshaled config field doesn't match expected value: env=%q", tt.input) os.Unsetenv("INPUT") } } @@ -119,20 +112,3 @@ func setIfNotEmpty(key, val string) { os.Setenv(key, val) } } - -func processYamlWithEnv(content string) string { - return envRegex.ReplaceAllStringFunc(content, func(match string) string { - submatches := envRegex.FindStringSubmatch(match) - key := submatches[1] - val, _ := os.LookupEnv(key) - return val - }) -} - -func loadKoanf(processed string) (*koanf.Koanf, error) { - k := koanf.New(".") - if err := k.Load(rawbytes.Provider([]byte(processed)), yaml.Parser()); err != nil { - return nil, err - } - return k, nil -} From f58eeef49d3a59f314bf69d5eeb176695f82ae5b Mon Sep 17 00:00:00 2001 From: Andrew Savinykh <658865+AndrewSav@users.noreply.github.com> Date: Sun, 15 Feb 2026 21:28:23 +1300 Subject: [PATCH 02/16] check return value from `unmarshalRoot` in test and fail if false Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- service/internal/config/config_reloader_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/service/internal/config/config_reloader_test.go b/service/internal/config/config_reloader_test.go index f96544e..39dad61 100644 --- a/service/internal/config/config_reloader_test.go +++ b/service/internal/config/config_reloader_test.go @@ -99,7 +99,10 @@ func TestEnvInConfig(t *testing.T) { t.Errorf("Error loading YAML: %v", err) continue } - unmarshalRoot(k, cfg) + if !unmarshalRoot(k, 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) From 1f8f36ace148cc38e89089b5d14396c0c41fda02 Mon Sep 17 00:00:00 2001 From: jamesread Date: Fri, 19 Jun 2026 22:28:54 +0100 Subject: [PATCH 03/16] docs: bug in "some admin actions" example --- .../security/example_some_admin_actions.adoc | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/docs/modules/ROOT/pages/security/example_some_admin_actions.adoc b/docs/modules/ROOT/pages/security/example_some_admin_actions.adoc index 5ebcaf7..8d11086 100644 --- a/docs/modules/ROOT/pages/security/example_some_admin_actions.adoc +++ b/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]. From 43eccdaf4894f1dfab4ae0fe9f720d3b7d569604 Mon Sep 17 00:00:00 2001 From: jamesread Date: Fri, 19 Jun 2026 22:59:56 +0100 Subject: [PATCH 04/16] feat: filtering comboboxes (#887) --- config.yaml | 2 +- .../vue/components/ChoiceCombobox.vue | 345 ++++++++++++++++++ frontend/resources/vue/views/ArgumentForm.vue | 16 +- .../multipleDropdowns/multipleDropdowns.js | 25 +- 4 files changed, 373 insertions(+), 15 deletions(-) create mode 100644 frontend/resources/vue/components/ChoiceCombobox.vue diff --git a/config.yaml b/config.yaml index b41ab82..c50b2df 100644 --- a/config.yaml +++ b/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 diff --git a/frontend/resources/vue/components/ChoiceCombobox.vue b/frontend/resources/vue/components/ChoiceCombobox.vue new file mode 100644 index 0000000..0d17316 --- /dev/null +++ b/frontend/resources/vue/components/ChoiceCombobox.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/frontend/resources/vue/views/ArgumentForm.vue b/frontend/resources/vue/views/ArgumentForm.vue index 4ccd463..f62bf89 100644 --- a/frontend/resources/vue/views/ArgumentForm.vue +++ b/frontend/resources/vue/views/ArgumentForm.vue @@ -21,12 +21,9 @@ - + { + const lists = await comboboxes[1].findElements(By.css('.choice-combobox-list li')) + return lists.length === 3 + }), 2000) }) }) From 97105a27c501eea90b5b13db684eb046c57ec151 Mon Sep 17 00:00:00 2001 From: jamesread Date: Fri, 19 Jun 2026 23:21:00 +0100 Subject: [PATCH 05/16] feat: filtering comboboxes (#887) --- .../vue/components/ChoiceCombobox.vue | 36 ++++++++----------- .../multipleDropdowns/multipleDropdowns.js | 5 ++- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/frontend/resources/vue/components/ChoiceCombobox.vue b/frontend/resources/vue/components/ChoiceCombobox.vue index 0d17316..e2488b9 100644 --- a/frontend/resources/vue/components/ChoiceCombobox.vue +++ b/frontend/resources/vue/components/ChoiceCombobox.vue @@ -2,6 +2,7 @@
    Date: Sat, 20 Jun 2026 01:26:27 +0100 Subject: [PATCH 07/16] chore: dont login to dockerhub on PR --- .github/workflows/build-and-release.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index b42ded7..b9ba5a0 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -73,13 +73,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 }} @@ -111,15 +113,17 @@ jobs: !integration-tests/node_modules - name: Install goreleaser + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false uses: goreleaser/goreleaser-action@v6 with: install-only: true - name: Set up Docker Buildx + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false uses: docker/setup-buildx-action@v3 - name: release - if: github.ref_type != 'tag' + if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) uses: cycjimmy/semantic-release-action@v4 with: extra_plugins: | @@ -131,8 +135,8 @@ jobs: GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }} - name: Archive binaries + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false uses: actions/upload-artifact@v4.3.1 with: name: "OliveTin-snapshot-${{ env.DATE }}-${{ github.sha }}" path: dist/OliveTin*.* - From d1787a38235cb28275a9a8bae3af5eb214934a92 Mon Sep 17 00:00:00 2001 From: jamesread Date: Thu, 25 Jun 2026 23:05:47 +0100 Subject: [PATCH 08/16] feat: Better prometheus support --- .../advanced_configuration/prometheus.adoc | 52 +++++++++++- .../tests/prometheus/prometheus.mjs | 2 + service/internal/executor/executor.go | 11 +-- service/internal/executor/prometheus.go | 81 +++++++++++++++++++ service/internal/executor/prometheus_test.go | 76 +++++++++++++++++ 5 files changed, 209 insertions(+), 13 deletions(-) create mode 100644 service/internal/executor/prometheus.go create mode 100644 service/internal/executor/prometheus_test.go diff --git a/docs/modules/ROOT/pages/advanced_configuration/prometheus.adoc b/docs/modules/ROOT/pages/advanced_configuration/prometheus.adoc index 7c811e2..a105709 100644 --- a/docs/modules/ROOT/pages/advanced_configuration/prometheus.adoc +++ b/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 ---- diff --git a/integration-tests/tests/prometheus/prometheus.mjs b/integration-tests/tests/prometheus/prometheus.mjs index 7d1f2ea..d9bbba7 100644 --- a/integration-tests/tests/prometheus/prometheus.mjs +++ b/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'}, ] diff --git a/service/internal/executor/executor.go b/service/internal/executor/executor.go index 605c7ad..af4bb8b 100644 --- a/service/internal/executor/executor.go +++ b/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 @@ -705,6 +696,8 @@ func (e *Executor) finishExecChain(req *ExecutionRequest) { entry.ExecutionFinished = true }) + recordExecutionMetrics(req.logEntry) + notifyListenersFinished(req) e.drainGroupQueue() } diff --git a/service/internal/executor/prometheus.go b/service/internal/executor/prometheus.go new file mode 100644 index 0000000..c70be23 --- /dev/null +++ b/service/internal/executor/prometheus.go @@ -0,0 +1,81 @@ +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}, + }) +) + +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) +} diff --git a/service/internal/executor/prometheus_test.go b/service/internal/executor/prometheus_test.go new file mode 100644 index 0000000..8bb969c --- /dev/null +++ b/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)) + }) + } +} From abf4fc2cf0074c124458baf07c4243198ab9e449 Mon Sep 17 00:00:00 2001 From: jamesread Date: Fri, 26 Jun 2026 21:19:19 +0100 Subject: [PATCH 09/16] feat: Windows icons and MSI support --- .github/workflows/build-and-release.yml | 6 ++ .gitignore | 1 + .goreleaser.yml | 1 + .releaserc.yaml | 2 + Makefile | 7 ++ service/Makefile | 7 +- var/windows/OliveTin.exe.manifest | 25 +++++++ var/windows/OliveTin.ico | Bin 0 -> 109595 bytes var/windows/OliveTin.wxs | 46 ++++++++++++ var/windows/build-msi.sh | 93 ++++++++++++++++++++++++ var/windows/generate-resources.sh | 92 +++++++++++++++++++++++ var/windows/upload-msi-release.sh | 40 ++++++++++ var/windows/versioninfo.json | 41 +++++++++++ 13 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 var/windows/OliveTin.exe.manifest create mode 100644 var/windows/OliveTin.ico create mode 100644 var/windows/OliveTin.wxs create mode 100755 var/windows/build-msi.sh create mode 100755 var/windows/generate-resources.sh create mode 100755 var/windows/upload-msi-release.sh create mode 100644 var/windows/versioninfo.json diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index b9ba5a0..5fc1d7c 100644 --- a/.github/workflows/build-and-release.yml +++ b/.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 diff --git a/.gitignore b/.gitignore index 48e8d33..469fbd0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ service/OliveTin service/OliveTin.armhf service/OliveTin.exe +service/resource_windows_*.syso service/reports releases/ dist/ diff --git a/.goreleaser.yml b/.goreleaser.yml index a6ba475..32c6a76 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -3,6 +3,7 @@ version: 2 before: hooks: - make service-prep + - make windows-resources VERSION={{ .Version }} builds: - env: diff --git a/.releaserc.yaml b/.releaserc.yaml index c3ddbdc..fc571cf 100644 --- a/.releaserc.yaml +++ b/.releaserc.yaml @@ -10,5 +10,7 @@ plugins: - - "@semantic-release/exec" - publishCmd: | goreleaser release --clean --timeout 60m + VERSION=${nextRelease.version} ./var/windows/build-msi.sh + ./var/windows/upload-msi-release.sh ${nextRelease.gitTag} tagFormat: '${version}' diff --git a/Makefile b/Makefile index 23062c2..7ce7955 100644 --- a/Makefile +++ b/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,6 +65,7 @@ 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) diff --git a/service/Makefile b/service/Makefile index dee7cc9..5d3044a 100644 --- a/service/Makefile +++ b/service/Makefile @@ -19,11 +19,14 @@ compile-x64-lin: go build -o OliveTin go env -u GOOS -compile-x64-win: +compile-x64-win: windows-resources go env -w GOOS=windows GOARCH=amd64 go build -o OliveTin.exe go env -u GOOS GOARCH +windows-resources: + $(MAKE) -wC .. windows-resources + compile: compile-armhf compile-x64-lin compile-x64-win codestyle: go-tools @@ -33,7 +36,7 @@ codestyle: go-tools gocritic check ./... test: unittests - + tests: unittests unittests: diff --git a/var/windows/OliveTin.exe.manifest b/var/windows/OliveTin.exe.manifest new file mode 100644 index 0000000..90faaa7 --- /dev/null +++ b/var/windows/OliveTin.exe.manifest @@ -0,0 +1,25 @@ + + + + + + + + + + + true + PerMonitorV2 + + + diff --git a/var/windows/OliveTin.ico b/var/windows/OliveTin.ico new file mode 100644 index 0000000000000000000000000000000000000000..446dd82e1cfa38cd517d8b2027382c01da987f0a GIT binary patch literal 109595 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 literal 0 HcmV?d00001 diff --git a/var/windows/OliveTin.wxs b/var/windows/OliveTin.wxs new file mode 100644 index 0000000..807dd5b --- /dev/null +++ b/var/windows/OliveTin.wxs @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/var/windows/build-msi.sh b/var/windows/build-msi.sh new file mode 100755 index 0000000..42aeb07 --- /dev/null +++ b/var/windows/build-msi.sh @@ -0,0 +1,93 @@ +#!/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_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}" +} + +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 +WINDOWS_VERSION="$(normalize_windows_version "${VERSION}")" + +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=${WINDOWS_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}" diff --git a/var/windows/generate-resources.sh b/var/windows/generate-resources.sh new file mode 100755 index 0000000..50e562c --- /dev/null +++ b/var/windows/generate-resources.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)" +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 </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}" + +go install "github.com/josephspurrier/goversioninfo/cmd/goversioninfo@${GOVERSIONINFO_VERSION}" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "${WORK_DIR}"' EXIT + +( + cd "${WORK_DIR}" + 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}" diff --git a/var/windows/upload-msi-release.sh b/var/windows/upload-msi-release.sh new file mode 100755 index 0000000..3773c28 --- /dev/null +++ b/var/windows/upload-msi-release.sh @@ -0,0 +1,40 @@ +#!/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") " >&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" +if [[ -f "${checksums_path}" ]] && ! grep -qF " ${MSI_NAME}" "${checksums_path}"; then + ( + cd "${DIST_DIR}" + sha256sum "${MSI_NAME}" + ) >> "${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}" diff --git a/var/windows/versioninfo.json b/var/windows/versioninfo.json new file mode 100644 index 0000000..7822648 --- /dev/null +++ b/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" + } + } +} From b749b797f86d0d3ad0727e00f0f19e6d186c34b5 Mon Sep 17 00:00:00 2001 From: jamesread Date: Fri, 26 Jun 2026 21:42:12 +0100 Subject: [PATCH 10/16] fix: Build MSI in goreleaser build --- .goreleaser.yml | 4 ++++ .releaserc.yaml | 4 +--- Makefile | 2 +- var/windows/OliveTin.wxs | 2 +- var/windows/build-msi.sh | 11 +++++------ var/windows/generate-resources.sh | 6 ++++-- var/windows/goreleaser-release-with-msi.sh | 17 +++++++++++++++++ var/windows/upload-msi-release.sh | 15 ++++++++++----- 8 files changed, 43 insertions(+), 18 deletions(-) create mode 100755 var/windows/goreleaser-release-with-msi.sh diff --git a/.goreleaser.yml b/.goreleaser.yml index 32c6a76..f299d24 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -43,6 +43,8 @@ builds: checksum: name_template: 'checksums.txt' + extra_files: + - glob: ./dist/OliveTin-windows-amd64.msi snapshot: version_template: "{{ .Branch }}-{{ .ShortCommit }}" changelog: @@ -176,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) diff --git a/.releaserc.yaml b/.releaserc.yaml index fc571cf..1aa94a5 100644 --- a/.releaserc.yaml +++ b/.releaserc.yaml @@ -9,8 +9,6 @@ plugins: - '@semantic-release/git' - - "@semantic-release/exec" - publishCmd: | - goreleaser release --clean --timeout 60m - VERSION=${nextRelease.version} ./var/windows/build-msi.sh - ./var/windows/upload-msi-release.sh ${nextRelease.gitTag} + VERSION=${nextRelease.version} GORELEASER_TIMEOUT=60m ./var/windows/goreleaser-release-with-msi.sh tagFormat: '${version}' diff --git a/Makefile b/Makefile index 7ce7955..186b56e 100644 --- a/Makefile +++ b/Makefile @@ -72,4 +72,4 @@ clean: config-tool: cd service && go run cmd/config-tool/main.go -.PHONY: proto service +.PHONY: proto service windows-resources windows-msi diff --git a/var/windows/OliveTin.wxs b/var/windows/OliveTin.wxs index 807dd5b..7884d8a 100644 --- a/var/windows/OliveTin.wxs +++ b/var/windows/OliveTin.wxs @@ -34,7 +34,7 @@ - + /dev/null || ! command -v wixl-heat >/dev/null; then exit 1 fi -normalize_windows_version() { +normalize_msi_version() { local raw="${1#v}" raw="${raw%%-*}" if [[ ! "${raw}" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]]; then - echo "0.0.0.0" + echo "0.0.0" return fi local -a parts=() @@ -32,8 +32,7 @@ normalize_windows_version() { 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}" + printf '%s.%s.%s' "${major}" "${minor}" "${patch}" } VERSION="${VERSION:-}" @@ -44,7 +43,7 @@ if [[ -z "${VERSION}" ]]; then echo "Could not determine release version; set VERSION explicitly" >&2 exit 1 fi -WINDOWS_VERSION="$(normalize_windows_version "${VERSION}")" +MSI_VERSION="$(normalize_msi_version "${VERSION}")" STAGING="$(mktemp -d)" APP_STAGING="$(mktemp -d)" @@ -82,7 +81,7 @@ cp -a "${SOURCE_ROOT}/webui/." "${APP_STAGING}/webui/" wixl \ -v \ -a x64 \ - -D "Version=${WINDOWS_VERSION}" \ + -D "Version=${MSI_VERSION}" \ -D "Win64=yes" \ -D "SourceDir=${APP_STAGING}" \ -D "ConfigSource=${SOURCE_ROOT}/config.yaml" \ diff --git a/var/windows/generate-resources.sh b/var/windows/generate-resources.sh index 50e562c..4728641 100755 --- a/var/windows/generate-resources.sh +++ b/var/windows/generate-resources.sh @@ -70,14 +70,16 @@ 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}"' EXIT +trap 'rm -rf "${WORK_DIR}" "${TOOL_BIN%/*}"' EXIT ( cd "${WORK_DIR}" - goversioninfo \ + "${TOOL_BIN}/goversioninfo" \ -64 \ -platform-specific \ -icon="${ICON_PATH}" \ diff --git a/var/windows/goreleaser-release-with-msi.sh b/var/windows/goreleaser-release-with-msi.sh new file mode 100755 index 0000000..d69a5e5 --- /dev/null +++ b/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 "$@" diff --git a/var/windows/upload-msi-release.sh b/var/windows/upload-msi-release.sh index 3773c28..6295c97 100755 --- a/var/windows/upload-msi-release.sh +++ b/var/windows/upload-msi-release.sh @@ -25,11 +25,16 @@ if ! command -v gh >/dev/null; then fi checksums_path="${DIST_DIR}/checksums.txt" -if [[ -f "${checksums_path}" ]] && ! grep -qF " ${MSI_NAME}" "${checksums_path}"; then - ( - cd "${DIST_DIR}" - sha256sum "${MSI_NAME}" - ) >> "${checksums_path}" +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}" + 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 From 42003da38452d8326838000a8c9b08bfdfca4515 Mon Sep 17 00:00:00 2001 From: jamesread Date: Fri, 26 Jun 2026 23:25:24 +0100 Subject: [PATCH 11/16] fix: rerun args --- .../gen/olivetin/api/v1/olivetin_pb.d.ts | 7 +- .../gen/olivetin/api/v1/olivetin_pb.js | 4 +- .../resources/vue/utils/rerunArguments.js | 59 ++++ .../vue/utils/rerunArguments.test.mjs | 103 ++++++ proto/olivetin/api/v1/olivetin.proto | 1 + service/gen/olivetin/api/v1/olivetin.pb.go | 178 +++++----- service/internal/api/api.go | 7 +- service/internal/api/api_log_arguments.go | 38 +++ .../internal/api/api_log_arguments_test.go | 307 ++++++++++++++++++ service/internal/executor/executor.go | 2 + service/internal/executor/log_arguments.go | 95 ++++++ .../internal/executor/log_arguments_test.go | 121 +++++++ 12 files changed, 833 insertions(+), 89 deletions(-) create mode 100644 frontend/resources/vue/utils/rerunArguments.js create mode 100644 frontend/resources/vue/utils/rerunArguments.test.mjs create mode 100644 service/internal/api/api_log_arguments.go create mode 100644 service/internal/api/api_log_arguments_test.go create mode 100644 service/internal/executor/log_arguments.go create mode 100644 service/internal/executor/log_arguments_test.go diff --git a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts b/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts index 78e8d7a..03a2c00 100644 --- a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts +++ b/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[]; }; /** diff --git a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js b/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js index 0ca3819..5e993ca 100644 --- a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js +++ b/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js @@ -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 */ @@ -8,7 +8,7 @@ import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2 * Describes the file olivetin/api/v1/olivetin.proto. */ export const file_olivetin_api_v1_olivetin = /*@__PURE__*/ - fileDesc("Ch5vbGl2ZXRpbi9hcGkvdjEvb2xpdmV0aW4ucHJvdG8SD29saXZldGluLmFwaS52MSLABAoGQWN0aW9uEhIKCmJpbmRpbmdfaWQYASABKAkSDQoFdGl0bGUYAiABKAkSDAoEaWNvbhgDIAEoCRIQCghjYW5fZXhlYxgEIAEoCBIyCglhcmd1bWVudHMYBSADKAsyHy5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnQSFgoOcG9wdXBfb25fc3RhcnQYBiABKAkSDQoFb3JkZXIYByABKAUSDwoHdGltZW91dBgIIAEoBRIjChtkYXRldGltZV9yYXRlX2xpbWl0X2V4cGlyZXMYCSABKAkSFwoPZXhlY19vbl9zdGFydHVwGAogASgIEhQKDGV4ZWNfb25fY3JvbhgLIAMoCRIjChtleGVjX29uX2ZpbGVfY3JlYXRlZF9pbl9kaXIYDCADKAkSIwobZXhlY19vbl9maWxlX2NoYW5nZWRfaW5fZGlyGA0gAygJEh0KFWV4ZWNfb25fY2FsZW5kYXJfZmlsZRgOIAEoCRJAChBleGVjX29uX3dlYmhvb2tzGA8gAygLMiYub2xpdmV0aW4uYXBpLnYxLkFjdGlvbldlYmhvb2tFeGVjSGludBIVCg1qdXN0aWZpY2F0aW9uGBAgASgIEhwKFGhhc19ydW5uaW5nX2luc3RhbmNlGBEgASgIEhsKE2hhc19xdWV1ZWRfaW5zdGFuY2UYEiABKAgSNgoGZ3JvdXBzGBMgAygLMiYub2xpdmV0aW4uYXBpLnYxLkFjdGlvbkdyb3VwTWVtYmVyc2hpcCJRChVBY3Rpb25Hcm91cE1lbWJlcnNoaXASDAoEbmFtZRgBIAEoCRIWCg5tYXhfY29uY3VycmVudBgCIAEoBRISCgpxdWV1ZV9zaXplGAMgASgFIsMCChVBY3Rpb25XZWJob29rRXhlY0hpbnQSEAoIdGVtcGxhdGUYASABKAkSEgoKbWF0Y2hfcGF0aBgCIAEoCRJPCg1tYXRjaF9oZWFkZXJzGAMgAygLMjgub2xpdmV0aW4uYXBpLnYxLkFjdGlvbldlYmhvb2tFeGVjSGludC5NYXRjaEhlYWRlcnNFbnRyeRJLCgttYXRjaF9xdWVyeRgEIAMoCzI2Lm9saXZldGluLmFwaS52MS5BY3Rpb25XZWJob29rRXhlY0hpbnQuTWF0Y2hRdWVyeUVudHJ5GjMKEU1hdGNoSGVhZGVyc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEaMQoPTWF0Y2hRdWVyeUVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiuwIKDkFjdGlvbkFyZ3VtZW50EgwKBG5hbWUYASABKAkSDQoFdGl0bGUYAiABKAkSDAoEdHlwZRgDIAEoCRIVCg1kZWZhdWx0X3ZhbHVlGAQgASgJEjYKB2Nob2ljZXMYBSADKAsyJS5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnRDaG9pY2USEwoLZGVzY3JpcHRpb24YBiABKAkSRQoLc3VnZ2VzdGlvbnMYByADKAsyMC5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnQuU3VnZ2VzdGlvbnNFbnRyeRIfChdzdWdnZXN0aW9uc19icm93c2VyX2tleRgIIAEoCRoyChBTdWdnZXN0aW9uc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiNAoUQWN0aW9uQXJndW1lbnRDaG9pY2USDQoFdmFsdWUYASABKAkSDQoFdGl0bGUYAiABKAkisgEKBkVudGl0eRINCgV0aXRsZRgBIAEoCRISCgp1bmlxdWVfa2V5GAIgASgJEgwKBHR5cGUYAyABKAkSEwoLZGlyZWN0b3JpZXMYBCADKAkSMwoGZmllbGRzGAUgAygLMiMub2xpdmV0aW4uYXBpLnYxLkVudGl0eS5GaWVsZHNFbnRyeRotCgtGaWVsZHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIlQKFEdldERhc2hib2FyZFJlc3BvbnNlEg0KBXRpdGxlGAEgASgJEi0KCWRhc2hib2FyZBgEIAEoCzIaLm9saXZldGluLmFwaS52MS5EYXNoYm9hcmQiXwoPRWZmZWN0aXZlUG9saWN5EhgKEHNob3dfZGlhZ25vc3RpY3MYASABKAgSFQoNc2hvd19sb2dfbGlzdBgCIAEoCBIbChNzaG93X3ZlcnNpb25fbnVtYmVyGAMgASgIIk0KE0dldERhc2hib2FyZFJlcXVlc3QSDQoFdGl0bGUYASABKAkSEwoLZW50aXR5X3R5cGUYAiABKAkSEgoKZW50aXR5X2tleRgDIAEoCSJRCglEYXNoYm9hcmQSDQoFdGl0bGUYASABKAkSNQoIY29udGVudHMYAiADKAsyIy5vbGl2ZXRpbi5hcGkudjEuRGFzaGJvYXJkQ29tcG9uZW50ItsBChJEYXNoYm9hcmRDb21wb25lbnQSDQoFdGl0bGUYASABKAkSDAoEdHlwZRgCIAEoCRI1Cghjb250ZW50cxgDIAMoCzIjLm9saXZldGluLmFwaS52MS5EYXNoYm9hcmRDb21wb25lbnQSDAoEaWNvbhgEIAEoCRIRCgljc3NfY2xhc3MYBSABKAkSJwoGYWN0aW9uGAYgASgLMhcub2xpdmV0aW4uYXBpLnYxLkFjdGlvbhITCgtlbnRpdHlfdHlwZRgHIAEoCRISCgplbnRpdHlfa2V5GAggASgJIpQBChJTdGFydEFjdGlvblJlcXVlc3QSEgoKYmluZGluZ19pZBgBIAEoCRI3Cglhcmd1bWVudHMYAiADKAsyJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25Bcmd1bWVudBIaChJ1bmlxdWVfdHJhY2tpbmdfaWQYAyABKAkSFQoNanVzdGlmaWNhdGlvbhgEIAEoCSIyChNTdGFydEFjdGlvbkFyZ3VtZW50EgwKBG5hbWUYASABKAkSDQoFdmFsdWUYAiABKAkiNAoTU3RhcnRBY3Rpb25SZXNwb25zZRIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYAiABKAkifgoZU3RhcnRBY3Rpb25BbmRXYWl0UmVxdWVzdBIRCglhY3Rpb25faWQYASABKAkSNwoJYXJndW1lbnRzGAIgAygLMiQub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQXJndW1lbnQSFQoNanVzdGlmaWNhdGlvbhgDIAEoCSJKChpTdGFydEFjdGlvbkFuZFdhaXRSZXNwb25zZRIsCglsb2dfZW50cnkYASABKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkiLAoXU3RhcnRBY3Rpb25CeUdldFJlcXVlc3QSEQoJYWN0aW9uX2lkGAEgASgJIjkKGFN0YXJ0QWN0aW9uQnlHZXRSZXNwb25zZRIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYAiABKAkiMwoeU3RhcnRBY3Rpb25CeUdldEFuZFdhaXRSZXF1ZXN0EhEKCWFjdGlvbl9pZBgBIAEoCSJPCh9TdGFydEFjdGlvbkJ5R2V0QW5kV2FpdFJlc3BvbnNlEiwKCWxvZ19lbnRyeRgBIAEoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSJeCg5HZXRMb2dzUmVxdWVzdBIUCgxzdGFydF9vZmZzZXQYASABKAMSEwoLZGF0ZV9maWx0ZXIYAiABKAkSEQoJcGFnZV9zaXplGAMgASgDEg4KBmZpbHRlchgEIAEoCSLbAwoITG9nRW50cnkSGAoQZGF0ZXRpbWVfc3RhcnRlZBgBIAEoCRIUCgxhY3Rpb25fdGl0bGUYAiABKAkSDgoGb3V0cHV0GAMgASgJEhEKCXRpbWVkX291dBgFIAEoCBIRCglleGl0X2NvZGUYBiABKAUSDAoEdXNlchgHIAEoCRISCgp1c2VyX2NsYXNzGAggASgJEhMKC2FjdGlvbl9pY29uGAkgASgJEgwKBHRhZ3MYCiADKAkSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAsgASgJEhkKEWRhdGV0aW1lX2ZpbmlzaGVkGAwgASgJEhkKEWV4ZWN1dGlvbl9zdGFydGVkGA4gASgIEhoKEmV4ZWN1dGlvbl9maW5pc2hlZBgPIAEoCBIPCgdibG9ja2VkGBAgASgIEhYKDmRhdGV0aW1lX2luZGV4GBEgASgDEhAKCGNhbl9raWxsGBIgASgIEiMKG2RhdGV0aW1lX3JhdGVfbGltaXRfZXhwaXJlcxgTIAEoCRISCgpiaW5kaW5nX2lkGBQgASgJEg4KBnF1ZXVlZBgVIAEoCBIYChBxdWV1ZWRfZm9yX2dyb3VwGBYgASgJEhUKDWp1c3RpZmljYXRpb24YFyABKAkikQEKD0dldExvZ3NSZXNwb25zZRInCgRsb2dzGAEgAygLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5EhcKD2NvdW50X3JlbWFpbmluZxgCIAEoAxIRCglwYWdlX3NpemUYAyABKAMSEwoLdG90YWxfY291bnQYBCABKAMSFAoMc3RhcnRfb2Zmc2V0GAUgASgDIj8KFEdldEFjdGlvbkxvZ3NSZXF1ZXN0EhEKCWFjdGlvbl9pZBgBIAEoCRIUCgxzdGFydF9vZmZzZXQYAiABKAMilwEKFUdldEFjdGlvbkxvZ3NSZXNwb25zZRInCgRsb2dzGAEgAygLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5EhcKD2NvdW50X3JlbWFpbmluZxgCIAEoAxIRCglwYWdlX3NpemUYAyABKAMSEwoLdG90YWxfY291bnQYBCABKAMSFAoMc3RhcnRfb2Zmc2V0GAUgASgDIhoKGEdldEV4ZWN1dGlvblF1ZXVlUmVxdWVzdCLGAQoURXhlY3V0aW9uUXVldWVBY3Rpb24SEgoKYmluZGluZ19pZBgBIAEoCRIUCgxhY3Rpb25fdGl0bGUYAiABKAkSEwoLYWN0aW9uX2ljb24YAyABKAkSFgoObWF4X2NvbmN1cnJlbnQYBCABKAUSFAoMYWN0aXZlX2NvdW50GAUgASgFEhUKDWVudGl0eV9wcmVmaXgYBiABKAkSKgoHZW50cmllcxgHIAMoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSLBAQoTRXhlY3V0aW9uUXVldWVHcm91cBIMCgRuYW1lGAEgASgJEgwKBGljb24YAiABKAkSFgoObWF4X2NvbmN1cnJlbnQYAyABKAUSFAoMYWN0aXZlX2NvdW50GAQgASgFEjYKB2FjdGlvbnMYBSADKAsyJS5vbGl2ZXRpbi5hcGkudjEuRXhlY3V0aW9uUXVldWVBY3Rpb24SFAoMcXVldWVkX2NvdW50GAYgASgFEhIKCnF1ZXVlX3NpemUYByABKAUiZwoZR2V0RXhlY3V0aW9uUXVldWVSZXNwb25zZRI0CgZncm91cHMYASADKAsyJC5vbGl2ZXRpbi5hcGkudjEuRXhlY3V0aW9uUXVldWVHcm91cBIUCgx0b3RhbF9hY3RpdmUYAiABKAUiZQobVmFsaWRhdGVBcmd1bWVudFR5cGVSZXF1ZXN0Eg0KBXZhbHVlGAEgASgJEgwKBHR5cGUYAiABKAkSEgoKYmluZGluZ19pZBgDIAEoCRIVCg1hcmd1bWVudF9uYW1lGAQgASgJIkIKHFZhbGlkYXRlQXJndW1lbnRUeXBlUmVzcG9uc2USDQoFdmFsaWQYASABKAgSEwoLZGVzY3JpcHRpb24YAiABKAkiNgoVV2F0Y2hFeGVjdXRpb25SZXF1ZXN0Eh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCSImChRXYXRjaEV4ZWN1dGlvblVwZGF0ZRIOCgZ1cGRhdGUYASABKAkiSgoWRXhlY3V0aW9uU3RhdHVzUmVxdWVzdBIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYASABKAkSEQoJYWN0aW9uX2lkGAIgASgJImEKGURhc2hib2FyZE5hdmlnYXRpb25UYXJnZXQSDQoFdGl0bGUYASABKAkSEwoLZW50aXR5X3R5cGUYAiABKAkSEgoKZW50aXR5X2tleRgDIAEoCRIMCgRwYXRoGAQgASgJIo8BChdFeGVjdXRpb25TdGF0dXNSZXNwb25zZRIsCglsb2dfZW50cnkYASABKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkSRgoSYmFja190b19kYXNoYm9hcmRzGAIgAygLMioub2xpdmV0aW4uYXBpLnYxLkRhc2hib2FyZE5hdmlnYXRpb25UYXJnZXQiDwoNV2hvQW1JUmVxdWVzdCJsCg5XaG9BbUlSZXNwb25zZRIaChJhdXRoZW50aWNhdGVkX3VzZXIYASABKAkSEQoJdXNlcmdyb3VwGAIgASgJEhAKCHByb3ZpZGVyGAMgASgJEgwKBGFjbHMYBCADKAkSCwoDc2lkGAUgASgJIhIKEFNvc1JlcG9ydFJlcXVlc3QiIgoRU29zUmVwb3J0UmVzcG9uc2USDQoFYWxlcnQYASABKAkiEQoPRHVtcFZhcnNSZXF1ZXN0IpUBChBEdW1wVmFyc1Jlc3BvbnNlEg0KBWFsZXJ0GAEgASgJEkEKCGNvbnRlbnRzGAIgAygLMi8ub2xpdmV0aW4uYXBpLnYxLkR1bXBWYXJzUmVzcG9uc2UuQ29udGVudHNFbnRyeRovCg1Db250ZW50c0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiOwoMRGVidWdCaW5kaW5nEhQKDGFjdGlvbl90aXRsZRgBIAEoCRIVCg1lbnRpdHlfcHJlZml4GAIgASgJIh4KHER1bXBQdWJsaWNJZEFjdGlvbk1hcFJlcXVlc3QizgEKHUR1bXBQdWJsaWNJZEFjdGlvbk1hcFJlc3BvbnNlEg0KBWFsZXJ0GAEgASgJEk4KCGNvbnRlbnRzGAIgAygLMjwub2xpdmV0aW4uYXBpLnYxLkR1bXBQdWJsaWNJZEFjdGlvbk1hcFJlc3BvbnNlLkNvbnRlbnRzRW50cnkaTgoNQ29udGVudHNFbnRyeRILCgNrZXkYASABKAkSLAoFdmFsdWUYAiABKAsyHS5vbGl2ZXRpbi5hcGkudjEuRGVidWdCaW5kaW5nOgI4ASISChBHZXRSZWFkeXpSZXF1ZXN0IiMKEUdldFJlYWR5elJlc3BvbnNlEg4KBnN0YXR1cxgBIAEoCSIUChJFdmVudFN0cmVhbVJlcXVlc3QimQMKE0V2ZW50U3RyZWFtUmVzcG9uc2USPQoOZW50aXR5X2NoYW5nZWQYAiABKAsyIy5vbGl2ZXRpbi5hcGkudjEuRXZlbnRFbnRpdHlDaGFuZ2VkSAASPQoOY29uZmlnX2NoYW5nZWQYAyABKAsyIy5vbGl2ZXRpbi5hcGkudjEuRXZlbnRDb25maWdDaGFuZ2VkSAASRQoSZXhlY3V0aW9uX2ZpbmlzaGVkGAQgASgLMicub2xpdmV0aW4uYXBpLnYxLkV2ZW50RXhlY3V0aW9uRmluaXNoZWRIABJDChFleGVjdXRpb25fc3RhcnRlZBgFIAEoCzImLm9saXZldGluLmFwaS52MS5FdmVudEV4ZWN1dGlvblN0YXJ0ZWRIABI5CgxvdXRwdXRfY2h1bmsYBiABKAsyIS5vbGl2ZXRpbi5hcGkudjEuRXZlbnRPdXRwdXRDaHVua0gAEjQKCWhlYXJ0YmVhdBgHIAEoCzIfLm9saXZldGluLmFwaS52MS5FdmVudEhlYXJ0YmVhdEgAQgcKBWV2ZW50IkEKEEV2ZW50T3V0cHV0Q2h1bmsSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJEg4KBm91dHB1dBgCIAEoCSIUChJFdmVudEVudGl0eUNoYW5nZWQiFAoSRXZlbnRDb25maWdDaGFuZ2VkIhAKDkV2ZW50SGVhcnRiZWF0IkYKFkV2ZW50RXhlY3V0aW9uRmluaXNoZWQSLAoJbG9nX2VudHJ5GAEgASgLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5IkUKFUV2ZW50RXhlY3V0aW9uU3RhcnRlZBIsCglsb2dfZW50cnkYASABKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkiMgoRS2lsbEFjdGlvblJlcXVlc3QSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJIm0KEktpbGxBY3Rpb25SZXNwb25zZRIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYASABKAkSDgoGa2lsbGVkGAIgASgIEhkKEWFscmVhZHlfY29tcGxldGVkGAMgASgIEg0KBWZvdW5kGAQgASgIIjsKFUxvY2FsVXNlckxvZ2luUmVxdWVzdBIQCgh1c2VybmFtZRgBIAEoCRIQCghwYXNzd29yZBgCIAEoCSIpChZMb2NhbFVzZXJMb2dpblJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgiJwoTUGFzc3dvcmRIYXNoUmVxdWVzdBIQCghwYXNzd29yZBgBIAEoCSIkChRQYXNzd29yZEhhc2hSZXNwb25zZRIMCgRoYXNoGAEgASgJIg8KDUxvZ291dFJlcXVlc3QiEAoOTG9nb3V0UmVzcG9uc2UiFwoVR2V0RGlhZ25vc3RpY3NSZXF1ZXN0IkUKFkdldERpYWdub3N0aWNzUmVzcG9uc2USEwoLU3NoRm91bmRLZXkYASABKAkSFgoOU3NoRm91bmRDb25maWcYAiABKAkiDQoLSW5pdFJlcXVlc3Qi6wUKDEluaXRSZXNwb25zZRISCgpzaG93Rm9vdGVyGAEgASgIEhYKDnNob3dOYXZpZ2F0aW9uGAIgASgIEhcKD3Nob3dOZXdWZXJzaW9ucxgDIAEoCBIYChBhdmFpbGFibGVWZXJzaW9uGAQgASgJEhYKDmN1cnJlbnRWZXJzaW9uGAUgASgJEhEKCXBhZ2VUaXRsZRgGIAEoCRIeChZzZWN0aW9uTmF2aWdhdGlvblN0eWxlGAcgASgJEhoKEmRlZmF1bHRJY29uRm9yQmFjaxgIIAEoCRIWCg5lbmFibGVDdXN0b21KcxgJIAEoCBIUCgxhdXRoTG9naW5VcmwYCiABKAkSFgoOYXV0aExvY2FsTG9naW4YCyABKAgSEQoJc3R5bGVNb2RzGAwgAygJEjgKD29BdXRoMlByb3ZpZGVycxgNIAMoCzIfLm9saXZldGluLmFwaS52MS5PQXV0aDJQcm92aWRlchI4Cg9hZGRpdGlvbmFsTGlua3MYDiADKAsyHy5vbGl2ZXRpbi5hcGkudjEuQWRkaXRpb25hbExpbmsSFgoOcm9vdERhc2hib2FyZHMYDyADKAkSGgoSYXV0aGVudGljYXRlZF91c2VyGBAgASgJEiMKG2F1dGhlbnRpY2F0ZWRfdXNlcl9wcm92aWRlchgRIAEoCRI6ChBlZmZlY3RpdmVfcG9saWN5GBIgASgLMiAub2xpdmV0aW4uYXBpLnYxLkVmZmVjdGl2ZVBvbGljeRIWCg5iYW5uZXJfbWVzc2FnZRgTIAEoCRISCgpiYW5uZXJfY3NzGBQgASgJEhgKEHNob3dfZGlhZ25vc3RpY3MYFSABKAgSFQoNc2hvd19sb2dfbGlzdBgWIAEoCBIWCg5sb2dpbl9yZXF1aXJlZBgXIAEoCBIYChBhdmFpbGFibGVfdGhlbWVzGBggAygJEiQKHHNob3dfbmF2aWdhdGVfb25fc3RhcnRfaWNvbnMYGSABKAgiLAoOQWRkaXRpb25hbExpbmsSDQoFdGl0bGUYASABKAkSCwoDdXJsGAIgASgJIjoKDk9BdXRoMlByb3ZpZGVyEg0KBXRpdGxlGAEgASgJEgwKBGljb24YAyABKAkSCwoDa2V5GAQgASgJIi0KF0dldEFjdGlvbkJpbmRpbmdSZXF1ZXN0EhIKCmJpbmRpbmdfaWQYASABKAkiiwEKGEdldEFjdGlvbkJpbmRpbmdSZXNwb25zZRInCgZhY3Rpb24YASABKAsyFy5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uEkYKEmJhY2tfdG9fZGFzaGJvYXJkcxgCIAMoCzIqLm9saXZldGluLmFwaS52MS5EYXNoYm9hcmROYXZpZ2F0aW9uVGFyZ2V0IhQKEkdldEVudGl0aWVzUmVxdWVzdCJUChNHZXRFbnRpdGllc1Jlc3BvbnNlEj0KEmVudGl0eV9kZWZpbml0aW9ucxgBIAMoCzIhLm9saXZldGluLmFwaS52MS5FbnRpdHlEZWZpbml0aW9uImkKEEVudGl0eURlZmluaXRpb24SDQoFdGl0bGUYASABKAkSKgoJaW5zdGFuY2VzGAIgAygLMhcub2xpdmV0aW4uYXBpLnYxLkVudGl0eRIaChJ1c2VkX29uX2Rhc2hib2FyZHMYAyADKAkiNAoQR2V0RW50aXR5UmVxdWVzdBISCgp1bmlxdWVfa2V5GAEgASgJEgwKBHR5cGUYAiABKAkiNQoUUmVzdGFydEFjdGlvblJlcXVlc3QSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJMtYTChJPbGl2ZVRpbkFwaVNlcnZpY2USXQoMR2V0RGFzaGJvYXJkEiQub2xpdmV0aW4uYXBpLnYxLkdldERhc2hib2FyZFJlcXVlc3QaJS5vbGl2ZXRpbi5hcGkudjEuR2V0RGFzaGJvYXJkUmVzcG9uc2UiABJaCgtTdGFydEFjdGlvbhIjLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvblJlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25SZXNwb25zZSIAEm8KElN0YXJ0QWN0aW9uQW5kV2FpdBIqLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkFuZFdhaXRSZXF1ZXN0Gisub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQW5kV2FpdFJlc3BvbnNlIgASaQoQU3RhcnRBY3Rpb25CeUdldBIoLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkJ5R2V0UmVxdWVzdBopLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkJ5R2V0UmVzcG9uc2UiABJ+ChdTdGFydEFjdGlvbkJ5R2V0QW5kV2FpdBIvLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkJ5R2V0QW5kV2FpdFJlcXVlc3QaMC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25CeUdldEFuZFdhaXRSZXNwb25zZSIAEl4KDVJlc3RhcnRBY3Rpb24SJS5vbGl2ZXRpbi5hcGkudjEuUmVzdGFydEFjdGlvblJlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25SZXNwb25zZSIAElcKCktpbGxBY3Rpb24SIi5vbGl2ZXRpbi5hcGkudjEuS2lsbEFjdGlvblJlcXVlc3QaIy5vbGl2ZXRpbi5hcGkudjEuS2lsbEFjdGlvblJlc3BvbnNlIgASZgoPRXhlY3V0aW9uU3RhdHVzEicub2xpdmV0aW4uYXBpLnYxLkV4ZWN1dGlvblN0YXR1c1JlcXVlc3QaKC5vbGl2ZXRpbi5hcGkudjEuRXhlY3V0aW9uU3RhdHVzUmVzcG9uc2UiABJOCgdHZXRMb2dzEh8ub2xpdmV0aW4uYXBpLnYxLkdldExvZ3NSZXF1ZXN0GiAub2xpdmV0aW4uYXBpLnYxLkdldExvZ3NSZXNwb25zZSIAEmAKDUdldEFjdGlvbkxvZ3MSJS5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uTG9nc1JlcXVlc3QaJi5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uTG9nc1Jlc3BvbnNlIgASbAoRR2V0RXhlY3V0aW9uUXVldWUSKS5vbGl2ZXRpbi5hcGkudjEuR2V0RXhlY3V0aW9uUXVldWVSZXF1ZXN0Gioub2xpdmV0aW4uYXBpLnYxLkdldEV4ZWN1dGlvblF1ZXVlUmVzcG9uc2UiABJ1ChRWYWxpZGF0ZUFyZ3VtZW50VHlwZRIsLm9saXZldGluLmFwaS52MS5WYWxpZGF0ZUFyZ3VtZW50VHlwZVJlcXVlc3QaLS5vbGl2ZXRpbi5hcGkudjEuVmFsaWRhdGVBcmd1bWVudFR5cGVSZXNwb25zZSIAEksKBldob0FtSRIeLm9saXZldGluLmFwaS52MS5XaG9BbUlSZXF1ZXN0Gh8ub2xpdmV0aW4uYXBpLnYxLldob0FtSVJlc3BvbnNlIgASVAoJU29zUmVwb3J0EiEub2xpdmV0aW4uYXBpLnYxLlNvc1JlcG9ydFJlcXVlc3QaIi5vbGl2ZXRpbi5hcGkudjEuU29zUmVwb3J0UmVzcG9uc2UiABJRCghEdW1wVmFycxIgLm9saXZldGluLmFwaS52MS5EdW1wVmFyc1JlcXVlc3QaIS5vbGl2ZXRpbi5hcGkudjEuRHVtcFZhcnNSZXNwb25zZSIAEngKFUR1bXBQdWJsaWNJZEFjdGlvbk1hcBItLm9saXZldGluLmFwaS52MS5EdW1wUHVibGljSWRBY3Rpb25NYXBSZXF1ZXN0Gi4ub2xpdmV0aW4uYXBpLnYxLkR1bXBQdWJsaWNJZEFjdGlvbk1hcFJlc3BvbnNlIgASVAoJR2V0UmVhZHl6EiEub2xpdmV0aW4uYXBpLnYxLkdldFJlYWR5elJlcXVlc3QaIi5vbGl2ZXRpbi5hcGkudjEuR2V0UmVhZHl6UmVzcG9uc2UiABJjCg5Mb2NhbFVzZXJMb2dpbhImLm9saXZldGluLmFwaS52MS5Mb2NhbFVzZXJMb2dpblJlcXVlc3QaJy5vbGl2ZXRpbi5hcGkudjEuTG9jYWxVc2VyTG9naW5SZXNwb25zZSIAEl0KDFBhc3N3b3JkSGFzaBIkLm9saXZldGluLmFwaS52MS5QYXNzd29yZEhhc2hSZXF1ZXN0GiUub2xpdmV0aW4uYXBpLnYxLlBhc3N3b3JkSGFzaFJlc3BvbnNlIgASSwoGTG9nb3V0Eh4ub2xpdmV0aW4uYXBpLnYxLkxvZ291dFJlcXVlc3QaHy5vbGl2ZXRpbi5hcGkudjEuTG9nb3V0UmVzcG9uc2UiABJcCgtFdmVudFN0cmVhbRIjLm9saXZldGluLmFwaS52MS5FdmVudFN0cmVhbVJlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuRXZlbnRTdHJlYW1SZXNwb25zZSIAMAESYwoOR2V0RGlhZ25vc3RpY3MSJi5vbGl2ZXRpbi5hcGkudjEuR2V0RGlhZ25vc3RpY3NSZXF1ZXN0Gicub2xpdmV0aW4uYXBpLnYxLkdldERpYWdub3N0aWNzUmVzcG9uc2UiABJFCgRJbml0Ehwub2xpdmV0aW4uYXBpLnYxLkluaXRSZXF1ZXN0Gh0ub2xpdmV0aW4uYXBpLnYxLkluaXRSZXNwb25zZSIAEmkKEEdldEFjdGlvbkJpbmRpbmcSKC5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uQmluZGluZ1JlcXVlc3QaKS5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uQmluZGluZ1Jlc3BvbnNlIgASWgoLR2V0RW50aXRpZXMSIy5vbGl2ZXRpbi5hcGkudjEuR2V0RW50aXRpZXNSZXF1ZXN0GiQub2xpdmV0aW4uYXBpLnYxLkdldEVudGl0aWVzUmVzcG9uc2UiABJJCglHZXRFbnRpdHkSIS5vbGl2ZXRpbi5hcGkudjEuR2V0RW50aXR5UmVxdWVzdBoXLm9saXZldGluLmFwaS52MS5FbnRpdHkiAEI4WjZnaXRodWIuY29tL09saXZlVGluL09saXZlVGluL2dlbi9vbGl2ZXRpbi9hcGkvdjE7YXBpdjFiBnByb3RvMw"); + fileDesc("Ch5vbGl2ZXRpbi9hcGkvdjEvb2xpdmV0aW4ucHJvdG8SD29saXZldGluLmFwaS52MSLABAoGQWN0aW9uEhIKCmJpbmRpbmdfaWQYASABKAkSDQoFdGl0bGUYAiABKAkSDAoEaWNvbhgDIAEoCRIQCghjYW5fZXhlYxgEIAEoCBIyCglhcmd1bWVudHMYBSADKAsyHy5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnQSFgoOcG9wdXBfb25fc3RhcnQYBiABKAkSDQoFb3JkZXIYByABKAUSDwoHdGltZW91dBgIIAEoBRIjChtkYXRldGltZV9yYXRlX2xpbWl0X2V4cGlyZXMYCSABKAkSFwoPZXhlY19vbl9zdGFydHVwGAogASgIEhQKDGV4ZWNfb25fY3JvbhgLIAMoCRIjChtleGVjX29uX2ZpbGVfY3JlYXRlZF9pbl9kaXIYDCADKAkSIwobZXhlY19vbl9maWxlX2NoYW5nZWRfaW5fZGlyGA0gAygJEh0KFWV4ZWNfb25fY2FsZW5kYXJfZmlsZRgOIAEoCRJAChBleGVjX29uX3dlYmhvb2tzGA8gAygLMiYub2xpdmV0aW4uYXBpLnYxLkFjdGlvbldlYmhvb2tFeGVjSGludBIVCg1qdXN0aWZpY2F0aW9uGBAgASgIEhwKFGhhc19ydW5uaW5nX2luc3RhbmNlGBEgASgIEhsKE2hhc19xdWV1ZWRfaW5zdGFuY2UYEiABKAgSNgoGZ3JvdXBzGBMgAygLMiYub2xpdmV0aW4uYXBpLnYxLkFjdGlvbkdyb3VwTWVtYmVyc2hpcCJRChVBY3Rpb25Hcm91cE1lbWJlcnNoaXASDAoEbmFtZRgBIAEoCRIWCg5tYXhfY29uY3VycmVudBgCIAEoBRISCgpxdWV1ZV9zaXplGAMgASgFIsMCChVBY3Rpb25XZWJob29rRXhlY0hpbnQSEAoIdGVtcGxhdGUYASABKAkSEgoKbWF0Y2hfcGF0aBgCIAEoCRJPCg1tYXRjaF9oZWFkZXJzGAMgAygLMjgub2xpdmV0aW4uYXBpLnYxLkFjdGlvbldlYmhvb2tFeGVjSGludC5NYXRjaEhlYWRlcnNFbnRyeRJLCgttYXRjaF9xdWVyeRgEIAMoCzI2Lm9saXZldGluLmFwaS52MS5BY3Rpb25XZWJob29rRXhlY0hpbnQuTWF0Y2hRdWVyeUVudHJ5GjMKEU1hdGNoSGVhZGVyc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEaMQoPTWF0Y2hRdWVyeUVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiuwIKDkFjdGlvbkFyZ3VtZW50EgwKBG5hbWUYASABKAkSDQoFdGl0bGUYAiABKAkSDAoEdHlwZRgDIAEoCRIVCg1kZWZhdWx0X3ZhbHVlGAQgASgJEjYKB2Nob2ljZXMYBSADKAsyJS5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnRDaG9pY2USEwoLZGVzY3JpcHRpb24YBiABKAkSRQoLc3VnZ2VzdGlvbnMYByADKAsyMC5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnQuU3VnZ2VzdGlvbnNFbnRyeRIfChdzdWdnZXN0aW9uc19icm93c2VyX2tleRgIIAEoCRoyChBTdWdnZXN0aW9uc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiNAoUQWN0aW9uQXJndW1lbnRDaG9pY2USDQoFdmFsdWUYASABKAkSDQoFdGl0bGUYAiABKAkisgEKBkVudGl0eRINCgV0aXRsZRgBIAEoCRISCgp1bmlxdWVfa2V5GAIgASgJEgwKBHR5cGUYAyABKAkSEwoLZGlyZWN0b3JpZXMYBCADKAkSMwoGZmllbGRzGAUgAygLMiMub2xpdmV0aW4uYXBpLnYxLkVudGl0eS5GaWVsZHNFbnRyeRotCgtGaWVsZHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIlQKFEdldERhc2hib2FyZFJlc3BvbnNlEg0KBXRpdGxlGAEgASgJEi0KCWRhc2hib2FyZBgEIAEoCzIaLm9saXZldGluLmFwaS52MS5EYXNoYm9hcmQiXwoPRWZmZWN0aXZlUG9saWN5EhgKEHNob3dfZGlhZ25vc3RpY3MYASABKAgSFQoNc2hvd19sb2dfbGlzdBgCIAEoCBIbChNzaG93X3ZlcnNpb25fbnVtYmVyGAMgASgIIk0KE0dldERhc2hib2FyZFJlcXVlc3QSDQoFdGl0bGUYASABKAkSEwoLZW50aXR5X3R5cGUYAiABKAkSEgoKZW50aXR5X2tleRgDIAEoCSJRCglEYXNoYm9hcmQSDQoFdGl0bGUYASABKAkSNQoIY29udGVudHMYAiADKAsyIy5vbGl2ZXRpbi5hcGkudjEuRGFzaGJvYXJkQ29tcG9uZW50ItsBChJEYXNoYm9hcmRDb21wb25lbnQSDQoFdGl0bGUYASABKAkSDAoEdHlwZRgCIAEoCRI1Cghjb250ZW50cxgDIAMoCzIjLm9saXZldGluLmFwaS52MS5EYXNoYm9hcmRDb21wb25lbnQSDAoEaWNvbhgEIAEoCRIRCgljc3NfY2xhc3MYBSABKAkSJwoGYWN0aW9uGAYgASgLMhcub2xpdmV0aW4uYXBpLnYxLkFjdGlvbhITCgtlbnRpdHlfdHlwZRgHIAEoCRISCgplbnRpdHlfa2V5GAggASgJIpQBChJTdGFydEFjdGlvblJlcXVlc3QSEgoKYmluZGluZ19pZBgBIAEoCRI3Cglhcmd1bWVudHMYAiADKAsyJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25Bcmd1bWVudBIaChJ1bmlxdWVfdHJhY2tpbmdfaWQYAyABKAkSFQoNanVzdGlmaWNhdGlvbhgEIAEoCSIyChNTdGFydEFjdGlvbkFyZ3VtZW50EgwKBG5hbWUYASABKAkSDQoFdmFsdWUYAiABKAkiNAoTU3RhcnRBY3Rpb25SZXNwb25zZRIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYAiABKAkifgoZU3RhcnRBY3Rpb25BbmRXYWl0UmVxdWVzdBIRCglhY3Rpb25faWQYASABKAkSNwoJYXJndW1lbnRzGAIgAygLMiQub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQXJndW1lbnQSFQoNanVzdGlmaWNhdGlvbhgDIAEoCSJKChpTdGFydEFjdGlvbkFuZFdhaXRSZXNwb25zZRIsCglsb2dfZW50cnkYASABKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkiLAoXU3RhcnRBY3Rpb25CeUdldFJlcXVlc3QSEQoJYWN0aW9uX2lkGAEgASgJIjkKGFN0YXJ0QWN0aW9uQnlHZXRSZXNwb25zZRIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYAiABKAkiMwoeU3RhcnRBY3Rpb25CeUdldEFuZFdhaXRSZXF1ZXN0EhEKCWFjdGlvbl9pZBgBIAEoCSJPCh9TdGFydEFjdGlvbkJ5R2V0QW5kV2FpdFJlc3BvbnNlEiwKCWxvZ19lbnRyeRgBIAEoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSJeCg5HZXRMb2dzUmVxdWVzdBIUCgxzdGFydF9vZmZzZXQYASABKAMSEwoLZGF0ZV9maWx0ZXIYAiABKAkSEQoJcGFnZV9zaXplGAMgASgDEg4KBmZpbHRlchgEIAEoCSKUBAoITG9nRW50cnkSGAoQZGF0ZXRpbWVfc3RhcnRlZBgBIAEoCRIUCgxhY3Rpb25fdGl0bGUYAiABKAkSDgoGb3V0cHV0GAMgASgJEhEKCXRpbWVkX291dBgFIAEoCBIRCglleGl0X2NvZGUYBiABKAUSDAoEdXNlchgHIAEoCRISCgp1c2VyX2NsYXNzGAggASgJEhMKC2FjdGlvbl9pY29uGAkgASgJEgwKBHRhZ3MYCiADKAkSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAsgASgJEhkKEWRhdGV0aW1lX2ZpbmlzaGVkGAwgASgJEhkKEWV4ZWN1dGlvbl9zdGFydGVkGA4gASgIEhoKEmV4ZWN1dGlvbl9maW5pc2hlZBgPIAEoCBIPCgdibG9ja2VkGBAgASgIEhYKDmRhdGV0aW1lX2luZGV4GBEgASgDEhAKCGNhbl9raWxsGBIgASgIEiMKG2RhdGV0aW1lX3JhdGVfbGltaXRfZXhwaXJlcxgTIAEoCRISCgpiaW5kaW5nX2lkGBQgASgJEg4KBnF1ZXVlZBgVIAEoCBIYChBxdWV1ZWRfZm9yX2dyb3VwGBYgASgJEhUKDWp1c3RpZmljYXRpb24YFyABKAkSNwoJYXJndW1lbnRzGBggAygLMiQub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQXJndW1lbnQikQEKD0dldExvZ3NSZXNwb25zZRInCgRsb2dzGAEgAygLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5EhcKD2NvdW50X3JlbWFpbmluZxgCIAEoAxIRCglwYWdlX3NpemUYAyABKAMSEwoLdG90YWxfY291bnQYBCABKAMSFAoMc3RhcnRfb2Zmc2V0GAUgASgDIj8KFEdldEFjdGlvbkxvZ3NSZXF1ZXN0EhEKCWFjdGlvbl9pZBgBIAEoCRIUCgxzdGFydF9vZmZzZXQYAiABKAMilwEKFUdldEFjdGlvbkxvZ3NSZXNwb25zZRInCgRsb2dzGAEgAygLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5EhcKD2NvdW50X3JlbWFpbmluZxgCIAEoAxIRCglwYWdlX3NpemUYAyABKAMSEwoLdG90YWxfY291bnQYBCABKAMSFAoMc3RhcnRfb2Zmc2V0GAUgASgDIhoKGEdldEV4ZWN1dGlvblF1ZXVlUmVxdWVzdCLGAQoURXhlY3V0aW9uUXVldWVBY3Rpb24SEgoKYmluZGluZ19pZBgBIAEoCRIUCgxhY3Rpb25fdGl0bGUYAiABKAkSEwoLYWN0aW9uX2ljb24YAyABKAkSFgoObWF4X2NvbmN1cnJlbnQYBCABKAUSFAoMYWN0aXZlX2NvdW50GAUgASgFEhUKDWVudGl0eV9wcmVmaXgYBiABKAkSKgoHZW50cmllcxgHIAMoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSLBAQoTRXhlY3V0aW9uUXVldWVHcm91cBIMCgRuYW1lGAEgASgJEgwKBGljb24YAiABKAkSFgoObWF4X2NvbmN1cnJlbnQYAyABKAUSFAoMYWN0aXZlX2NvdW50GAQgASgFEjYKB2FjdGlvbnMYBSADKAsyJS5vbGl2ZXRpbi5hcGkudjEuRXhlY3V0aW9uUXVldWVBY3Rpb24SFAoMcXVldWVkX2NvdW50GAYgASgFEhIKCnF1ZXVlX3NpemUYByABKAUiZwoZR2V0RXhlY3V0aW9uUXVldWVSZXNwb25zZRI0CgZncm91cHMYASADKAsyJC5vbGl2ZXRpbi5hcGkudjEuRXhlY3V0aW9uUXVldWVHcm91cBIUCgx0b3RhbF9hY3RpdmUYAiABKAUiZQobVmFsaWRhdGVBcmd1bWVudFR5cGVSZXF1ZXN0Eg0KBXZhbHVlGAEgASgJEgwKBHR5cGUYAiABKAkSEgoKYmluZGluZ19pZBgDIAEoCRIVCg1hcmd1bWVudF9uYW1lGAQgASgJIkIKHFZhbGlkYXRlQXJndW1lbnRUeXBlUmVzcG9uc2USDQoFdmFsaWQYASABKAgSEwoLZGVzY3JpcHRpb24YAiABKAkiNgoVV2F0Y2hFeGVjdXRpb25SZXF1ZXN0Eh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCSImChRXYXRjaEV4ZWN1dGlvblVwZGF0ZRIOCgZ1cGRhdGUYASABKAkiSgoWRXhlY3V0aW9uU3RhdHVzUmVxdWVzdBIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYASABKAkSEQoJYWN0aW9uX2lkGAIgASgJImEKGURhc2hib2FyZE5hdmlnYXRpb25UYXJnZXQSDQoFdGl0bGUYASABKAkSEwoLZW50aXR5X3R5cGUYAiABKAkSEgoKZW50aXR5X2tleRgDIAEoCRIMCgRwYXRoGAQgASgJIo8BChdFeGVjdXRpb25TdGF0dXNSZXNwb25zZRIsCglsb2dfZW50cnkYASABKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkSRgoSYmFja190b19kYXNoYm9hcmRzGAIgAygLMioub2xpdmV0aW4uYXBpLnYxLkRhc2hib2FyZE5hdmlnYXRpb25UYXJnZXQiDwoNV2hvQW1JUmVxdWVzdCJsCg5XaG9BbUlSZXNwb25zZRIaChJhdXRoZW50aWNhdGVkX3VzZXIYASABKAkSEQoJdXNlcmdyb3VwGAIgASgJEhAKCHByb3ZpZGVyGAMgASgJEgwKBGFjbHMYBCADKAkSCwoDc2lkGAUgASgJIhIKEFNvc1JlcG9ydFJlcXVlc3QiIgoRU29zUmVwb3J0UmVzcG9uc2USDQoFYWxlcnQYASABKAkiEQoPRHVtcFZhcnNSZXF1ZXN0IpUBChBEdW1wVmFyc1Jlc3BvbnNlEg0KBWFsZXJ0GAEgASgJEkEKCGNvbnRlbnRzGAIgAygLMi8ub2xpdmV0aW4uYXBpLnYxLkR1bXBWYXJzUmVzcG9uc2UuQ29udGVudHNFbnRyeRovCg1Db250ZW50c0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiOwoMRGVidWdCaW5kaW5nEhQKDGFjdGlvbl90aXRsZRgBIAEoCRIVCg1lbnRpdHlfcHJlZml4GAIgASgJIh4KHER1bXBQdWJsaWNJZEFjdGlvbk1hcFJlcXVlc3QizgEKHUR1bXBQdWJsaWNJZEFjdGlvbk1hcFJlc3BvbnNlEg0KBWFsZXJ0GAEgASgJEk4KCGNvbnRlbnRzGAIgAygLMjwub2xpdmV0aW4uYXBpLnYxLkR1bXBQdWJsaWNJZEFjdGlvbk1hcFJlc3BvbnNlLkNvbnRlbnRzRW50cnkaTgoNQ29udGVudHNFbnRyeRILCgNrZXkYASABKAkSLAoFdmFsdWUYAiABKAsyHS5vbGl2ZXRpbi5hcGkudjEuRGVidWdCaW5kaW5nOgI4ASISChBHZXRSZWFkeXpSZXF1ZXN0IiMKEUdldFJlYWR5elJlc3BvbnNlEg4KBnN0YXR1cxgBIAEoCSIUChJFdmVudFN0cmVhbVJlcXVlc3QimQMKE0V2ZW50U3RyZWFtUmVzcG9uc2USPQoOZW50aXR5X2NoYW5nZWQYAiABKAsyIy5vbGl2ZXRpbi5hcGkudjEuRXZlbnRFbnRpdHlDaGFuZ2VkSAASPQoOY29uZmlnX2NoYW5nZWQYAyABKAsyIy5vbGl2ZXRpbi5hcGkudjEuRXZlbnRDb25maWdDaGFuZ2VkSAASRQoSZXhlY3V0aW9uX2ZpbmlzaGVkGAQgASgLMicub2xpdmV0aW4uYXBpLnYxLkV2ZW50RXhlY3V0aW9uRmluaXNoZWRIABJDChFleGVjdXRpb25fc3RhcnRlZBgFIAEoCzImLm9saXZldGluLmFwaS52MS5FdmVudEV4ZWN1dGlvblN0YXJ0ZWRIABI5CgxvdXRwdXRfY2h1bmsYBiABKAsyIS5vbGl2ZXRpbi5hcGkudjEuRXZlbnRPdXRwdXRDaHVua0gAEjQKCWhlYXJ0YmVhdBgHIAEoCzIfLm9saXZldGluLmFwaS52MS5FdmVudEhlYXJ0YmVhdEgAQgcKBWV2ZW50IkEKEEV2ZW50T3V0cHV0Q2h1bmsSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJEg4KBm91dHB1dBgCIAEoCSIUChJFdmVudEVudGl0eUNoYW5nZWQiFAoSRXZlbnRDb25maWdDaGFuZ2VkIhAKDkV2ZW50SGVhcnRiZWF0IkYKFkV2ZW50RXhlY3V0aW9uRmluaXNoZWQSLAoJbG9nX2VudHJ5GAEgASgLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5IkUKFUV2ZW50RXhlY3V0aW9uU3RhcnRlZBIsCglsb2dfZW50cnkYASABKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkiMgoRS2lsbEFjdGlvblJlcXVlc3QSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJIm0KEktpbGxBY3Rpb25SZXNwb25zZRIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYASABKAkSDgoGa2lsbGVkGAIgASgIEhkKEWFscmVhZHlfY29tcGxldGVkGAMgASgIEg0KBWZvdW5kGAQgASgIIjsKFUxvY2FsVXNlckxvZ2luUmVxdWVzdBIQCgh1c2VybmFtZRgBIAEoCRIQCghwYXNzd29yZBgCIAEoCSIpChZMb2NhbFVzZXJMb2dpblJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgiJwoTUGFzc3dvcmRIYXNoUmVxdWVzdBIQCghwYXNzd29yZBgBIAEoCSIkChRQYXNzd29yZEhhc2hSZXNwb25zZRIMCgRoYXNoGAEgASgJIg8KDUxvZ291dFJlcXVlc3QiEAoOTG9nb3V0UmVzcG9uc2UiFwoVR2V0RGlhZ25vc3RpY3NSZXF1ZXN0IkUKFkdldERpYWdub3N0aWNzUmVzcG9uc2USEwoLU3NoRm91bmRLZXkYASABKAkSFgoOU3NoRm91bmRDb25maWcYAiABKAkiDQoLSW5pdFJlcXVlc3Qi6wUKDEluaXRSZXNwb25zZRISCgpzaG93Rm9vdGVyGAEgASgIEhYKDnNob3dOYXZpZ2F0aW9uGAIgASgIEhcKD3Nob3dOZXdWZXJzaW9ucxgDIAEoCBIYChBhdmFpbGFibGVWZXJzaW9uGAQgASgJEhYKDmN1cnJlbnRWZXJzaW9uGAUgASgJEhEKCXBhZ2VUaXRsZRgGIAEoCRIeChZzZWN0aW9uTmF2aWdhdGlvblN0eWxlGAcgASgJEhoKEmRlZmF1bHRJY29uRm9yQmFjaxgIIAEoCRIWCg5lbmFibGVDdXN0b21KcxgJIAEoCBIUCgxhdXRoTG9naW5VcmwYCiABKAkSFgoOYXV0aExvY2FsTG9naW4YCyABKAgSEQoJc3R5bGVNb2RzGAwgAygJEjgKD29BdXRoMlByb3ZpZGVycxgNIAMoCzIfLm9saXZldGluLmFwaS52MS5PQXV0aDJQcm92aWRlchI4Cg9hZGRpdGlvbmFsTGlua3MYDiADKAsyHy5vbGl2ZXRpbi5hcGkudjEuQWRkaXRpb25hbExpbmsSFgoOcm9vdERhc2hib2FyZHMYDyADKAkSGgoSYXV0aGVudGljYXRlZF91c2VyGBAgASgJEiMKG2F1dGhlbnRpY2F0ZWRfdXNlcl9wcm92aWRlchgRIAEoCRI6ChBlZmZlY3RpdmVfcG9saWN5GBIgASgLMiAub2xpdmV0aW4uYXBpLnYxLkVmZmVjdGl2ZVBvbGljeRIWCg5iYW5uZXJfbWVzc2FnZRgTIAEoCRISCgpiYW5uZXJfY3NzGBQgASgJEhgKEHNob3dfZGlhZ25vc3RpY3MYFSABKAgSFQoNc2hvd19sb2dfbGlzdBgWIAEoCBIWCg5sb2dpbl9yZXF1aXJlZBgXIAEoCBIYChBhdmFpbGFibGVfdGhlbWVzGBggAygJEiQKHHNob3dfbmF2aWdhdGVfb25fc3RhcnRfaWNvbnMYGSABKAgiLAoOQWRkaXRpb25hbExpbmsSDQoFdGl0bGUYASABKAkSCwoDdXJsGAIgASgJIjoKDk9BdXRoMlByb3ZpZGVyEg0KBXRpdGxlGAEgASgJEgwKBGljb24YAyABKAkSCwoDa2V5GAQgASgJIi0KF0dldEFjdGlvbkJpbmRpbmdSZXF1ZXN0EhIKCmJpbmRpbmdfaWQYASABKAkiiwEKGEdldEFjdGlvbkJpbmRpbmdSZXNwb25zZRInCgZhY3Rpb24YASABKAsyFy5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uEkYKEmJhY2tfdG9fZGFzaGJvYXJkcxgCIAMoCzIqLm9saXZldGluLmFwaS52MS5EYXNoYm9hcmROYXZpZ2F0aW9uVGFyZ2V0IhQKEkdldEVudGl0aWVzUmVxdWVzdCJUChNHZXRFbnRpdGllc1Jlc3BvbnNlEj0KEmVudGl0eV9kZWZpbml0aW9ucxgBIAMoCzIhLm9saXZldGluLmFwaS52MS5FbnRpdHlEZWZpbml0aW9uImkKEEVudGl0eURlZmluaXRpb24SDQoFdGl0bGUYASABKAkSKgoJaW5zdGFuY2VzGAIgAygLMhcub2xpdmV0aW4uYXBpLnYxLkVudGl0eRIaChJ1c2VkX29uX2Rhc2hib2FyZHMYAyADKAkiNAoQR2V0RW50aXR5UmVxdWVzdBISCgp1bmlxdWVfa2V5GAEgASgJEgwKBHR5cGUYAiABKAkiNQoUUmVzdGFydEFjdGlvblJlcXVlc3QSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJMtYTChJPbGl2ZVRpbkFwaVNlcnZpY2USXQoMR2V0RGFzaGJvYXJkEiQub2xpdmV0aW4uYXBpLnYxLkdldERhc2hib2FyZFJlcXVlc3QaJS5vbGl2ZXRpbi5hcGkudjEuR2V0RGFzaGJvYXJkUmVzcG9uc2UiABJaCgtTdGFydEFjdGlvbhIjLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvblJlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25SZXNwb25zZSIAEm8KElN0YXJ0QWN0aW9uQW5kV2FpdBIqLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkFuZFdhaXRSZXF1ZXN0Gisub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQW5kV2FpdFJlc3BvbnNlIgASaQoQU3RhcnRBY3Rpb25CeUdldBIoLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkJ5R2V0UmVxdWVzdBopLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkJ5R2V0UmVzcG9uc2UiABJ+ChdTdGFydEFjdGlvbkJ5R2V0QW5kV2FpdBIvLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkJ5R2V0QW5kV2FpdFJlcXVlc3QaMC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25CeUdldEFuZFdhaXRSZXNwb25zZSIAEl4KDVJlc3RhcnRBY3Rpb24SJS5vbGl2ZXRpbi5hcGkudjEuUmVzdGFydEFjdGlvblJlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25SZXNwb25zZSIAElcKCktpbGxBY3Rpb24SIi5vbGl2ZXRpbi5hcGkudjEuS2lsbEFjdGlvblJlcXVlc3QaIy5vbGl2ZXRpbi5hcGkudjEuS2lsbEFjdGlvblJlc3BvbnNlIgASZgoPRXhlY3V0aW9uU3RhdHVzEicub2xpdmV0aW4uYXBpLnYxLkV4ZWN1dGlvblN0YXR1c1JlcXVlc3QaKC5vbGl2ZXRpbi5hcGkudjEuRXhlY3V0aW9uU3RhdHVzUmVzcG9uc2UiABJOCgdHZXRMb2dzEh8ub2xpdmV0aW4uYXBpLnYxLkdldExvZ3NSZXF1ZXN0GiAub2xpdmV0aW4uYXBpLnYxLkdldExvZ3NSZXNwb25zZSIAEmAKDUdldEFjdGlvbkxvZ3MSJS5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uTG9nc1JlcXVlc3QaJi5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uTG9nc1Jlc3BvbnNlIgASbAoRR2V0RXhlY3V0aW9uUXVldWUSKS5vbGl2ZXRpbi5hcGkudjEuR2V0RXhlY3V0aW9uUXVldWVSZXF1ZXN0Gioub2xpdmV0aW4uYXBpLnYxLkdldEV4ZWN1dGlvblF1ZXVlUmVzcG9uc2UiABJ1ChRWYWxpZGF0ZUFyZ3VtZW50VHlwZRIsLm9saXZldGluLmFwaS52MS5WYWxpZGF0ZUFyZ3VtZW50VHlwZVJlcXVlc3QaLS5vbGl2ZXRpbi5hcGkudjEuVmFsaWRhdGVBcmd1bWVudFR5cGVSZXNwb25zZSIAEksKBldob0FtSRIeLm9saXZldGluLmFwaS52MS5XaG9BbUlSZXF1ZXN0Gh8ub2xpdmV0aW4uYXBpLnYxLldob0FtSVJlc3BvbnNlIgASVAoJU29zUmVwb3J0EiEub2xpdmV0aW4uYXBpLnYxLlNvc1JlcG9ydFJlcXVlc3QaIi5vbGl2ZXRpbi5hcGkudjEuU29zUmVwb3J0UmVzcG9uc2UiABJRCghEdW1wVmFycxIgLm9saXZldGluLmFwaS52MS5EdW1wVmFyc1JlcXVlc3QaIS5vbGl2ZXRpbi5hcGkudjEuRHVtcFZhcnNSZXNwb25zZSIAEngKFUR1bXBQdWJsaWNJZEFjdGlvbk1hcBItLm9saXZldGluLmFwaS52MS5EdW1wUHVibGljSWRBY3Rpb25NYXBSZXF1ZXN0Gi4ub2xpdmV0aW4uYXBpLnYxLkR1bXBQdWJsaWNJZEFjdGlvbk1hcFJlc3BvbnNlIgASVAoJR2V0UmVhZHl6EiEub2xpdmV0aW4uYXBpLnYxLkdldFJlYWR5elJlcXVlc3QaIi5vbGl2ZXRpbi5hcGkudjEuR2V0UmVhZHl6UmVzcG9uc2UiABJjCg5Mb2NhbFVzZXJMb2dpbhImLm9saXZldGluLmFwaS52MS5Mb2NhbFVzZXJMb2dpblJlcXVlc3QaJy5vbGl2ZXRpbi5hcGkudjEuTG9jYWxVc2VyTG9naW5SZXNwb25zZSIAEl0KDFBhc3N3b3JkSGFzaBIkLm9saXZldGluLmFwaS52MS5QYXNzd29yZEhhc2hSZXF1ZXN0GiUub2xpdmV0aW4uYXBpLnYxLlBhc3N3b3JkSGFzaFJlc3BvbnNlIgASSwoGTG9nb3V0Eh4ub2xpdmV0aW4uYXBpLnYxLkxvZ291dFJlcXVlc3QaHy5vbGl2ZXRpbi5hcGkudjEuTG9nb3V0UmVzcG9uc2UiABJcCgtFdmVudFN0cmVhbRIjLm9saXZldGluLmFwaS52MS5FdmVudFN0cmVhbVJlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuRXZlbnRTdHJlYW1SZXNwb25zZSIAMAESYwoOR2V0RGlhZ25vc3RpY3MSJi5vbGl2ZXRpbi5hcGkudjEuR2V0RGlhZ25vc3RpY3NSZXF1ZXN0Gicub2xpdmV0aW4uYXBpLnYxLkdldERpYWdub3N0aWNzUmVzcG9uc2UiABJFCgRJbml0Ehwub2xpdmV0aW4uYXBpLnYxLkluaXRSZXF1ZXN0Gh0ub2xpdmV0aW4uYXBpLnYxLkluaXRSZXNwb25zZSIAEmkKEEdldEFjdGlvbkJpbmRpbmcSKC5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uQmluZGluZ1JlcXVlc3QaKS5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uQmluZGluZ1Jlc3BvbnNlIgASWgoLR2V0RW50aXRpZXMSIy5vbGl2ZXRpbi5hcGkudjEuR2V0RW50aXRpZXNSZXF1ZXN0GiQub2xpdmV0aW4uYXBpLnYxLkdldEVudGl0aWVzUmVzcG9uc2UiABJJCglHZXRFbnRpdHkSIS5vbGl2ZXRpbi5hcGkudjEuR2V0RW50aXR5UmVxdWVzdBoXLm9saXZldGluLmFwaS52MS5FbnRpdHkiAEI4WjZnaXRodWIuY29tL09saXZlVGluL09saXZlVGluL2dlbi9vbGl2ZXRpbi9hcGkvdjE7YXBpdjFiBnByb3RvMw"); /** * Describes the message olivetin.api.v1.Action. diff --git a/frontend/resources/vue/utils/rerunArguments.js b/frontend/resources/vue/utils/rerunArguments.js new file mode 100644 index 0000000..82499e9 --- /dev/null +++ b/frontend/resources/vue/utils/rerunArguments.js @@ -0,0 +1,59 @@ +import { needsArgumentForm } from './needsArgumentForm.js' + +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 (arg.type === 'password') { + 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 +} diff --git a/frontend/resources/vue/utils/rerunArguments.test.mjs b/frontend/resources/vue/utils/rerunArguments.test.mjs new file mode 100644 index 0000000..b5725f2 --- /dev/null +++ b/frontend/resources/vue/utils/rerunArguments.test.mjs @@ -0,0 +1,103 @@ +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 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' + } + ) +}) diff --git a/proto/olivetin/api/v1/olivetin.proto b/proto/olivetin/api/v1/olivetin.proto index c888c2e..068ef69 100644 --- a/proto/olivetin/api/v1/olivetin.proto +++ b/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 { diff --git a/service/gen/olivetin/api/v1/olivetin.pb.go b/service/gen/olivetin/api/v1/olivetin.pb.go index 3505a1c..89c4165 100644 --- a/service/gen/olivetin/api/v1/olivetin.pb.go +++ b/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() } diff --git a/service/internal/api/api.go b/service/internal/api/api.go index 5c3d240..736d639 100644 --- a/service/internal/api/api.go +++ b/service/internal/api/api.go @@ -7,6 +7,7 @@ import ( "os" "path" "sort" + "strings" "connectrpc.com/connect" "google.golang.org/protobuf/encoding/protojson" @@ -388,6 +389,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 +1550,15 @@ func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv return nil, err } - if execReqLogEntry.Binding.Action.Justification { + if execReqLogEntry.Binding.Action.Justification && strings.TrimSpace(execReqLogEntry.Justification) == "" { return nil, restartRequiresJustificationError() } 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, } diff --git a/service/internal/api/api_log_arguments.go b/service/internal/api/api_log_arguments.go new file mode 100644 index 0000000..442cc89 --- /dev/null +++ b/service/internal/api/api_log_arguments.go @@ -0,0 +1,38 @@ +package api + +import ( + "sort" + + apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" +) + +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 +} diff --git a/service/internal/api/api_log_arguments_test.go b/service/internal/api/api_log_arguments_test.go new file mode 100644 index 0000000..68ca4e5 --- /dev/null +++ b/service/internal/api/api_log_arguments_test.go @@ -0,0 +1,307 @@ +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 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") +} diff --git a/service/internal/executor/executor.go b/service/internal/executor/executor.go index 605c7ad..2ad1856 100644 --- a/service/internal/executor/executor.go +++ b/service/internal/executor/executor.go @@ -171,6 +171,7 @@ type InternalLogEntry struct { ActionTitle string ActionIcon string Justification string + Arguments map[string]string } // .Binding can be nil, so we need to handle that. @@ -863,6 +864,7 @@ func stepParseArgs(req *ExecutionRequest) bool { return fail(req, err) } mangleInvalidArgumentValues(req) + copyStorableArgumentsToLogEntry(req) if hasExec(req) { return handleExecBranch(req) diff --git a/service/internal/executor/log_arguments.go b/service/internal/executor/log_arguments.go new file mode 100644 index 0000000..0c71b30 --- /dev/null +++ b/service/internal/executor/log_arguments.go @@ -0,0 +1,95 @@ +package executor + +import ( + "strings" + + config "github.com/OliveTin/OliveTin/internal/config" +) + +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 + }) +} diff --git a/service/internal/executor/log_arguments_test.go b/service/internal/executor/log_arguments_test.go new file mode 100644 index 0000000..9a3ef32 --- /dev/null +++ b/service/internal/executor/log_arguments_test.go @@ -0,0 +1,121 @@ +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"]) +} From e63094e8e8478de202d7ad5e81b255997165c821 Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 28 Jun 2026 22:15:46 +0100 Subject: [PATCH 12/16] fix: several merge messes --- .../vue/components/ChoiceCombobox.vue | 32 +++++++++------ .../vue/components/choiceComboboxHelpers.js | 33 +++++++++++++++ .../components/choiceComboboxHelpers.test.mjs | 40 +++++++++++++++++++ .../multipleDropdowns/multipleDropdowns.js | 14 ++++--- service/Makefile | 4 +- .../internal/config/config_reloader_test.go | 40 ++++++++++++------- var/windows/build-msi.sh | 6 +-- var/windows/generate-resources.sh | 8 ++-- var/windows/upload-msi-release.sh | 2 +- 9 files changed, 136 insertions(+), 43 deletions(-) create mode 100644 frontend/resources/vue/components/choiceComboboxHelpers.js create mode 100644 frontend/resources/vue/components/choiceComboboxHelpers.test.mjs diff --git a/frontend/resources/vue/components/ChoiceCombobox.vue b/frontend/resources/vue/components/ChoiceCombobox.vue index e2488b9..d19ec35 100644 --- a/frontend/resources/vue/components/ChoiceCombobox.vue +++ b/frontend/resources/vue/components/ChoiceCombobox.vue @@ -52,6 +52,10 @@